Skip to main content

grr_cli/
mcp.rs

1//! Model Context Protocol (MCP) server for grr.
2//!
3//! Hand-rolled JSON-RPC 2.0 over stdio — no SDK crate: a tool-only server
4//! needs five methods. Newline-delimited requests in, one response line
5//! per request out, logs to stderr ONLY (stdout is the MCP stream, the
6//! same discipline as the rest of grr).
7//!
8//! * `initialize` — protocol-version negotiation + capabilities
9//! * `notifications/initialized` — a notification; never answered
10//! * `tools/list` — every method in the Discovery index as a tool
11//! * `tools/call` — one method invocation through the shared call path
12//! * `ping` — an empty result
13//!
14//! Tool names are the dotted method ids `grr api call` takes
15//! (`gmail.users.messages.list`), so an agent reaches every method Google
16//! publishes through the same resolution the CLI uses. Errors never kill
17//! the server: a tool failure is an `isError` result (MCP's tool-error
18//! contract), a protocol failure a JSON-RPC error object.
19//!
20//! ```text
21//! {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
22//! {"jsonrpc":"2.0","method":"notifications/initialized"}
23//! {"jsonrpc":"2.0","id":2,"method":"tools/list"}
24//! {"jsonrpc":"2.0","id":3,"method":"tools/call",
25//!  "params":{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}}
26//! ```
27
28use crate::commands::api::{self, call_method};
29use crate::commands::safety::{SafetyProfile, is_write_verb};
30use crate::core::auth::GoogleAuth;
31use crate::discovery::{self, Method, Parameter, Service};
32use serde_json::{Map, Value, json};
33
34/// The protocol version this server speaks. A client asking for a version
35/// we understand gets it echoed; anything else gets this one — MCP's
36/// negotiation rule makes the server's answer authoritative.
37///
38/// `2025-11-25` is the latest revision the official TypeScript SDK ships
39/// (`LATEST_PROTOCOL_VERSION` in @modelcontextprotocol/sdk 1.31.0), and
40/// its handshake is byte-compatible with this server's surface. The newer
41/// spec revision `2026-07-28` REMOVED the `initialize` handshake in favor
42/// of `server/discover` plus per-request version metadata — a different
43/// handshake shape this server does not implement, so it is deliberately
44/// not in the list: echoing it would promise a surface we cannot serve.
45pub(crate) const PROTOCOL_VERSION: &str = "2025-11-25";
46
47/// Versions understood well enough to echo verbatim.
48const SUPPORTED_VERSIONS: [&str; 3] = ["2024-11-05", "2025-06-18", "2025-11-25"];
49
50/// JSON-RPC 2.0 error codes.
51const PARSE_ERROR: i64 = -32700;
52const INVALID_REQUEST: i64 = -32600;
53const METHOD_NOT_FOUND: i64 = -32601;
54
55/// One server instance: one credential, one safety profile, every tool.
56/// The stdio loop is single-tasked, so no `Arc` is needed.
57pub struct McpServer {
58    auth: GoogleAuth,
59    profile: SafetyProfile,
60}
61
62impl McpServer {
63    pub fn new(auth: GoogleAuth, profile: SafetyProfile) -> Self {
64        Self { auth, profile }
65    }
66
67    /// How many tools the Discovery index exposes, for the startup banner.
68    pub fn tool_count(&self) -> usize {
69        discovery::services()
70            .values()
71            .map(|s| s.methods.len())
72            .sum()
73    }
74
75    /// Handle one line of JSON-RPC. `Some(response)` for requests (and for
76    /// parse errors, which answer with a null id); `None` for
77    /// notifications, which get no response. Never panics and never kills
78    /// the server: every failure is an error-shaped response.
79    pub async fn handle_line(&self, line: &str) -> Option<Value> {
80        let message: Value = match serde_json::from_str(line) {
81            Ok(message) => message,
82            Err(error) => {
83                return Some(error_response(
84                    &Value::Null,
85                    PARSE_ERROR,
86                    format!("parse error: {error}"),
87                ));
88            }
89        };
90
91        // A message without an id is a notification (`notifications/
92        // initialized` included): it gets no response by definition.
93        let id = message.get("id").cloned()?;
94
95        let method = message.get("method").and_then(|m| m.as_str()).unwrap_or("");
96        let params = message.get("params").cloned().unwrap_or(Value::Null);
97
98        let response = match method {
99            "initialize" => json!({
100                "jsonrpc": "2.0",
101                "id": id,
102                "result": initialize_result(&params),
103            }),
104            "ping" => json!({ "jsonrpc": "2.0", "id": id, "result": {} }),
105            "tools/list" => json!({
106                "jsonrpc": "2.0",
107                "id": id,
108                "result": { "tools": tools_list() },
109            }),
110            "tools/call" => {
111                let result = self.tools_call(&params).await;
112                json!({ "jsonrpc": "2.0", "id": id, "result": result })
113            }
114            "" => error_response(&id, INVALID_REQUEST, "request has no method".to_owned()),
115            other => error_response(&id, METHOD_NOT_FOUND, format!("method not found: {other}")),
116        };
117        Some(response)
118    }
119
120    /// `tools/call`: resolve the tool name, assemble the parameters,
121    /// invoke the shared call path. EVERY error — unknown tool, refused by
122    /// the safety profile, HTTP failure — is an `isError` result, never a
123    /// process exit: the server must survive one bad call.
124    async fn tools_call(&self, params: &Value) -> Value {
125        let Some(name) = params.get("name").and_then(|n| n.as_str()) else {
126            return error_result("tools/call needs a tool `name`");
127        };
128        let arguments = params
129            .get("arguments")
130            .cloned()
131            .unwrap_or_else(|| Value::Object(Map::new()));
132        let Value::Object(args) = arguments else {
133            return error_result("tools/call `arguments` must be an object");
134        };
135        match self.invoke_tool(name, &args).await {
136            Ok(text) => ok_result(text),
137            Err(message) => error_result(message),
138        }
139    }
140
141    async fn invoke_tool(&self, name: &str, args: &Map<String, Value>) -> Result<String, String> {
142        let (service, method) = discovery::resolve(name)
143            .map_err(|message| format!("unknown tool `{name}`: {message}"))?;
144
145        // The same gate the CLI dispatch honors (src/commands/safety.rs).
146        self.profile.check(&service.name, method)?;
147
148        // Assemble the parameter map: the `params` escape-hatch object goes
149        // in first, then the tool-call arguments — the arguments are the
150        // typed params and win on conflict ("typed params win", the merge
151        // order of `api::parse_params`).
152        let mut merged = Map::new();
153        if let Some(Value::Object(object)) = args.get("params") {
154            merged.extend(object.clone());
155        }
156
157        let mut body_file: Option<String> = None;
158        let mut query: Vec<String> = Vec::new();
159        let mut dry_run = false;
160        for (key, value) in args {
161            match key.as_str() {
162                "params" => {}
163                // Both spellings: the input schema declares `dry_run`, but
164                // agents routinely write `dryRun`. No Discovery parameter
165                // uses either name, so consuming them is safe.
166                "dry_run" | "dryRun" => dry_run = value.as_bool().unwrap_or(false),
167                "body_file" | "bodyFile" => body_file = value.as_str().map(str::to_owned),
168                "query" => {
169                    if let Some(items) = value.as_array() {
170                        query.extend(items.iter().filter_map(|v| v.as_str().map(str::to_owned)));
171                    }
172                }
173                other => {
174                    merged.insert(other.to_owned(), value.clone());
175                }
176            }
177        }
178
179        // stdin IS the MCP stream: a `-` body_file would swallow JSON-RPC
180        // lines. Say so rather than corrupting the session.
181        if body_file.as_deref() == Some("-") {
182            return Err(
183                "body_file `-` (stdin) is not available over MCP; pass a file path".to_owned(),
184            );
185        }
186
187        let payload = call_method(
188            Some(&self.auth),
189            service,
190            method,
191            merged,
192            api::CallOptions {
193                body_file,
194                // The verb override is `grr api call`'s escape hatch; MCP
195                // speaks the Discovery document.
196                verb_override: None,
197                query_extras: query,
198                dry_run,
199            },
200        )
201        .await
202        .map_err(|error| error.to_string())?;
203
204        serde_json::to_string(&payload)
205            .map_err(|error| format!("serialising the result failed: {error}"))
206    }
207}
208
209fn initialize_result(params: &Value) -> Value {
210    let requested = params.get("protocolVersion").and_then(|v| v.as_str());
211    let version = match requested {
212        Some(version) if SUPPORTED_VERSIONS.contains(&version) => (*version).to_owned(),
213        _ => PROTOCOL_VERSION.to_owned(),
214    };
215    json!({
216        "protocolVersion": version,
217        "capabilities": { "tools": {} },
218        "serverInfo": { "name": "grr", "version": env!("CARGO_PKG_VERSION") },
219    })
220}
221
222/// Every method in the Discovery index as an MCP tool. The index grows as
223/// Google publishes APIs — the list is derived, never a hardcoded count.
224fn tools_list() -> Vec<Value> {
225    discovery::services()
226        .values()
227        .flat_map(|service| service.methods.iter().map(|m| tool_descriptor(service, m)))
228        .collect()
229}
230
231fn tool_descriptor(service: &Service, method: &Method) -> Value {
232    json!({
233        "name": format!("{}.{}", service.name, method.id),
234        "description": tool_description(method),
235        "inputSchema": input_schema(method),
236    })
237}
238
239/// The method's Discovery description + its HTTP verb + a hint that write
240/// verbs need auth scopes (and are gated by `--readonly`).
241fn tool_description(method: &Method) -> String {
242    let verb = method.http_method.to_ascii_uppercase();
243    let mut text = format!("{} [HTTP {verb}]", method.description);
244    if is_write_verb(&verb) {
245        text.push_str(" — write verb: needs auth scopes; refused under --readonly safety profiles");
246    } else {
247        text.push_str(" — needs auth scopes");
248    }
249    text
250}
251
252/// The tool's input schema: a JSON-schema object built from the method's
253/// parameters plus grr's escape hatches; `required` is the method's
254/// required parameter names.
255fn input_schema(method: &Method) -> Value {
256    let mut properties = Map::new();
257    for param in &method.parameters {
258        properties.insert(param.name.clone(), param_schema(param));
259    }
260    properties.insert(
261        "params".into(),
262        json!({
263            "type": "object",
264            "additionalProperties": true,
265            "description": "Parameter map merged into the request; typed parameters win.",
266        }),
267    );
268    properties.insert("body_file".into(), json!({
269        "type": "string",
270        "description": "Read the request body from this file ('-' for stdin) instead of building one from the parameters.",
271    }));
272    properties.insert("query".into(), json!({
273        "type": "array",
274        "items": { "type": "string" },
275        "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
276    }));
277    properties.insert(
278        "dry_run".into(),
279        json!({
280            "type": "boolean",
281            "description": "Return the request that would be sent, without sending it.",
282        }),
283    );
284
285    let mut schema = Map::new();
286    schema.insert("type".into(), json!("object"));
287    schema.insert("properties".into(), Value::Object(properties));
288    if !method.required.is_empty() {
289        schema.insert("required".into(), json!(method.required));
290    }
291    Value::Object(schema)
292}
293
294/// One parameter as a JSON-schema property. Discovery's scalar kinds map
295/// to JSON-schema types; anything else (`any`, nested objects) omits the
296/// type rather than lie. A repeated parameter becomes an array of its base
297/// type, which is what `build_query` expands at call time.
298fn param_schema(param: &Parameter) -> Value {
299    let mut out = Map::new();
300    match param.kind.as_str() {
301        "string" => {
302            out.insert("type".into(), json!("string"));
303        }
304        "integer" => {
305            out.insert("type".into(), json!("integer"));
306        }
307        "number" => {
308            out.insert("type".into(), json!("number"));
309        }
310        "boolean" => {
311            out.insert("type".into(), json!("boolean"));
312        }
313        _ => {}
314    }
315    if !param.description.is_empty() {
316        out.insert("description".into(), json!(param.description));
317    }
318    if let Some(values) = &param.enum_values {
319        out.insert("enum".into(), json!(values));
320    }
321    let base = Value::Object(out);
322    if param.repeated {
323        json!({ "type": "array", "items": base })
324    } else {
325        base
326    }
327}
328
329fn error_response(id: &Value, code: i64, message: String) -> Value {
330    json!({
331        "jsonrpc": "2.0",
332        "id": id,
333        "error": { "code": code, "message": message },
334    })
335}
336
337fn ok_result(text: String) -> Value {
338    json!({ "content": [ { "type": "text", "text": text } ] })
339}
340
341fn error_result(message: impl Into<String>) -> Value {
342    json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
343}
344
345/// The stdio loop: newline-delimited JSON-RPC in, one response line per
346/// request out. Generic over the reader/writer so tests can run the whole
347/// handshake in-process. EOF on stdin (the client closed the session) is
348/// a clean shutdown.
349pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
350where
351    R: tokio::io::AsyncBufRead + Unpin,
352    W: tokio::io::AsyncWrite + Unpin,
353{
354    use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
355
356    let mut lines = reader.lines();
357    while let Some(line) = lines.next_line().await? {
358        if line.trim().is_empty() {
359            continue;
360        }
361        if let Some(response) = server.handle_line(&line).await {
362            writer.write_all(response.to_string().as_bytes()).await?;
363            writer.write_all(b"\n").await?;
364            writer.flush().await?;
365        }
366    }
367    Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::core::auth::TokenStorage;
374    use crate::core::config::OAuthConfig;
375
376    async fn test_server() -> McpServer {
377        test_server_with_profile(SafetyProfile::default()).await
378    }
379
380    async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
381        // `GoogleAuth::with_token` is the documented test hook: it skips
382        // OAuth and persistent storage entirely. Dry-run and gate tests
383        // never touch the network.
384        let config = OAuthConfig {
385            client_id: "test-id.apps.googleusercontent.com".into(),
386            client_secret: None,
387            ..Default::default()
388        };
389        let storage = TokenStorage {
390            access_token: "test-token".into(),
391            refresh_token: None,
392            expires_at: u64::MAX,
393            token_type: "Bearer".into(),
394            scope: String::new(),
395        };
396        let auth = GoogleAuth::with_token(config, storage).await.unwrap();
397        McpServer::new(auth, profile)
398    }
399
400    fn request(id: i64, method: &str, params: &str) -> String {
401        format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
402    }
403
404    #[tokio::test]
405    async fn the_stdio_loop_pipes_the_full_handshake() {
406        let server = test_server().await;
407        let input = format!(
408            "{}\n{}\n{}\n{}\n",
409            request(
410                1,
411                "initialize",
412                r#"{"protocolVersion":"2024-11-05","clientInfo":{"name":"t","version":"0"}}"#
413            ),
414            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
415            request(2, "tools/list", "{}"),
416            request(3, "ping", "{}"),
417        );
418        let mut out: Vec<u8> = Vec::new();
419        serve(&server, input.as_bytes(), &mut out).await.unwrap();
420
421        // One response line per REQUEST; the notification got none.
422        let lines: Vec<Value> = String::from_utf8(out)
423            .unwrap()
424            .lines()
425            .map(|l| serde_json::from_str(l).unwrap())
426            .collect();
427        assert_eq!(lines.len(), 3);
428
429        assert_eq!(lines[0]["jsonrpc"], "2.0");
430        assert_eq!(lines[0]["id"], 1);
431        assert!(lines[0]["result"]["protocolVersion"].is_string());
432        assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
433        assert_eq!(lines[0]["result"]["serverInfo"]["name"], "grr");
434        assert!(lines[0]["result"]["serverInfo"]["version"].is_string());
435
436        assert_eq!(lines[1]["id"], 2);
437        let tools = lines[1]["result"]["tools"].as_array().unwrap();
438        assert_eq!(tools.len(), server.tool_count());
439
440        assert_eq!(lines[2]["id"], 3);
441        assert_eq!(lines[2]["result"], json!({}));
442    }
443
444    #[tokio::test]
445    async fn initialize_echoes_a_supported_protocol_version() {
446        let server = test_server().await;
447
448        let line = request(1, "initialize", r#"{"protocolVersion":"2025-06-18"}"#);
449        let response = server.handle_line(&line).await.unwrap();
450        assert_eq!(response["result"]["protocolVersion"], "2025-06-18");
451
452        let line = request(2, "initialize", r#"{"protocolVersion":"2025-11-25"}"#);
453        let response = server.handle_line(&line).await.unwrap();
454        assert_eq!(response["result"]["protocolVersion"], "2025-11-25");
455
456        // An unknown version: the server's answer is authoritative.
457        let line = request(3, "initialize", r#"{"protocolVersion":"1999-01-01"}"#);
458        let response = server.handle_line(&line).await.unwrap();
459        assert_eq!(response["result"]["protocolVersion"], PROTOCOL_VERSION);
460    }
461
462    #[tokio::test]
463    async fn tools_list_covers_every_index_method_with_an_input_schema() {
464        let server = test_server().await;
465        let line = request(2, "tools/list", "{}");
466        let response = server.handle_line(&line).await.unwrap();
467        let tools = response["result"]["tools"].as_array().unwrap();
468
469        // The index grows as Google publishes APIs — never a hardcoded count.
470        let expected: usize = discovery::services()
471            .values()
472            .map(|s| s.methods.len())
473            .sum();
474        assert_eq!(tools.len(), expected);
475
476        for tool in tools {
477            let name = tool["name"].as_str().unwrap();
478            // Tool names are the dotted method ids `grr api call` takes.
479            assert!(name.contains('.'), "{name} is not a dotted id");
480            assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
481            assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
482            assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
483            assert!(
484                tool["description"].as_str().unwrap().contains("HTTP"),
485                "{name}"
486            );
487        }
488    }
489
490    #[tokio::test]
491    async fn input_schemas_carry_the_escape_hatches_and_required_params() {
492        let server = test_server().await;
493        let line = request(2, "tools/list", "{}");
494        let response = server.handle_line(&line).await.unwrap();
495        let tools = response["result"]["tools"].as_array().unwrap();
496        let schema = tools
497            .iter()
498            .find(|t| t["name"] == "gmail.users.messages.list")
499            .map(|t| t["inputSchema"].clone())
500            .unwrap();
501
502        for prop in ["params", "body_file", "query", "dry_run"] {
503            assert!(schema["properties"][prop].is_object(), "missing {prop}");
504        }
505        let required: Vec<&str> = schema["required"]
506            .as_array()
507            .unwrap()
508            .iter()
509            .filter_map(|v| v.as_str())
510            .collect();
511        assert!(required.contains(&"userId"), "{required:?}");
512
513        // Typed parameters keep their Discovery types; labelIds is repeated.
514        assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
515        assert_eq!(schema["properties"]["labelIds"]["type"], "array");
516        assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
517    }
518
519    #[tokio::test]
520    async fn enum_values_surface_in_the_schema() {
521        let server = test_server().await;
522        let line = request(2, "tools/list", "{}");
523        let response = server.handle_line(&line).await.unwrap();
524        let tools = response["result"]["tools"].as_array().unwrap();
525        let schema = tools
526            .iter()
527            .find(|t| t["name"] == "calendar.calendarList.list")
528            .map(|t| t["inputSchema"].clone())
529            .unwrap();
530        let values = schema["properties"]["minAccessRole"]["enum"]
531            .as_array()
532            .unwrap();
533        assert!(
534            values.iter().any(|v| v.as_str() == Some("owner")),
535            "{values:?}"
536        );
537    }
538
539    #[tokio::test]
540    async fn tools_call_dry_run_matches_the_api_call_payload() {
541        let server = test_server().await;
542        let line = request(
543            3,
544            "tools/call",
545            r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
546        );
547        let response = server.handle_line(&line).await.unwrap();
548        assert_eq!(response["result"]["isError"], Value::Null);
549        let text = response["result"]["content"][0]["text"].as_str().unwrap();
550        let payload: Value = serde_json::from_str(text).unwrap();
551
552        // The same dry-run payload `grr api call --dry-run` prints, via
553        // the shared plan path.
554        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
555        let mut params = Map::new();
556        params.insert("userId".into(), json!("me"));
557        let plan = api::plan_request(service, method, &params, None, None, &[]).unwrap();
558        assert_eq!(payload, api::dry_run_payload(method, &plan));
559    }
560
561    #[tokio::test]
562    async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
563        let server = test_server().await;
564        let line = request(
565            4,
566            "tools/call",
567            r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
568        );
569        let response = server.handle_line(&line).await.unwrap();
570        let text = response["result"]["content"][0]["text"].as_str().unwrap();
571        let payload: Value = serde_json::from_str(text).unwrap();
572        assert_eq!(payload["dryRun"], true);
573    }
574
575    #[tokio::test]
576    async fn tool_arguments_win_over_the_params_escape_hatch() {
577        let server = test_server().await;
578        let line = request(
579            5,
580            "tools/call",
581            r#"{"name":"gmail.users.messages.list","arguments":
582                {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
583        );
584        let response = server.handle_line(&line).await.unwrap();
585        let text = response["result"]["content"][0]["text"].as_str().unwrap();
586        let payload: Value = serde_json::from_str(text).unwrap();
587        // The typed argument wins; the escape hatch's other key rides along.
588        let url = payload["url"].as_str().unwrap();
589        assert!(url.contains("/users/me/messages"), "{url}");
590        assert!(url.contains("q=is%3Aunread"), "{url}");
591        assert!(!url.contains("someone-else"), "{url}");
592    }
593
594    #[tokio::test]
595    async fn an_unknown_tool_is_an_error_result_not_a_crash() {
596        let server = test_server().await;
597        let line = request(
598            6,
599            "tools/call",
600            r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
601        );
602        let response = server.handle_line(&line).await.unwrap();
603        assert_eq!(response["result"]["isError"], true);
604        let text = response["result"]["content"][0]["text"].as_str().unwrap();
605        assert!(text.contains("unknown tool"), "{text}");
606    }
607
608    #[tokio::test]
609    async fn readonly_refuses_writes_with_an_mcp_error_result() {
610        let server = test_server_with_profile(SafetyProfile::readonly()).await;
611
612        let line = request(
613            7,
614            "tools/call",
615            r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
616        );
617        let response = server.handle_line(&line).await.unwrap();
618        assert_eq!(response["result"]["isError"], true);
619        let text = response["result"]["content"][0]["text"].as_str().unwrap();
620        // Actionable: names the method, the verb and how to lift it.
621        assert!(text.contains("gmail.users.messages.send"), "{text}");
622        assert!(text.contains("POST"), "{text}");
623        assert!(text.contains("Rerun without --readonly"), "{text}");
624
625        // A GET still runs under the same profile.
626        let line = request(
627            8,
628            "tools/call",
629            r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
630        );
631        let response = server.handle_line(&line).await.unwrap();
632        assert_eq!(response["result"]["isError"], Value::Null);
633    }
634
635    #[tokio::test]
636    async fn a_missing_tool_name_is_an_error_result() {
637        let server = test_server().await;
638        let line = request(9, "tools/call", r#"{"arguments":{}}"#);
639        let response = server.handle_line(&line).await.unwrap();
640        assert_eq!(response["result"]["isError"], true);
641    }
642
643    #[tokio::test]
644    async fn a_stdin_body_file_is_refused_over_mcp() {
645        let server = test_server().await;
646        let line = request(
647            10,
648            "tools/call",
649            r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
650        );
651        let response = server.handle_line(&line).await.unwrap();
652        assert_eq!(response["result"]["isError"], true);
653        let text = response["result"]["content"][0]["text"].as_str().unwrap();
654        assert!(text.contains("stdin"), "{text}");
655    }
656
657    #[tokio::test]
658    async fn an_unknown_method_is_a_jsonrpc_error() {
659        let server = test_server().await;
660        let line = request(11, "resources/list", "{}");
661        let response = server.handle_line(&line).await.unwrap();
662        assert_eq!(response["error"]["code"], -32601);
663        assert!(
664            response["error"]["message"]
665                .as_str()
666                .unwrap()
667                .contains("resources/list")
668        );
669        assert_eq!(response["id"], 11);
670    }
671
672    #[tokio::test]
673    async fn a_parse_error_answers_with_a_null_id() {
674        let server = test_server().await;
675        let response = server.handle_line("not json at all").await.unwrap();
676        assert_eq!(response["error"]["code"], -32700);
677        assert!(response["id"].is_null());
678    }
679
680    #[tokio::test]
681    async fn notifications_are_never_answered() {
682        let server = test_server().await;
683        assert!(
684            server
685                .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
686                .await
687                .is_none()
688        );
689        assert!(
690            server
691                .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
692                .await
693                .is_none()
694        );
695    }
696}