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