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