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