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    // outputSchema: the pruned response shape for this method's $ref, when
302    // the upstream document named one (~87% of methods). GET/list tools
303    // declare their payload shape; a write tool's outputSchema describes
304    // the post-write resource state.
305    let output_schema = method
306        .response_ref
307        .as_deref()
308        .and_then(|name| service.schemas.get(name))
309        .map(|schema| serde_json::to_value(schema).expect("Schema serializes"));
310    let mut descriptor = json!({
311        "name": format!("{}.{}", service.name, method.id),
312        "description": tool_description(service, method),
313        "inputSchema": input_schema(service, method),
314    });
315    if let Some(schema) = output_schema {
316        descriptor
317            .as_object_mut()
318            .expect("descriptor is an object")
319            .insert("outputSchema".into(), schema);
320    }
321    descriptor
322}
323
324/// Curated descriptions for the few Discovery methods that ship none —
325/// high-impact tools an agent must not have to guess at. Keyed by the
326/// method's index id (resource path + verb, WITHOUT the service prefix —
327/// the service name joins at tool-name assembly, not in `Method.id`);
328/// anything not listed falls through to the verb-derived text.
329const DESCRIPTION_OVERRIDES: &[(&str, &str)] = &[(
330    "scripts.run",
331    "Executes a function in an Apps Script project (Apps Script API script.run). The script \
332         runs headlessly with the scopes its manifest declares — review them before calling: \
333         arbitrary script code executes with your authorization. Returns the script function's \
334         response (OPS_ERROR / ops for failures); script-side failures come back as tool errors, \
335         not dead servers. Prefer `script processes list` to observe running executions and \
336         `script projects get` to read a project's source before running it.",
337)];
338
339/// The resources this method's id sits among, as sibling guidance: the
340/// other verbs over the same resource path, so an agent sees the CRUD
341/// neighbourhood without leaving the tool list.
342fn sibling_summary(service: &Service, method: &Method) -> String {
343    // The method id is `resource.path.segments.verb`; siblings share every
344    // segment but the last.
345    let verb = method.id.rsplit('.').next().unwrap_or("");
346    let resource = &method.id[..method.id.len().saturating_sub(verb.len()).saturating_sub(1)];
347    if resource.is_empty() {
348        return String::new();
349    }
350    let mut siblings: Vec<&str> = service
351        .methods
352        .iter()
353        .filter(|m| {
354            m.id != method.id
355                && m.id
356                    .strip_suffix(&format!(".{}", m.id.rsplit('.').next().unwrap_or("")))
357                    == Some(resource)
358        })
359        .map(|m| m.id.rsplit('.').next().unwrap_or(""))
360        .collect();
361    siblings.sort_unstable();
362    siblings.dedup();
363    if siblings.is_empty() {
364        String::new()
365    } else {
366        format!(" Siblings over the same resource: {}.", siblings.join(", "))
367    }
368}
369
370/// What the call returns, derived from the verb — the shape the shared
371/// call path actually delivers (JSON on stdout, envelope on MCP).
372fn returns_summary(method: &Method) -> &'static str {
373    match method.http_method.to_ascii_uppercase().as_str() {
374        "GET" => "Returns the requested resource as JSON",
375        "DELETE" => "Returns the post-delete resource state (or empty) as JSON",
376        _ => "Returns the API's response resource as JSON",
377    }
378}
379
380/// The method's Discovery description, enriched with what an agent needs
381/// to pick and use the tool correctly: the resource it acts on, what comes
382/// back, how the request body is built, the auth/safety story, and the
383/// sibling methods over the same resource. Everything here is derived from
384/// the Discovery index — no hand-written per-method prose to rot.
385fn tool_description(service: &Service, method: &Method) -> String {
386    let verb = method.http_method.to_ascii_uppercase();
387    let base = DESCRIPTION_OVERRIDES
388        .iter()
389        .find(|(id, _)| *id == method.id)
390        .map(|(_, text)| text.to_string())
391        .unwrap_or_else(|| {
392            if method.description.is_empty() {
393                format!(
394                    "{}s {} via the {} API",
395                    verb.to_ascii_lowercase(),
396                    method.id.rsplit('.').next().unwrap_or("resource"),
397                    service.name
398                )
399            } else {
400                method.description.clone()
401            }
402        });
403
404    // Discovery descriptions commonly end in a period; normalize so we never
405    // ship "text.. [HTTP".
406    let base = base.trim_end_matches('.');
407    let mut text = format!("{base} [HTTP {verb}]");
408
409    // Behavior: what the call does to the world, before the auth line.
410    match verb.as_str() {
411        "POST" => text.push_str(" — creates or triggers a change"),
412        "DELETE" => text.push_str(
413            " — removes the resource; irreversible unless the API documents a trash/undo flow",
414        ),
415        "PATCH" | "PUT" => text.push_str(" — updates the resource"),
416        _ => {}
417    }
418    if is_write_verb(&verb) {
419        text.push_str("; refused under --readonly safety profiles");
420    }
421
422    // Completeness: the two things the schema cannot say.
423    // Discovery text can carry its own trailing period mid-sentence; don't
424    // let our appended clause double it. Trailing-period truncation (300
425    // chars) at distill time also leaves a bare period to normalize.
426    if text.ends_with(' ') {
427        text.pop();
428    }
429    match &method.request_ref {
430        Some(body_type) => {
431            let fields = request_body_fields(service, body_type);
432            text.push_str(&format!(
433                ". {} — request body is a JSON {body_type} object{fields}; pass it via `params` or whole via `body_file`",
434                returns_summary(method)
435            ));
436        }
437        None => text.push_str(&format!(
438            ". {} — request bodies are built from the typed parameters (typed flags win over the `params` map), or read whole from `body_file`",
439            returns_summary(method)
440        )),
441    }
442
443    text.push_str(&sibling_summary(service, method));
444    text
445}
446
447/// The request schema's top-level field names, for the description — an
448/// agent choosing parameters sees what the body accepts without opening
449/// the schema. Long lists truncate at eight names.
450fn request_body_fields(service: &Service, schema_name: &str) -> String {
451    let Some(schema) = service.schemas.get(schema_name) else {
452        return String::new();
453    };
454    let Some(properties) = &schema.properties else {
455        return String::new();
456    };
457    let mut names: Vec<&String> = properties.keys().collect();
458    names.sort_unstable();
459    if names.is_empty() {
460        return String::new();
461    }
462    if names.len() > 8 {
463        format!(
464            " accepting {} and {} more fields",
465            names[..8]
466                .iter()
467                .map(|s| s.as_str())
468                .collect::<Vec<_>>()
469                .join(", "),
470            names.len() - 8
471        )
472    } else {
473        format!(
474            " accepting {}",
475            names
476                .iter()
477                .map(|s| s.as_str())
478                .collect::<Vec<_>>()
479                .join(", ")
480        )
481    }
482}
483
484/// The tool's input schema: a JSON-schema object built from the method's
485/// parameters plus grr's escape hatches; `required` is the method's
486/// required parameter names.
487fn input_schema(service: &Service, method: &Method) -> Value {
488    let mut properties = Map::new();
489    for param in &method.parameters {
490        properties.insert(param.name.clone(), param_schema(param));
491    }
492    properties.insert(
493        "params".into(),
494        json!({
495            "type": "object",
496            "additionalProperties": true,
497            "description": "Parameter map merged into the request; typed parameters win.",
498        }),
499    );
500    let body_file_desc = match &method.request_ref {
501        Some(name) => format!(
502            "Read the request body from this file ('-' for stdin) instead of building one from the parameters. The body is a JSON {name} object."
503        ),
504        None => "Read the request body from this file ('-' for stdin) instead of building one from the parameters.".to_owned(),
505    };
506    properties.insert(
507        "body_file".into(),
508        json!({
509            "type": "string",
510            "description": body_file_desc,
511        }),
512    );
513    properties.insert("query".into(), json!({
514        "type": "array",
515        "items": { "type": "string" },
516        "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
517    }));
518    // The request-body shape, when the upstream method names one: agents
519    // see the field names to build (params map keys or body_file content).
520    if let Some(name) = &method.request_ref
521        && let Some(schema) = service.schemas.get(name)
522    {
523        let mut body_schema = serde_json::to_value(schema).expect("Schema serializes");
524        if let Some(obj) = body_schema.as_object_mut() {
525            obj.insert(
526                "description".into(),
527                json!(format!("Request body of type {name}: pass it via `params` (merged into the built body) or whole via `body_file`.")),
528            );
529        }
530        properties.insert("request_body".into(), body_schema);
531    }
532    properties.insert(
533        "dry_run".into(),
534        json!({
535            "type": "boolean",
536            "description": "Return the request that would be sent, without sending it.",
537        }),
538    );
539
540    let mut schema = Map::new();
541    schema.insert("type".into(), json!("object"));
542    schema.insert("properties".into(), Value::Object(properties));
543    if !method.required.is_empty() {
544        schema.insert("required".into(), json!(method.required));
545    }
546    Value::Object(schema)
547}
548
549/// One parameter as a JSON-schema property. Discovery's scalar kinds map
550/// to JSON-schema types; anything else (`any`, nested objects) omits the
551/// type rather than lie. A repeated parameter becomes an array of its base
552/// type, which is what `build_query` expands at call time.
553fn param_schema(param: &Parameter) -> Value {
554    let mut out = Map::new();
555    match param.kind.as_str() {
556        "string" => {
557            out.insert("type".into(), json!("string"));
558        }
559        "integer" => {
560            out.insert("type".into(), json!("integer"));
561        }
562        "number" => {
563            out.insert("type".into(), json!("number"));
564        }
565        "boolean" => {
566            out.insert("type".into(), json!("boolean"));
567        }
568        _ => {}
569    }
570    if let Some(format) = &param.format {
571        // Google's format names (int32, uint32, google-fieldmask,
572        // google-datetime) pass through verbatim — close enough for a
573        // consumer and honest about their origin.
574        out.insert("format".into(), json!(format));
575    }
576    if let Some(minimum) = param.minimum {
577        out.insert("minimum".into(), json!(minimum));
578    }
579    if let Some(maximum) = param.maximum {
580        out.insert("maximum".into(), json!(maximum));
581    }
582    if let Some(pattern) = &param.pattern {
583        out.insert("pattern".into(), json!(pattern));
584    }
585    if let Some(default) = &param.default_value {
586        out.insert("default".into(), json!(default));
587    }
588    let mut description = String::new();
589    if let Some(location) = &param.location {
590        let hint = match location.as_str() {
591            "path" => "URL path segment",
592            "query" => "query parameter",
593            other => other,
594        };
595        description.push_str(&format!("[{hint}] "));
596    }
597    if !param.description.is_empty() {
598        description.push_str(&param.description);
599    }
600    if let (Some(values), Some(texts)) = (&param.enum_values, &param.enum_descriptions)
601        && values.len() == texts.len()
602        && !texts.is_empty()
603    {
604        let pairs: Vec<String> = values
605            .iter()
606            .zip(texts)
607            .map(|(v, t)| format!("`{v}` = {t}"))
608            .collect();
609        if !description.is_empty() {
610            description.push(' ');
611        }
612        description.push_str(&format!("Values: {}.", pairs.join("; ")));
613    }
614    if !description.is_empty() {
615        out.insert("description".into(), json!(description));
616    }
617    if let Some(values) = &param.enum_values {
618        out.insert("enum".into(), json!(values));
619    }
620    let base = Value::Object(out);
621    if param.repeated {
622        json!({ "type": "array", "items": base })
623    } else {
624        base
625    }
626}
627
628fn error_response(id: &Value, code: i64, message: String) -> Value {
629    json!({
630        "jsonrpc": "2.0",
631        "id": id,
632        "error": { "code": code, "message": message },
633    })
634}
635
636fn ok_result(text: String) -> Value {
637    json!({ "content": [ { "type": "text", "text": text } ] })
638}
639
640fn error_result(message: impl Into<String>) -> Value {
641    json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
642}
643
644/// The stdio loop: newline-delimited JSON-RPC in, one response line per
645/// request out. Generic over the reader/writer so tests can run the whole
646/// handshake in-process. EOF on stdin (the client closed the session) is
647/// a clean shutdown.
648pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
649where
650    R: tokio::io::AsyncBufRead + Unpin,
651    W: tokio::io::AsyncWrite + Unpin,
652{
653    use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
654
655    let mut lines = reader.lines();
656    while let Some(line) = lines.next_line().await? {
657        if line.trim().is_empty() {
658            continue;
659        }
660        if let Some(response) = server.handle_line(&line).await {
661            writer.write_all(response.to_string().as_bytes()).await?;
662            writer.write_all(b"\n").await?;
663            writer.flush().await?;
664        }
665    }
666    Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::core::auth::TokenStorage;
673    use crate::core::config::OAuthConfig;
674
675    async fn test_server() -> McpServer {
676        test_server_with_profile(SafetyProfile::default()).await
677    }
678
679    async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
680        // `GoogleAuth::with_token` is the documented test hook: it skips
681        // OAuth and persistent storage entirely. Dry-run and gate tests
682        // never touch the network.
683        let config = OAuthConfig {
684            client_id: "test-id.apps.googleusercontent.com".into(),
685            client_secret: None,
686            ..Default::default()
687        };
688        let storage = TokenStorage {
689            access_token: "test-token".into(),
690            refresh_token: None,
691            expires_at: u64::MAX,
692            token_type: "Bearer".into(),
693            scope: String::new(),
694        };
695        let auth = GoogleAuth::with_token(config, storage).await.unwrap();
696        McpServer::new(auth, profile)
697    }
698
699    fn request(id: i64, method: &str, params: &str) -> String {
700        format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
701    }
702
703    #[tokio::test]
704    async fn the_stdio_loop_pipes_the_stateless_requests() {
705        let server = test_server().await;
706        let input = format!(
707            "{}\n{}\n{}\n",
708            request(1, "server/discover", "{}"),
709            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
710            request(2, "tools/list", "{}"),
711        );
712        let mut out: Vec<u8> = Vec::new();
713        serve(&server, input.as_bytes(), &mut out).await.unwrap();
714
715        // One response line per REQUEST; the notification got none.
716        let lines: Vec<Value> = String::from_utf8(out)
717            .unwrap()
718            .lines()
719            .map(|l| serde_json::from_str(l).unwrap())
720            .collect();
721        assert_eq!(lines.len(), 2);
722
723        assert_eq!(lines[0]["jsonrpc"], "2.0");
724        assert_eq!(lines[0]["id"], 1);
725        assert_eq!(lines[0]["result"]["resultType"], "complete");
726        assert_eq!(
727            lines[0]["result"]["supportedVersions"],
728            json!(["2026-07-28"])
729        );
730        assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
731        assert_eq!(
732            lines[0]["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
733            "grr"
734        );
735
736        assert_eq!(lines[1]["id"], 2);
737        let tools = lines[1]["result"]["tools"].as_array().unwrap();
738        assert_eq!(tools.len(), server.tool_count());
739    }
740
741    /// The handshake revisions are not served at all — except `initialize`,
742    /// which gets a minimal legacy handshake so standard clients connect.
743    /// `ping` and `logging/setLevel` get an error naming the revision to
744    /// speak and the RPC that replaced the handshake.
745    #[tokio::test]
746    async fn legacy_handshake_methods_are_refused_with_the_revision_to_speak() {
747        let server = test_server().await;
748
749        for method in ["ping", "logging/setLevel"] {
750            let line = request(1, method, "{}");
751            let response = server.handle_line(&line).await.unwrap();
752
753            assert_eq!(response["error"]["code"], METHOD_NOT_FOUND, "{method}");
754        }
755    }
756
757    /// `initialize` compat: a standard client opens with the legacy
758    /// handshake and must get a valid result — server identity, tool
759    /// capability, and the revision it asked for echoed back (several
760    /// clients reject a newer one) — instead of an error.
761    #[tokio::test]
762    async fn initialize_answers_a_minimal_legacy_handshake() {
763        let server = test_server().await;
764
765        let line = request(
766            1,
767            "initialize",
768            r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}"#,
769        );
770        let response = server.handle_line(&line).await.unwrap();
771        let result = &response["result"];
772        assert_eq!(result["protocolVersion"], "2025-06-18");
773        assert_eq!(result["serverInfo"]["name"], "grr");
774        assert!(result["capabilities"]["tools"].is_object());
775        // The handshake completes; tools/* then works as usual.
776        let line = request(2, "tools/list", "{}");
777        let response = server.handle_line(&line).await.unwrap();
778        assert_eq!(
779            response["result"]["tools"].as_array().unwrap().len(),
780            server.tool_count()
781        );
782    }
783
784    /// The stateless revision has no handshake, so `server/discover` may be
785    /// a client's first call — and `tools/list` works with no prior call at
786    /// all. The server holds no handshake state to satisfy.
787    #[tokio::test]
788    async fn server_discover_answers_without_a_handshake() {
789        let server = test_server().await;
790
791        let line = request(
792            1,
793            "server/discover",
794            r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0"}}}"#,
795        );
796        let response = server.handle_line(&line).await.unwrap();
797        let result = &response["result"];
798
799        assert_eq!(result["resultType"], "complete");
800        assert_eq!(
801            result["supportedVersions"],
802            json!(["2026-07-28"]),
803            "the current revision, and only it"
804        );
805        assert!(result["capabilities"]["tools"].is_object());
806        assert_eq!(
807            result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
808            "grr"
809        );
810
811        // A 2026-07-28 client may also skip discovery entirely.
812        let inline = request(2, "tools/list", "{}");
813        let response = server.handle_line(&inline).await.unwrap();
814        assert!(response["result"]["tools"].is_array());
815    }
816
817    /// No handshake means no `serverInfo` to carry identity, so every result
818    /// names the server — the spec asks for `_meta` on each one.
819    #[tokio::test]
820    async fn results_identify_the_server_per_request() {
821        let server = test_server().await;
822
823        for method in ["tools/list", "server/discover"] {
824            let line = request(1, method, "{}");
825            let response = server.handle_line(&line).await.unwrap();
826            assert_eq!(
827                response["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "grr",
828                "{method} did not identify the server"
829            );
830        }
831    }
832
833    #[tokio::test]
834    async fn tools_list_covers_every_index_method_with_an_input_schema() {
835        let server = test_server().await;
836        let line = request(2, "tools/list", "{}");
837        let response = server.handle_line(&line).await.unwrap();
838        let tools = response["result"]["tools"].as_array().unwrap();
839
840        // The index grows as Google publishes APIs — never a hardcoded count.
841        let expected: usize = discovery::services()
842            .values()
843            .map(|s| s.methods.len())
844            .sum();
845        assert_eq!(tools.len(), expected);
846
847        for tool in tools {
848            let name = tool["name"].as_str().unwrap();
849            // Tool names are the dotted method ids `grr api call` takes.
850            assert!(name.contains('.'), "{name} is not a dotted id");
851            assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
852            assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
853            assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
854            assert!(
855                tool["description"].as_str().unwrap().contains("HTTP"),
856                "{name}"
857            );
858        }
859    }
860
861    /// Tools whose upstream method documents a response schema declare an
862    /// `outputSchema`; its shape mirrors the pruned Discovery schema (field
863    /// names, types, array items). Tools without one omit the key rather
864    /// than fake a shape.
865    #[tokio::test]
866    async fn output_schema_mirrors_the_response_ref() {
867        let server = test_server().await;
868        let line = request(2, "tools/list", "{}");
869        let response = server.handle_line(&line).await.unwrap();
870        let tools = response["result"]["tools"].as_array().unwrap();
871
872        let find = |name: &str| {
873            tools
874                .iter()
875                .find(|t| t["name"] == name)
876                .unwrap_or_else(|| panic!("{name} missing"))
877        };
878
879        // gmail.users.threads.list responds with ListThreadsResponse.
880        let list = find("gmail.users.threads.list");
881        let output = &list["outputSchema"];
882        assert!(
883            output.is_object(),
884            "threads.list must carry an outputSchema"
885        );
886        assert_eq!(output["type"], "object");
887        assert!(
888            output["properties"]["nextPageToken"]["type"] == "string",
889            "list envelope fields survive distillation"
890        );
891        assert!(
892            output["properties"]["threads"]["items"]["properties"]["id"]["type"] == "string",
893            "nested array items are inlined one $ref deep"
894        );
895
896        // Methods whose upstream document names no response omit the key.
897        let empty = find("gmail.users.stop");
898        assert!(
899            empty.get("outputSchema").is_none(),
900            "no fabricated output schema"
901        );
902
903        // Schemas are pruned: no description keys anywhere in the output.
904        let raw = serde_json::to_string(&response).unwrap();
905        assert!(
906            !raw.contains("\"outputSchema\":{\"description"),
907            "pruned schemas carry no descriptions"
908        );
909    }
910
911    /// Methods whose upstream document names a request body get its shape
912    /// as a `request_body` schema (field names an agent can build) and a
913    /// description clause naming the body type; tools without one keep the
914    /// generic body_file wording.
915    #[tokio::test]
916    async fn request_body_schemas_carry_the_body_shape_and_type_name() {
917        let server = test_server().await;
918        let line = request(2, "tools/list", "{}");
919        let response = server.handle_line(&line).await.unwrap();
920        let tools = response["result"]["tools"].as_array().unwrap();
921        let find = |name: &str| {
922            tools
923                .iter()
924                .find(|t| t["name"] == name)
925                .unwrap_or_else(|| panic!("{name} missing"))
926        };
927
928        let query = find("searchconsole.searchanalytics.query");
929        let body = &query["inputSchema"]["properties"]["request_body"];
930        assert!(body.is_object(), "query must expose its request body shape");
931        assert_eq!(body["type"], "object");
932        assert!(
933            body["properties"]["startDate"].is_object(),
934            "body field names survive distillation"
935        );
936        let description = query["description"].as_str().unwrap();
937        assert!(
938            description.contains("SearchAnalyticsQueryRequest"),
939            "description names the body type: {description}"
940        );
941        assert!(
942            description.contains("startDate"),
943            "description lists top-level body fields: {description}"
944        );
945
946        // A method without a request ref: no request_body property, generic
947        // body_file wording.
948        let get = find("gmail.users.threads.list");
949        assert!(
950            get["inputSchema"]["properties"]
951                .get("request_body")
952                .is_none(),
953            "no fabricated request body"
954        );
955        let body_file = get["inputSchema"]["properties"]["body_file"]["description"]
956            .as_str()
957            .unwrap();
958        assert!(
959            !body_file.contains("JSON"),
960            "generic wording when no body type exists: {body_file}"
961        );
962    }
963
964    /// Parameter schemas surface what Discovery documents beyond the type:
965    /// location (path segment vs query), enum-value prose, format, default,
966    /// and bounds.
967    #[tokio::test]
968    async fn parameter_schemas_carry_location_enums_format_and_bounds() {
969        let server = test_server().await;
970        let line = request(2, "tools/list", "{}");
971        let response = server.handle_line(&line).await.unwrap();
972        let tools = response["result"]["tools"].as_array().unwrap();
973        let find = |name: &str| {
974            tools
975                .iter()
976                .find(|t| t["name"] == name)
977                .unwrap_or_else(|| panic!("{name} missing"))
978        };
979
980        let user_id =
981            find("gmail.users.threads.list")["inputSchema"]["properties"]["userId"]["description"]
982                .as_str()
983                .unwrap();
984        assert!(
985            user_id.starts_with("[URL path segment]"),
986            "path parameters announce their location: {user_id}"
987        );
988
989        // calendar.events.list maxResults: format int32, default 250, min 1.
990        let max_results = find("calendar.events.list")["inputSchema"]["properties"]["maxResults"]
991            .as_object()
992            .expect("maxResults present");
993        assert_eq!(max_results["format"], "int32");
994        assert_eq!(max_results["minimum"], 1.0);
995        assert_eq!(max_results["default"], "250");
996    }
997
998    /// Descriptions carry what an agent needs to pick a tool: what the call
999    /// returns, how the request body is built, and — where the resource has
1000    /// siblings — the CRUD neighbourhood. Also covers the curated overrides:
1001    /// Discovery methods that ship no description still get real prose.
1002    #[tokio::test]
1003    async fn descriptions_carry_returns_body_and_siblings() {
1004        let server = test_server().await;
1005        let line = request(2, "tools/list", "{}");
1006        let response = server.handle_line(&line).await.unwrap();
1007        let tools = response["result"]["tools"].as_array().unwrap();
1008        let by_name = |name: &str| {
1009            tools
1010                .iter()
1011                .find(|t| t["name"] == name)
1012                .unwrap_or_else(|| panic!("{name} missing"))["description"]
1013                .as_str()
1014                .unwrap()
1015                .to_owned()
1016        };
1017
1018        let delete = by_name("gmail.users.messages.delete");
1019        assert!(
1020            delete.contains("irreversible"),
1021            "delete should warn about irreversibility: {delete}"
1022        );
1023        assert!(
1024            delete.contains("refused under --readonly"),
1025            "write verbs name the safety gate: {delete}"
1026        );
1027
1028        let list = by_name("calendar.events.list");
1029        assert!(
1030            list.contains("Siblings over the same resource"),
1031            "list tools name their CRUD neighbours: {list}"
1032        );
1033        assert!(list.contains("as JSON"), "returns are described: {list}");
1034        assert!(
1035            list.contains("body_file"),
1036            "body building is described: {list}"
1037        );
1038
1039        let run = by_name("script.scripts.run");
1040        assert!(
1041            run.starts_with("Executes a function"),
1042            "Discovery shipped no description for scripts.run; the curated override must supply one: {run}"
1043        );
1044
1045        // Our appended clauses never start with a period directly after the
1046        // upstream text's own: the verb tag always follows cleanly.
1047        for tool in tools {
1048            let d = tool["description"].as_str().unwrap();
1049            assert!(
1050                !d.contains(". [HTTP"),
1051                "{}: doubled period before the verb tag in {d}",
1052                tool["name"]
1053            );
1054        }
1055    }
1056
1057    #[tokio::test]
1058    async fn input_schemas_carry_the_escape_hatches_and_required_params() {
1059        let server = test_server().await;
1060        let line = request(2, "tools/list", "{}");
1061        let response = server.handle_line(&line).await.unwrap();
1062        let tools = response["result"]["tools"].as_array().unwrap();
1063        let schema = tools
1064            .iter()
1065            .find(|t| t["name"] == "gmail.users.messages.list")
1066            .map(|t| t["inputSchema"].clone())
1067            .unwrap();
1068
1069        for prop in ["params", "body_file", "query", "dry_run"] {
1070            assert!(schema["properties"][prop].is_object(), "missing {prop}");
1071        }
1072        let required: Vec<&str> = schema["required"]
1073            .as_array()
1074            .unwrap()
1075            .iter()
1076            .filter_map(|v| v.as_str())
1077            .collect();
1078        assert!(required.contains(&"userId"), "{required:?}");
1079
1080        // Typed parameters keep their Discovery types; labelIds is repeated.
1081        assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
1082        assert_eq!(schema["properties"]["labelIds"]["type"], "array");
1083        assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
1084    }
1085
1086    #[tokio::test]
1087    async fn enum_values_surface_in_the_schema() {
1088        let server = test_server().await;
1089        let line = request(2, "tools/list", "{}");
1090        let response = server.handle_line(&line).await.unwrap();
1091        let tools = response["result"]["tools"].as_array().unwrap();
1092        let schema = tools
1093            .iter()
1094            .find(|t| t["name"] == "calendar.calendarList.list")
1095            .map(|t| t["inputSchema"].clone())
1096            .unwrap();
1097        let values = schema["properties"]["minAccessRole"]["enum"]
1098            .as_array()
1099            .unwrap();
1100        assert!(
1101            values.iter().any(|v| v.as_str() == Some("owner")),
1102            "{values:?}"
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn tools_call_dry_run_matches_the_api_call_payload() {
1108        let server = test_server().await;
1109        let line = request(
1110            3,
1111            "tools/call",
1112            r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
1113        );
1114        let response = server.handle_line(&line).await.unwrap();
1115        assert_eq!(response["result"]["isError"], Value::Null);
1116        let text = response["result"]["content"][0]["text"].as_str().unwrap();
1117        let payload: Value = serde_json::from_str(text).unwrap();
1118
1119        // The same dry-run payload `grr api call --dry-run` prints, via
1120        // the shared plan path.
1121        let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
1122        let mut params = Map::new();
1123        params.insert("userId".into(), json!("me"));
1124        let plan = api::plan_request(service, method, &params, None, None, &[]).unwrap();
1125        assert_eq!(payload, api::dry_run_payload(method, &plan));
1126    }
1127
1128    #[tokio::test]
1129    async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
1130        let server = test_server().await;
1131        let line = request(
1132            4,
1133            "tools/call",
1134            r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
1135        );
1136        let response = server.handle_line(&line).await.unwrap();
1137        let text = response["result"]["content"][0]["text"].as_str().unwrap();
1138        let payload: Value = serde_json::from_str(text).unwrap();
1139        assert_eq!(payload["dryRun"], true);
1140    }
1141
1142    #[tokio::test]
1143    async fn tool_arguments_win_over_the_params_escape_hatch() {
1144        let server = test_server().await;
1145        let line = request(
1146            5,
1147            "tools/call",
1148            r#"{"name":"gmail.users.messages.list","arguments":
1149                {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
1150        );
1151        let response = server.handle_line(&line).await.unwrap();
1152        let text = response["result"]["content"][0]["text"].as_str().unwrap();
1153        let payload: Value = serde_json::from_str(text).unwrap();
1154        // The typed argument wins; the escape hatch's other key rides along.
1155        let url = payload["url"].as_str().unwrap();
1156        assert!(url.contains("/users/me/messages"), "{url}");
1157        assert!(url.contains("q=is%3Aunread"), "{url}");
1158        assert!(!url.contains("someone-else"), "{url}");
1159    }
1160
1161    #[tokio::test]
1162    async fn an_unknown_tool_is_an_error_result_not_a_crash() {
1163        let server = test_server().await;
1164        let line = request(
1165            6,
1166            "tools/call",
1167            r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
1168        );
1169        let response = server.handle_line(&line).await.unwrap();
1170        assert_eq!(response["result"]["isError"], true);
1171        let text = response["result"]["content"][0]["text"].as_str().unwrap();
1172        assert!(text.contains("unknown tool"), "{text}");
1173    }
1174
1175    #[tokio::test]
1176    async fn readonly_refuses_writes_with_an_mcp_error_result() {
1177        let server = test_server_with_profile(SafetyProfile::new(true, [], [])).await;
1178
1179        let line = request(
1180            7,
1181            "tools/call",
1182            r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
1183        );
1184        let response = server.handle_line(&line).await.unwrap();
1185        assert_eq!(response["result"]["isError"], true);
1186        let text = response["result"]["content"][0]["text"].as_str().unwrap();
1187        // Actionable: names the method, the verb and how to lift it.
1188        assert!(text.contains("gmail.users.messages.send"), "{text}");
1189        assert!(text.contains("POST"), "{text}");
1190        assert!(text.contains("Rerun without --readonly"), "{text}");
1191
1192        // A GET still runs under the same profile.
1193        let line = request(
1194            8,
1195            "tools/call",
1196            r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
1197        );
1198        let response = server.handle_line(&line).await.unwrap();
1199        assert_eq!(response["result"]["isError"], Value::Null);
1200    }
1201
1202    #[tokio::test]
1203    async fn a_missing_tool_name_is_an_error_result() {
1204        let server = test_server().await;
1205        let line = request(9, "tools/call", r#"{"arguments":{}}"#);
1206        let response = server.handle_line(&line).await.unwrap();
1207        assert_eq!(response["result"]["isError"], true);
1208    }
1209
1210    #[tokio::test]
1211    async fn a_stdin_body_file_is_refused_over_mcp() {
1212        let server = test_server().await;
1213        let line = request(
1214            10,
1215            "tools/call",
1216            r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
1217        );
1218        let response = server.handle_line(&line).await.unwrap();
1219        assert_eq!(response["result"]["isError"], true);
1220        let text = response["result"]["content"][0]["text"].as_str().unwrap();
1221        assert!(text.contains("stdin"), "{text}");
1222    }
1223
1224    #[tokio::test]
1225    async fn an_unknown_method_is_a_jsonrpc_error() {
1226        let server = test_server().await;
1227        let line = request(11, "resources/list", "{}");
1228        let response = server.handle_line(&line).await.unwrap();
1229        assert_eq!(response["error"]["code"], -32601);
1230        assert!(
1231            response["error"]["message"]
1232                .as_str()
1233                .unwrap()
1234                .contains("resources/list")
1235        );
1236        assert_eq!(response["id"], 11);
1237    }
1238
1239    #[tokio::test]
1240    async fn a_parse_error_answers_with_a_null_id() {
1241        let server = test_server().await;
1242        let response = server.handle_line("not json at all").await.unwrap();
1243        assert_eq!(response["error"]["code"], -32700);
1244        assert!(response["id"].is_null());
1245    }
1246
1247    #[tokio::test]
1248    async fn notifications_are_never_answered() {
1249        let server = test_server().await;
1250        assert!(
1251            server
1252                .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
1253                .await
1254                .is_none()
1255        );
1256        assert!(
1257            server
1258                .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
1259                .await
1260                .is_none()
1261        );
1262    }
1263}