1use crate::commands::api::{self, call_method};
29use crate::commands::safety::{SafetyProfile, is_write_verb};
30use crate::core::auth::GoogleAuth;
31use crate::discovery::{self, Method, Parameter, Service};
32use serde_json::{Map, Value, json};
33
34pub(crate) const PROTOCOL_VERSION: &str = "2025-11-25";
46
47const SUPPORTED_VERSIONS: [&str; 3] = ["2024-11-05", "2025-06-18", "2025-11-25"];
49
50const PARSE_ERROR: i64 = -32700;
52const INVALID_REQUEST: i64 = -32600;
53const METHOD_NOT_FOUND: i64 = -32601;
54
55pub struct McpServer {
58 auth: GoogleAuth,
59 profile: SafetyProfile,
60}
61
62impl McpServer {
63 pub fn new(auth: GoogleAuth, profile: SafetyProfile) -> Self {
64 Self { auth, profile }
65 }
66
67 pub fn tool_count(&self) -> usize {
69 discovery::services()
70 .values()
71 .map(|s| s.methods.len())
72 .sum()
73 }
74
75 pub async fn handle_line(&self, line: &str) -> Option<Value> {
80 let message: Value = match serde_json::from_str(line) {
81 Ok(message) => message,
82 Err(error) => {
83 return Some(error_response(
84 &Value::Null,
85 PARSE_ERROR,
86 format!("parse error: {error}"),
87 ));
88 }
89 };
90
91 let id = message.get("id").cloned()?;
94
95 let method = message.get("method").and_then(|m| m.as_str()).unwrap_or("");
96 let params = message.get("params").cloned().unwrap_or(Value::Null);
97
98 let response = match method {
99 "initialize" => json!({
100 "jsonrpc": "2.0",
101 "id": id,
102 "result": initialize_result(¶ms),
103 }),
104 "ping" => json!({ "jsonrpc": "2.0", "id": id, "result": {} }),
105 "tools/list" => json!({
106 "jsonrpc": "2.0",
107 "id": id,
108 "result": { "tools": tools_list() },
109 }),
110 "tools/call" => {
111 let result = self.tools_call(¶ms).await;
112 json!({ "jsonrpc": "2.0", "id": id, "result": result })
113 }
114 "" => error_response(&id, INVALID_REQUEST, "request has no method".to_owned()),
115 other => error_response(&id, METHOD_NOT_FOUND, format!("method not found: {other}")),
116 };
117 Some(response)
118 }
119
120 async fn tools_call(&self, params: &Value) -> Value {
125 let Some(name) = params.get("name").and_then(|n| n.as_str()) else {
126 return error_result("tools/call needs a tool `name`");
127 };
128 let arguments = params
129 .get("arguments")
130 .cloned()
131 .unwrap_or_else(|| Value::Object(Map::new()));
132 let Value::Object(args) = arguments else {
133 return error_result("tools/call `arguments` must be an object");
134 };
135 match self.invoke_tool(name, &args).await {
136 Ok(text) => ok_result(text),
137 Err(message) => error_result(message),
138 }
139 }
140
141 async fn invoke_tool(&self, name: &str, args: &Map<String, Value>) -> Result<String, String> {
142 let (service, method) = discovery::resolve(name)
143 .map_err(|message| format!("unknown tool `{name}`: {message}"))?;
144
145 self.profile.check(&service.name, method)?;
147
148 let mut merged = Map::new();
153 if let Some(Value::Object(object)) = args.get("params") {
154 merged.extend(object.clone());
155 }
156
157 let mut body_file: Option<String> = None;
158 let mut query: Vec<String> = Vec::new();
159 let mut dry_run = false;
160 for (key, value) in args {
161 match key.as_str() {
162 "params" => {}
163 "dry_run" | "dryRun" => dry_run = value.as_bool().unwrap_or(false),
167 "body_file" | "bodyFile" => body_file = value.as_str().map(str::to_owned),
168 "query" => {
169 if let Some(items) = value.as_array() {
170 query.extend(items.iter().filter_map(|v| v.as_str().map(str::to_owned)));
171 }
172 }
173 other => {
174 merged.insert(other.to_owned(), value.clone());
175 }
176 }
177 }
178
179 if body_file.as_deref() == Some("-") {
182 return Err(
183 "body_file `-` (stdin) is not available over MCP; pass a file path".to_owned(),
184 );
185 }
186
187 let payload = call_method(
188 Some(&self.auth),
189 service,
190 method,
191 merged,
192 api::CallOptions {
193 body_file,
194 verb_override: None,
197 query_extras: query,
198 dry_run,
199 },
200 )
201 .await
202 .map_err(|error| error.to_string())?;
203
204 serde_json::to_string(&payload)
205 .map_err(|error| format!("serialising the result failed: {error}"))
206 }
207}
208
209fn initialize_result(params: &Value) -> Value {
210 let requested = params.get("protocolVersion").and_then(|v| v.as_str());
211 let version = match requested {
212 Some(version) if SUPPORTED_VERSIONS.contains(&version) => (*version).to_owned(),
213 _ => PROTOCOL_VERSION.to_owned(),
214 };
215 json!({
216 "protocolVersion": version,
217 "capabilities": { "tools": {} },
218 "serverInfo": { "name": "grr", "version": env!("CARGO_PKG_VERSION") },
219 })
220}
221
222fn tools_list() -> Vec<Value> {
225 discovery::services()
226 .values()
227 .flat_map(|service| service.methods.iter().map(|m| tool_descriptor(service, m)))
228 .collect()
229}
230
231fn tool_descriptor(service: &Service, method: &Method) -> Value {
232 json!({
233 "name": format!("{}.{}", service.name, method.id),
234 "description": tool_description(method),
235 "inputSchema": input_schema(method),
236 })
237}
238
239fn tool_description(method: &Method) -> String {
242 let verb = method.http_method.to_ascii_uppercase();
243 let mut text = format!("{} [HTTP {verb}]", method.description);
244 if is_write_verb(&verb) {
245 text.push_str(" — write verb: needs auth scopes; refused under --readonly safety profiles");
246 } else {
247 text.push_str(" — needs auth scopes");
248 }
249 text
250}
251
252fn input_schema(method: &Method) -> Value {
256 let mut properties = Map::new();
257 for param in &method.parameters {
258 properties.insert(param.name.clone(), param_schema(param));
259 }
260 properties.insert(
261 "params".into(),
262 json!({
263 "type": "object",
264 "additionalProperties": true,
265 "description": "Parameter map merged into the request; typed parameters win.",
266 }),
267 );
268 properties.insert("body_file".into(), json!({
269 "type": "string",
270 "description": "Read the request body from this file ('-' for stdin) instead of building one from the parameters.",
271 }));
272 properties.insert("query".into(), json!({
273 "type": "array",
274 "items": { "type": "string" },
275 "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
276 }));
277 properties.insert(
278 "dry_run".into(),
279 json!({
280 "type": "boolean",
281 "description": "Return the request that would be sent, without sending it.",
282 }),
283 );
284
285 let mut schema = Map::new();
286 schema.insert("type".into(), json!("object"));
287 schema.insert("properties".into(), Value::Object(properties));
288 if !method.required.is_empty() {
289 schema.insert("required".into(), json!(method.required));
290 }
291 Value::Object(schema)
292}
293
294fn param_schema(param: &Parameter) -> Value {
299 let mut out = Map::new();
300 match param.kind.as_str() {
301 "string" => {
302 out.insert("type".into(), json!("string"));
303 }
304 "integer" => {
305 out.insert("type".into(), json!("integer"));
306 }
307 "number" => {
308 out.insert("type".into(), json!("number"));
309 }
310 "boolean" => {
311 out.insert("type".into(), json!("boolean"));
312 }
313 _ => {}
314 }
315 if !param.description.is_empty() {
316 out.insert("description".into(), json!(param.description));
317 }
318 if let Some(values) = ¶m.enum_values {
319 out.insert("enum".into(), json!(values));
320 }
321 let base = Value::Object(out);
322 if param.repeated {
323 json!({ "type": "array", "items": base })
324 } else {
325 base
326 }
327}
328
329fn error_response(id: &Value, code: i64, message: String) -> Value {
330 json!({
331 "jsonrpc": "2.0",
332 "id": id,
333 "error": { "code": code, "message": message },
334 })
335}
336
337fn ok_result(text: String) -> Value {
338 json!({ "content": [ { "type": "text", "text": text } ] })
339}
340
341fn error_result(message: impl Into<String>) -> Value {
342 json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
343}
344
345pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
350where
351 R: tokio::io::AsyncBufRead + Unpin,
352 W: tokio::io::AsyncWrite + Unpin,
353{
354 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
355
356 let mut lines = reader.lines();
357 while let Some(line) = lines.next_line().await? {
358 if line.trim().is_empty() {
359 continue;
360 }
361 if let Some(response) = server.handle_line(&line).await {
362 writer.write_all(response.to_string().as_bytes()).await?;
363 writer.write_all(b"\n").await?;
364 writer.flush().await?;
365 }
366 }
367 Ok(())
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::core::auth::TokenStorage;
374 use crate::core::config::OAuthConfig;
375
376 async fn test_server() -> McpServer {
377 test_server_with_profile(SafetyProfile::default()).await
378 }
379
380 async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
381 let config = OAuthConfig {
385 client_id: "test-id.apps.googleusercontent.com".into(),
386 client_secret: None,
387 ..Default::default()
388 };
389 let storage = TokenStorage {
390 access_token: "test-token".into(),
391 refresh_token: None,
392 expires_at: u64::MAX,
393 token_type: "Bearer".into(),
394 scope: String::new(),
395 };
396 let auth = GoogleAuth::with_token(config, storage).await.unwrap();
397 McpServer::new(auth, profile)
398 }
399
400 fn request(id: i64, method: &str, params: &str) -> String {
401 format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
402 }
403
404 #[tokio::test]
405 async fn the_stdio_loop_pipes_the_full_handshake() {
406 let server = test_server().await;
407 let input = format!(
408 "{}\n{}\n{}\n{}\n",
409 request(
410 1,
411 "initialize",
412 r#"{"protocolVersion":"2024-11-05","clientInfo":{"name":"t","version":"0"}}"#
413 ),
414 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
415 request(2, "tools/list", "{}"),
416 request(3, "ping", "{}"),
417 );
418 let mut out: Vec<u8> = Vec::new();
419 serve(&server, input.as_bytes(), &mut out).await.unwrap();
420
421 let lines: Vec<Value> = String::from_utf8(out)
423 .unwrap()
424 .lines()
425 .map(|l| serde_json::from_str(l).unwrap())
426 .collect();
427 assert_eq!(lines.len(), 3);
428
429 assert_eq!(lines[0]["jsonrpc"], "2.0");
430 assert_eq!(lines[0]["id"], 1);
431 assert!(lines[0]["result"]["protocolVersion"].is_string());
432 assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
433 assert_eq!(lines[0]["result"]["serverInfo"]["name"], "grr");
434 assert!(lines[0]["result"]["serverInfo"]["version"].is_string());
435
436 assert_eq!(lines[1]["id"], 2);
437 let tools = lines[1]["result"]["tools"].as_array().unwrap();
438 assert_eq!(tools.len(), server.tool_count());
439
440 assert_eq!(lines[2]["id"], 3);
441 assert_eq!(lines[2]["result"], json!({}));
442 }
443
444 #[tokio::test]
445 async fn initialize_echoes_a_supported_protocol_version() {
446 let server = test_server().await;
447
448 let line = request(1, "initialize", r#"{"protocolVersion":"2025-06-18"}"#);
449 let response = server.handle_line(&line).await.unwrap();
450 assert_eq!(response["result"]["protocolVersion"], "2025-06-18");
451
452 let line = request(2, "initialize", r#"{"protocolVersion":"2025-11-25"}"#);
453 let response = server.handle_line(&line).await.unwrap();
454 assert_eq!(response["result"]["protocolVersion"], "2025-11-25");
455
456 let line = request(3, "initialize", r#"{"protocolVersion":"1999-01-01"}"#);
458 let response = server.handle_line(&line).await.unwrap();
459 assert_eq!(response["result"]["protocolVersion"], PROTOCOL_VERSION);
460 }
461
462 #[tokio::test]
463 async fn tools_list_covers_every_index_method_with_an_input_schema() {
464 let server = test_server().await;
465 let line = request(2, "tools/list", "{}");
466 let response = server.handle_line(&line).await.unwrap();
467 let tools = response["result"]["tools"].as_array().unwrap();
468
469 let expected: usize = discovery::services()
471 .values()
472 .map(|s| s.methods.len())
473 .sum();
474 assert_eq!(tools.len(), expected);
475
476 for tool in tools {
477 let name = tool["name"].as_str().unwrap();
478 assert!(name.contains('.'), "{name} is not a dotted id");
480 assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
481 assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
482 assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
483 assert!(
484 tool["description"].as_str().unwrap().contains("HTTP"),
485 "{name}"
486 );
487 }
488 }
489
490 #[tokio::test]
491 async fn input_schemas_carry_the_escape_hatches_and_required_params() {
492 let server = test_server().await;
493 let line = request(2, "tools/list", "{}");
494 let response = server.handle_line(&line).await.unwrap();
495 let tools = response["result"]["tools"].as_array().unwrap();
496 let schema = tools
497 .iter()
498 .find(|t| t["name"] == "gmail.users.messages.list")
499 .map(|t| t["inputSchema"].clone())
500 .unwrap();
501
502 for prop in ["params", "body_file", "query", "dry_run"] {
503 assert!(schema["properties"][prop].is_object(), "missing {prop}");
504 }
505 let required: Vec<&str> = schema["required"]
506 .as_array()
507 .unwrap()
508 .iter()
509 .filter_map(|v| v.as_str())
510 .collect();
511 assert!(required.contains(&"userId"), "{required:?}");
512
513 assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
515 assert_eq!(schema["properties"]["labelIds"]["type"], "array");
516 assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
517 }
518
519 #[tokio::test]
520 async fn enum_values_surface_in_the_schema() {
521 let server = test_server().await;
522 let line = request(2, "tools/list", "{}");
523 let response = server.handle_line(&line).await.unwrap();
524 let tools = response["result"]["tools"].as_array().unwrap();
525 let schema = tools
526 .iter()
527 .find(|t| t["name"] == "calendar.calendarList.list")
528 .map(|t| t["inputSchema"].clone())
529 .unwrap();
530 let values = schema["properties"]["minAccessRole"]["enum"]
531 .as_array()
532 .unwrap();
533 assert!(
534 values.iter().any(|v| v.as_str() == Some("owner")),
535 "{values:?}"
536 );
537 }
538
539 #[tokio::test]
540 async fn tools_call_dry_run_matches_the_api_call_payload() {
541 let server = test_server().await;
542 let line = request(
543 3,
544 "tools/call",
545 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
546 );
547 let response = server.handle_line(&line).await.unwrap();
548 assert_eq!(response["result"]["isError"], Value::Null);
549 let text = response["result"]["content"][0]["text"].as_str().unwrap();
550 let payload: Value = serde_json::from_str(text).unwrap();
551
552 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
555 let mut params = Map::new();
556 params.insert("userId".into(), json!("me"));
557 let plan = api::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
558 assert_eq!(payload, api::dry_run_payload(method, &plan));
559 }
560
561 #[tokio::test]
562 async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
563 let server = test_server().await;
564 let line = request(
565 4,
566 "tools/call",
567 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
568 );
569 let response = server.handle_line(&line).await.unwrap();
570 let text = response["result"]["content"][0]["text"].as_str().unwrap();
571 let payload: Value = serde_json::from_str(text).unwrap();
572 assert_eq!(payload["dryRun"], true);
573 }
574
575 #[tokio::test]
576 async fn tool_arguments_win_over_the_params_escape_hatch() {
577 let server = test_server().await;
578 let line = request(
579 5,
580 "tools/call",
581 r#"{"name":"gmail.users.messages.list","arguments":
582 {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
583 );
584 let response = server.handle_line(&line).await.unwrap();
585 let text = response["result"]["content"][0]["text"].as_str().unwrap();
586 let payload: Value = serde_json::from_str(text).unwrap();
587 let url = payload["url"].as_str().unwrap();
589 assert!(url.contains("/users/me/messages"), "{url}");
590 assert!(url.contains("q=is%3Aunread"), "{url}");
591 assert!(!url.contains("someone-else"), "{url}");
592 }
593
594 #[tokio::test]
595 async fn an_unknown_tool_is_an_error_result_not_a_crash() {
596 let server = test_server().await;
597 let line = request(
598 6,
599 "tools/call",
600 r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
601 );
602 let response = server.handle_line(&line).await.unwrap();
603 assert_eq!(response["result"]["isError"], true);
604 let text = response["result"]["content"][0]["text"].as_str().unwrap();
605 assert!(text.contains("unknown tool"), "{text}");
606 }
607
608 #[tokio::test]
609 async fn readonly_refuses_writes_with_an_mcp_error_result() {
610 let server = test_server_with_profile(SafetyProfile::readonly()).await;
611
612 let line = request(
613 7,
614 "tools/call",
615 r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
616 );
617 let response = server.handle_line(&line).await.unwrap();
618 assert_eq!(response["result"]["isError"], true);
619 let text = response["result"]["content"][0]["text"].as_str().unwrap();
620 assert!(text.contains("gmail.users.messages.send"), "{text}");
622 assert!(text.contains("POST"), "{text}");
623 assert!(text.contains("Rerun without --readonly"), "{text}");
624
625 let line = request(
627 8,
628 "tools/call",
629 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
630 );
631 let response = server.handle_line(&line).await.unwrap();
632 assert_eq!(response["result"]["isError"], Value::Null);
633 }
634
635 #[tokio::test]
636 async fn a_missing_tool_name_is_an_error_result() {
637 let server = test_server().await;
638 let line = request(9, "tools/call", r#"{"arguments":{}}"#);
639 let response = server.handle_line(&line).await.unwrap();
640 assert_eq!(response["result"]["isError"], true);
641 }
642
643 #[tokio::test]
644 async fn a_stdin_body_file_is_refused_over_mcp() {
645 let server = test_server().await;
646 let line = request(
647 10,
648 "tools/call",
649 r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
650 );
651 let response = server.handle_line(&line).await.unwrap();
652 assert_eq!(response["result"]["isError"], true);
653 let text = response["result"]["content"][0]["text"].as_str().unwrap();
654 assert!(text.contains("stdin"), "{text}");
655 }
656
657 #[tokio::test]
658 async fn an_unknown_method_is_a_jsonrpc_error() {
659 let server = test_server().await;
660 let line = request(11, "resources/list", "{}");
661 let response = server.handle_line(&line).await.unwrap();
662 assert_eq!(response["error"]["code"], -32601);
663 assert!(
664 response["error"]["message"]
665 .as_str()
666 .unwrap()
667 .contains("resources/list")
668 );
669 assert_eq!(response["id"], 11);
670 }
671
672 #[tokio::test]
673 async fn a_parse_error_answers_with_a_null_id() {
674 let server = test_server().await;
675 let response = server.handle_line("not json at all").await.unwrap();
676 assert_eq!(response["error"]["code"], -32700);
677 assert!(response["id"].is_null());
678 }
679
680 #[tokio::test]
681 async fn notifications_are_never_answered() {
682 let server = test_server().await;
683 assert!(
684 server
685 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
686 .await
687 .is_none()
688 );
689 assert!(
690 server
691 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
692 .await
693 .is_none()
694 );
695 }
696}