Skip to main content

par_term_mcp/
lib.rs

1//! Minimal MCP (Model Context Protocol) server over stdio.
2//!
3//! Reads line-delimited JSON-RPC 2.0 from stdin and writes responses to stdout.
4//! Exposes tools for par-term ACP integrations:
5//! - `config_update`: writes configuration changes to a file for the main app
6//!   to pick up
7//! - `terminal_screenshot`: requests a live terminal screenshot from the app
8//!   via a file-based IPC handshake (with an optional fallback image path for
9//!   non-GUI test harnesses)
10//! - `shader_diagnostics`: requests live shader state and last compile/reload
11//!   errors from the running app via file-based IPC
12//!
13//! # Module layout
14//!
15//! - [`jsonrpc`] — JSON-RPC 2.0 wire types, response helpers, and stdout framing
16//! - [`ipc`] — IPC path resolution, atomic writes, and restricted-permission helpers
17//! - [`tools`] — tool registration, descriptors, and dispatch
18//! - [`tools::config_update`] — `config_update` tool handler
19//! - [`tools::screenshot`] — `terminal_screenshot` tool handler
20//! - [`tools::diagnostics`] — `shader_diagnostics` tool handler
21//!
22//! # SEC-008: Trust Boundary — stdin/stdout IPC Channel
23//!
24//! This MCP server communicates over stdin and stdout using JSON-RPC 2.0.
25//! There is **no authentication, encryption, or integrity verification** on
26//! this IPC channel. Any process that can write to the MCP server's stdin can
27//! invoke any tool (including `config_update`, which writes to the user's
28//! configuration file on disk).
29//!
30//! **The stdin/stdout channel is a trust boundary.** Only trusted MCP client
31//! processes (i.e., ACP agents that par-term itself has spawned) should be
32//! connected to this server. The caller is responsible for ensuring that:
33//!
34//! 1. The MCP server process is only spawned by par-term's ACP subsystem.
35//! 2. The stdin pipe is not shared with or writable by untrusted processes.
36//! 3. Agent TOML files (which define which agents are launched) are treated as
37//!    a trust boundary — only install agents from sources you trust.
38//!
39//! The file-based IPC paths used for screenshot and diagnostics requests use
40//! restrictive permissions (0o600) to prevent unauthorized reads or writes,
41//! but the stdin/stdout channel itself has no such protection.
42
43pub mod ipc;
44pub mod jsonrpc;
45pub mod tools;
46
47use serde::{Deserialize, Serialize};
48use std::io::BufRead;
49use std::sync::OnceLock;
50
51use jsonrpc::{IncomingMessage, method_not_found, parse_error, send_response, success_response};
52use tools::{handle_tools_call, handle_tools_list};
53
54// ---------------------------------------------------------------------------
55// Protocol constants (pub(crate) so submodules can access them)
56// ---------------------------------------------------------------------------
57
58/// MCP protocol version.
59pub(crate) const PROTOCOL_VERSION: &str = "2024-11-05";
60
61/// Server name reported during initialization.
62pub(crate) const SERVER_NAME: &str = "par-term";
63
64/// Application version set by the main crate.
65/// Use `set_app_version()` to initialize this before calling `run_mcp_server()`.
66static APP_VERSION: OnceLock<String> = OnceLock::new();
67
68/// Set the application version (should be called from the main crate with
69/// the root crate's `VERSION` constant before running the MCP server).
70pub fn set_app_version(version: impl Into<String>) {
71    let _ = APP_VERSION.set(version.into());
72}
73
74/// Get the application version, falling back to the crate version if not set.
75pub(crate) fn get_app_version() -> &'static str {
76    APP_VERSION
77        .get()
78        .map(|s| s.as_str())
79        .unwrap_or(env!("CARGO_PKG_VERSION"))
80}
81
82/// Handle the `initialize` JSON-RPC request.
83fn handle_initialize() -> serde_json::Value {
84    serde_json::json!({
85        "protocolVersion": PROTOCOL_VERSION,
86        "capabilities": {
87            "tools": {}
88        },
89        "serverInfo": {
90            "name": SERVER_NAME,
91            "version": get_app_version()
92        }
93    })
94}
95
96/// Environment variable for overriding the config update file path.
97pub const CONFIG_UPDATE_PATH_ENV: &str = "PAR_TERM_CONFIG_UPDATE_PATH";
98/// Environment variable for screenshot request IPC file path.
99pub const SCREENSHOT_REQUEST_PATH_ENV: &str = "PAR_TERM_SCREENSHOT_REQUEST_PATH";
100/// Environment variable for screenshot response IPC file path.
101pub const SCREENSHOT_RESPONSE_PATH_ENV: &str = "PAR_TERM_SCREENSHOT_RESPONSE_PATH";
102/// Environment variable for shader diagnostics request IPC file path.
103pub const SHADER_DIAGNOSTICS_REQUEST_PATH_ENV: &str = "PAR_TERM_SHADER_DIAGNOSTICS_REQUEST_PATH";
104/// Environment variable for shader diagnostics response IPC file path.
105pub const SHADER_DIAGNOSTICS_RESPONSE_PATH_ENV: &str = "PAR_TERM_SHADER_DIAGNOSTICS_RESPONSE_PATH";
106/// Optional environment variable for a static fallback screenshot file path.
107/// Used by the ACP harness to test the screenshot tool flow without a GUI.
108pub const SCREENSHOT_FALLBACK_PATH_ENV: &str = "PAR_TERM_SCREENSHOT_FALLBACK_PATH";
109
110/// Default config update filename (relative to config dir).
111pub const CONFIG_UPDATE_FILENAME: &str = ".config-update.json";
112/// Default screenshot request filename (relative to config dir).
113pub const SCREENSHOT_REQUEST_FILENAME: &str = ".screenshot-request.json";
114/// Default screenshot response filename (relative to config dir).
115pub const SCREENSHOT_RESPONSE_FILENAME: &str = ".screenshot-response.json";
116/// Default shader diagnostics request filename (relative to config dir).
117pub const SHADER_DIAGNOSTICS_REQUEST_FILENAME: &str = ".shader-diagnostics-request.json";
118/// Default shader diagnostics response filename (relative to config dir).
119pub const SHADER_DIAGNOSTICS_RESPONSE_FILENAME: &str = ".shader-diagnostics-response.json";
120
121/// Screenshot request written by the MCP server for the GUI app to fulfill.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TerminalScreenshotRequest {
124    pub request_id: String,
125}
126
127/// Screenshot response written by the GUI app for the MCP server to read.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct TerminalScreenshotResponse {
130    pub request_id: String,
131    pub ok: bool,
132    #[serde(default)]
133    pub error: Option<String>,
134    #[serde(default)]
135    pub mime_type: Option<String>,
136    #[serde(default)]
137    pub data_base64: Option<String>,
138    #[serde(default)]
139    pub width: Option<u32>,
140    #[serde(default)]
141    pub height: Option<u32>,
142}
143
144/// Shader diagnostics request written by the MCP server for the GUI app to fulfill.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ShaderDiagnosticsRequest {
147    pub request_id: String,
148}
149
150/// Per-shader diagnostics included in [`ShaderDiagnosticsResponse`].
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct ShaderDiagnosticsEntry {
153    pub shader: Option<String>,
154    pub enabled: bool,
155    pub last_error: Option<String>,
156    pub wgsl_path: Option<String>,
157}
158
159/// Live shader diagnostics returned by the GUI app.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ShaderDiagnostics {
162    pub background: ShaderDiagnosticsEntry,
163    pub cursor: ShaderDiagnosticsEntry,
164    pub shaders_dir: String,
165    pub wrapped_glsl_path: String,
166}
167
168/// Shader diagnostics response written by the GUI app for the MCP server to read.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ShaderDiagnosticsResponse {
171    pub request_id: String,
172    pub ok: bool,
173    #[serde(default)]
174    pub error: Option<String>,
175    #[serde(default)]
176    pub diagnostics: Option<ShaderDiagnostics>,
177}
178
179// Re-export IPC path helpers so callers don't need to name the submodule.
180pub use ipc::{
181    screenshot_request_path, screenshot_response_path, shader_diagnostics_request_path,
182    shader_diagnostics_response_path,
183};
184
185/// Run the MCP server loop. Reads JSON-RPC messages from stdin until the
186/// stream is closed or an I/O error occurs, then returns normally so that
187/// callers can run destructors and exit cleanly.
188pub fn run_mcp_server() {
189    let version = get_app_version();
190    eprintln!("[mcp-server] Starting par-term MCP server v{version}");
191
192    let stdin = std::io::stdin();
193    let mut stdout = std::io::stdout();
194    let reader = stdin.lock();
195
196    for line in reader.lines() {
197        let line = match line {
198            Ok(l) => l,
199            Err(e) => {
200                eprintln!("[mcp-server] Error reading stdin: {e}");
201                break;
202            }
203        };
204
205        let trimmed = line.trim();
206        if trimmed.is_empty() {
207            continue;
208        }
209
210        eprintln!("[mcp-server] <- {trimmed}");
211
212        let msg: IncomingMessage = match serde_json::from_str(trimmed) {
213            Ok(m) => m,
214            Err(e) => {
215                eprintln!("[mcp-server] Parse error: {e}");
216                send_response(&mut stdout, &parse_error());
217                continue;
218            }
219        };
220
221        let method = match &msg.method {
222            Some(m) => m.as_str(),
223            None => {
224                // No method field — not a request or notification we handle
225                eprintln!("[mcp-server] Ignoring message without method");
226                continue;
227            }
228        };
229
230        // Check if this is a notification (no id) — notifications don't get responses
231        let id = match msg.id {
232            Some(id) => id,
233            None => {
234                eprintln!("[mcp-server] Notification: {method}");
235                // No response for notifications
236                continue;
237            }
238        };
239
240        // Dispatch the request
241        let response = match method {
242            "initialize" => success_response(id, handle_initialize()),
243            "tools/list" => success_response(id, handle_tools_list()),
244            "tools/call" => success_response(id, handle_tools_call(msg.params)),
245            _ => method_not_found(id, method),
246        };
247
248        eprintln!(
249            "[mcp-server] -> {}",
250            serde_json::to_string(&response).unwrap_or_else(|_| "<serialization error>".into())
251        );
252
253        send_response(&mut stdout, &response);
254    }
255
256    eprintln!("[mcp-server] stdin closed, exiting");
257}
258
259// ---------------------------------------------------------------------------
260// Tests
261// ---------------------------------------------------------------------------
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use ipc::{config_update_path, set_ipc_file_permissions, write_json_atomic};
267    use jsonrpc::{IncomingMessage, method_not_found, parse_error, success_response};
268    use std::path::PathBuf;
269    use tools::config_update::write_config_updates;
270    use tools::diagnostics::diagnostics_tool_result;
271    use tools::screenshot::image_tool_result_from_file;
272
273    #[test]
274    fn test_handle_initialize() {
275        let result = handle_initialize();
276        assert_eq!(result["protocolVersion"], PROTOCOL_VERSION);
277        assert!(result["capabilities"]["tools"].is_object());
278        assert_eq!(result["serverInfo"]["name"], SERVER_NAME);
279    }
280
281    #[test]
282    fn test_handle_tools_list() {
283        let result = handle_tools_list();
284        let tools = result["tools"].as_array().unwrap();
285        assert_eq!(tools.len(), 3);
286        let names: Vec<_> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
287        assert!(names.contains(&"config_update"));
288        assert!(names.contains(&"terminal_screenshot"));
289        assert!(names.contains(&"shader_diagnostics"));
290        for tool in tools {
291            assert!(tool["inputSchema"].is_object());
292        }
293    }
294
295    #[test]
296    fn test_handle_tools_call_unknown_tool() {
297        let params = serde_json::json!({
298            "name": "nonexistent_tool",
299            "arguments": {}
300        });
301        let result = handle_tools_call(Some(params));
302        assert_eq!(result["isError"], true);
303        assert!(
304            result["content"][0]["text"]
305                .as_str()
306                .unwrap()
307                .contains("Unknown tool")
308        );
309    }
310
311    #[test]
312    fn test_handle_tools_call_missing_params() {
313        let result = handle_tools_call(None);
314        assert_eq!(result["isError"], true);
315    }
316
317    #[test]
318    fn test_handle_config_update_missing_updates() {
319        let params = serde_json::json!({
320            "name": "config_update",
321            "arguments": {}
322        });
323        let result = handle_tools_call(Some(params));
324        assert_eq!(result["isError"], true);
325        assert!(
326            result["content"][0]["text"]
327                .as_str()
328                .unwrap()
329                .contains("Missing 'updates'")
330        );
331    }
332
333    #[test]
334    fn test_handle_config_update_invalid_updates_type() {
335        let params = serde_json::json!({
336            "name": "config_update",
337            "arguments": {
338                "updates": "not an object"
339            }
340        });
341        let result = handle_tools_call(Some(params));
342        assert_eq!(result["isError"], true);
343        assert!(
344            result["content"][0]["text"]
345                .as_str()
346                .unwrap()
347                .contains("must be a JSON object")
348        );
349    }
350
351    #[test]
352    fn test_handle_config_update_success() {
353        // Use a temp directory to avoid touching real config
354        let dir = tempfile::tempdir().unwrap();
355        let update_path = dir.path().join("test-update.json");
356
357        let updates = serde_json::json!({
358            "font_size": 14.0,
359            "custom_shader_enabled": true
360        });
361        let result = write_config_updates(&updates, &update_path);
362
363        // Should not be an error
364        assert!(result.get("isError").is_none());
365        assert!(
366            result["content"][0]["text"]
367                .as_str()
368                .unwrap()
369                .contains("Successfully")
370        );
371
372        // Verify the file was written
373        let written: serde_json::Value =
374            serde_json::from_str(&std::fs::read_to_string(&update_path).unwrap()).unwrap();
375        assert_eq!(written["font_size"], 14.0);
376        assert_eq!(written["custom_shader_enabled"], true);
377    }
378
379    #[test]
380    fn test_success_response_format() {
381        let resp = success_response(
382            serde_json::Value::Number(1.into()),
383            serde_json::json!({"ok": true}),
384        );
385        let json = serde_json::to_value(&resp).unwrap();
386        assert_eq!(json["jsonrpc"], "2.0");
387        assert_eq!(json["id"], 1);
388        assert_eq!(json["result"]["ok"], true);
389        assert!(json.get("error").is_none());
390    }
391
392    #[test]
393    fn test_method_not_found_response() {
394        let resp = method_not_found(serde_json::Value::Number(5.into()), "bogus/method");
395        let json = serde_json::to_value(&resp).unwrap();
396        assert_eq!(json["jsonrpc"], "2.0");
397        assert_eq!(json["id"], 5);
398        assert_eq!(json["error"]["code"], -32601);
399        assert!(
400            json["error"]["message"]
401                .as_str()
402                .unwrap()
403                .contains("bogus/method")
404        );
405    }
406
407    #[test]
408    fn test_parse_error_response() {
409        let resp = parse_error();
410        let json = serde_json::to_value(&resp).unwrap();
411        assert_eq!(json["jsonrpc"], "2.0");
412        assert!(json["id"].is_null());
413        assert_eq!(json["error"]["code"], -32700);
414    }
415
416    #[test]
417    fn test_config_update_path_env_override_and_default() {
418        // Test env var override
419        //
420        // SAFETY: `std::env::set_var` / `remove_var` are `unsafe` in Rust 2024 because
421        // they are not thread-safe. This is acceptable in test code because:
422        // (a) `CONFIG_UPDATE_PATH_ENV` is a unique, test-specific environment variable
423        //     that is not read by any other concurrently-executing test in this crate,
424        // (b) the variable is unset again at the end of this test body, and
425        // (c) this code is only compiled in `#[cfg(test)]` and never runs in production.
426        unsafe {
427            std::env::set_var(CONFIG_UPDATE_PATH_ENV, "/tmp/test-par-term-update.json");
428        }
429        let path = config_update_path();
430        assert_eq!(path, PathBuf::from("/tmp/test-par-term-update.json"));
431
432        // Test default path (env var unset)
433        // SAFETY: see set_var comment above.
434        unsafe {
435            std::env::remove_var(CONFIG_UPDATE_PATH_ENV);
436        }
437        let path = config_update_path();
438        let path_str = path.to_str().unwrap();
439        assert!(
440            path_str.contains("par-term"),
441            "Expected path to contain 'par-term', got: {path_str}"
442        );
443        assert!(
444            path_str.ends_with(CONFIG_UPDATE_FILENAME),
445            "Expected path to end with '{CONFIG_UPDATE_FILENAME}', got: {path_str}"
446        );
447    }
448
449    #[test]
450    fn test_shader_diagnostics_paths_env_override_and_default() {
451        // SAFETY: `std::env::set_var` / `remove_var` are `unsafe` in Rust 2024 because
452        // they are not thread-safe. The diagnostics env vars are unique to this test
453        // and are removed before the test returns.
454        unsafe {
455            std::env::set_var(
456                SHADER_DIAGNOSTICS_REQUEST_PATH_ENV,
457                "/tmp/test-par-term-shader-diag-req.json",
458            );
459            std::env::set_var(
460                SHADER_DIAGNOSTICS_RESPONSE_PATH_ENV,
461                "/tmp/test-par-term-shader-diag-resp.json",
462            );
463        }
464        assert_eq!(
465            shader_diagnostics_request_path(),
466            PathBuf::from("/tmp/test-par-term-shader-diag-req.json")
467        );
468        assert_eq!(
469            shader_diagnostics_response_path(),
470            PathBuf::from("/tmp/test-par-term-shader-diag-resp.json")
471        );
472
473        // SAFETY: see set_var comment above.
474        unsafe {
475            std::env::remove_var(SHADER_DIAGNOSTICS_REQUEST_PATH_ENV);
476            std::env::remove_var(SHADER_DIAGNOSTICS_RESPONSE_PATH_ENV);
477        }
478        assert!(
479            shader_diagnostics_request_path()
480                .to_string_lossy()
481                .ends_with(SHADER_DIAGNOSTICS_REQUEST_FILENAME)
482        );
483        assert!(
484            shader_diagnostics_response_path()
485                .to_string_lossy()
486                .ends_with(SHADER_DIAGNOSTICS_RESPONSE_FILENAME)
487        );
488    }
489
490    #[test]
491    fn test_diagnostics_tool_result_includes_shader_errors_and_paths() {
492        let response = ShaderDiagnosticsResponse {
493            request_id: "req-1".to_string(),
494            ok: true,
495            error: None,
496            diagnostics: Some(ShaderDiagnostics {
497                background: ShaderDiagnosticsEntry {
498                    shader: Some("bad.glsl".to_string()),
499                    enabled: true,
500                    last_error: Some("naga validation failed".to_string()),
501                    wgsl_path: Some("/tmp/par_term_bad_shader.wgsl".to_string()),
502                },
503                cursor: ShaderDiagnosticsEntry {
504                    shader: None,
505                    enabled: false,
506                    last_error: None,
507                    wgsl_path: None,
508                },
509                shaders_dir: "/Users/example/.config/par-term/shaders".to_string(),
510                wrapped_glsl_path: "/tmp/par_term_debug_wrapped.glsl".to_string(),
511            }),
512        };
513
514        let result = diagnostics_tool_result(response);
515
516        assert!(result.get("isError").is_none());
517        let text = result["content"][0]["text"].as_str().unwrap();
518        assert!(text.contains("bad.glsl"));
519        assert!(text.contains("naga validation failed"));
520        assert!(text.contains("/tmp/par_term_bad_shader.wgsl"));
521        assert!(text.contains("shader_diagnostics"));
522    }
523
524    #[test]
525    fn test_screenshot_paths_env_override_and_default() {
526        // SAFETY: `std::env::set_var` / `remove_var` are `unsafe` in Rust 2024 because
527        // they are not thread-safe. This is acceptable here because:
528        // (a) `SCREENSHOT_REQUEST_PATH_ENV` and `SCREENSHOT_RESPONSE_PATH_ENV` are
529        //     unique, test-specific keys not shared with other concurrently-running tests,
530        // (b) both variables are unset again later in this same test body, and
531        // (c) this block is only compiled in `#[cfg(test)]` and never runs in production.
532        unsafe {
533            std::env::set_var(
534                SCREENSHOT_REQUEST_PATH_ENV,
535                "/tmp/test-par-term-shot-req.json",
536            );
537            std::env::set_var(
538                SCREENSHOT_RESPONSE_PATH_ENV,
539                "/tmp/test-par-term-shot-resp.json",
540            );
541        }
542        assert_eq!(
543            screenshot_request_path(),
544            PathBuf::from("/tmp/test-par-term-shot-req.json")
545        );
546        assert_eq!(
547            screenshot_response_path(),
548            PathBuf::from("/tmp/test-par-term-shot-resp.json")
549        );
550
551        // SAFETY: see set_var comment above — same reasoning applies to remove_var.
552        unsafe {
553            std::env::remove_var(SCREENSHOT_REQUEST_PATH_ENV);
554            std::env::remove_var(SCREENSHOT_RESPONSE_PATH_ENV);
555        }
556        assert!(
557            screenshot_request_path()
558                .to_string_lossy()
559                .ends_with(SCREENSHOT_REQUEST_FILENAME)
560        );
561        assert!(
562            screenshot_response_path()
563                .to_string_lossy()
564                .ends_with(SCREENSHOT_RESPONSE_FILENAME)
565        );
566    }
567
568    #[test]
569    fn test_image_tool_result_from_file_missing() {
570        let result = image_tool_result_from_file(std::path::Path::new(
571            "/tmp/does-not-exist-terminal-screenshot.png",
572        ));
573        assert_eq!(result["isError"], true);
574    }
575
576    #[test]
577    fn test_incoming_message_notification() {
578        let msg: IncomingMessage =
579            serde_json::from_str(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
580                .unwrap();
581        assert!(msg.id.is_none());
582        assert_eq!(msg.method.as_deref(), Some("notifications/initialized"));
583    }
584
585    #[test]
586    fn test_incoming_message_request() {
587        let msg: IncomingMessage =
588            serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#)
589                .unwrap();
590        assert!(msg.id.is_some());
591        assert_eq!(msg.method.as_deref(), Some("initialize"));
592    }
593
594    #[cfg(unix)]
595    #[test]
596    fn test_set_ipc_file_permissions() {
597        use std::os::unix::fs::PermissionsExt;
598
599        let dir = tempfile::tempdir().unwrap();
600        let path = dir.path().join("ipc-test.json");
601        std::fs::write(&path, "{}").unwrap();
602
603        set_ipc_file_permissions(&path).unwrap();
604
605        let metadata = std::fs::metadata(&path).unwrap();
606        let mode = metadata.permissions().mode() & 0o777;
607        assert_eq!(
608            mode, 0o600,
609            "IPC file should have mode 0o600, got {mode:#o}"
610        );
611    }
612
613    #[cfg(unix)]
614    #[test]
615    fn test_write_config_updates_sets_restrictive_permissions() {
616        use std::os::unix::fs::PermissionsExt;
617
618        let dir = tempfile::tempdir().unwrap();
619        let update_path = dir.path().join("config-update.json");
620
621        let updates = serde_json::json!({"font_size": 14.0});
622        let result = write_config_updates(&updates, &update_path);
623        assert!(result.get("isError").is_none(), "Expected success result");
624
625        let metadata = std::fs::metadata(&update_path).unwrap();
626        let mode = metadata.permissions().mode() & 0o777;
627        assert_eq!(
628            mode, 0o600,
629            "Config update IPC file should have mode 0o600, got {mode:#o}"
630        );
631    }
632
633    #[cfg(unix)]
634    #[test]
635    fn test_write_json_atomic_sets_restrictive_permissions() {
636        use std::os::unix::fs::PermissionsExt;
637
638        let dir = tempfile::tempdir().unwrap();
639        let path = dir.path().join("atomic-test.json");
640
641        let payload = serde_json::json!({"request_id": "test-123"});
642        write_json_atomic(&payload, &path).unwrap();
643
644        let metadata = std::fs::metadata(&path).unwrap();
645        let mode = metadata.permissions().mode() & 0o777;
646        assert_eq!(
647            mode, 0o600,
648            "Atomically written IPC file should have mode 0o600, got {mode:#o}"
649        );
650    }
651}