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