1use crate::commands::api::{self, call_method};
33use crate::commands::safety::{SafetyProfile, is_write_verb};
34use crate::core::auth::GoogleAuth;
35use crate::discovery::{self, Method, Parameter, Service};
36use serde_json::{Map, Value, json};
37
38pub(crate) const PROTOCOL_VERSION: &str = "2026-07-28";
50
51const SUPPORTED_VERSIONS: [&str; 1] = ["2026-07-28"];
55
56const PARSE_ERROR: i64 = -32700;
58const INVALID_REQUEST: i64 = -32600;
59const METHOD_NOT_FOUND: i64 = -32601;
60
61pub struct McpServer {
64 auth: GoogleAuth,
65 profile: SafetyProfile,
66}
67
68impl McpServer {
69 pub fn new(auth: GoogleAuth, profile: SafetyProfile) -> Self {
70 Self { auth, profile }
71 }
72
73 pub fn tool_count(&self) -> usize {
75 discovery::services()
76 .values()
77 .map(|s| s.methods.len())
78 .sum()
79 }
80
81 pub async fn handle_line(&self, line: &str) -> Option<Value> {
86 let message: Value = match serde_json::from_str(line) {
87 Ok(message) => message,
88 Err(error) => {
89 return Some(error_response(
90 &Value::Null,
91 PARSE_ERROR,
92 format!("parse error: {error}"),
93 ));
94 }
95 };
96
97 let id = message.get("id").cloned()?;
100
101 let method = message.get("method").and_then(|m| m.as_str()).unwrap_or("");
102 let params = message.get("params").cloned().unwrap_or(Value::Null);
103
104 let response = match method {
105 "server/discover" => json!({
106 "jsonrpc": "2.0",
107 "id": id,
108 "result": discover_result(),
109 }),
110 "tools/list" => json!({
111 "jsonrpc": "2.0",
112 "id": id,
113 "result": { "tools": tools_list(), "_meta": server_meta() },
114 }),
115 "tools/call" => {
116 let mut result = self.tools_call(¶ms).await;
117 if let Some(object) = result.as_object_mut() {
120 object.insert("_meta".to_owned(), server_meta());
121 }
122 json!({ "jsonrpc": "2.0", "id": id, "result": result })
123 }
124 "initialize" | "ping" | "logging/setLevel" => error_response(
128 &id,
129 METHOD_NOT_FOUND,
130 format!(
131 "`{method}` is not part of MCP {PROTOCOL_VERSION} — the handshake is gone. \
132 Call `server/discover` for the revisions served, then `tools/*` inline."
133 ),
134 ),
135 "" => error_response(&id, INVALID_REQUEST, "request has no method".to_owned()),
136 other => error_response(&id, METHOD_NOT_FOUND, format!("method not found: {other}")),
137 };
138 Some(response)
139 }
140
141 async fn tools_call(&self, params: &Value) -> Value {
146 let Some(name) = params.get("name").and_then(|n| n.as_str()) else {
147 return error_result("tools/call needs a tool `name`");
148 };
149 let arguments = params
150 .get("arguments")
151 .cloned()
152 .unwrap_or_else(|| Value::Object(Map::new()));
153 let Value::Object(args) = arguments else {
154 return error_result("tools/call `arguments` must be an object");
155 };
156 match self.invoke_tool(name, &args).await {
157 Ok(text) => ok_result(text),
158 Err(message) => error_result(message),
159 }
160 }
161
162 async fn invoke_tool(&self, name: &str, args: &Map<String, Value>) -> Result<String, String> {
163 let (service, method) = discovery::resolve(name)
164 .map_err(|message| format!("unknown tool `{name}`: {message}"))?;
165
166 self.profile.check(&service.name, method)?;
168
169 let mut merged = Map::new();
174 if let Some(Value::Object(object)) = args.get("params") {
175 merged.extend(object.clone());
176 }
177
178 let mut body_file: Option<String> = None;
179 let mut query: Vec<String> = Vec::new();
180 let mut dry_run = false;
181 for (key, value) in args {
182 match key.as_str() {
183 "params" => {}
184 "dry_run" | "dryRun" => dry_run = value.as_bool().unwrap_or(false),
188 "body_file" | "bodyFile" => body_file = value.as_str().map(str::to_owned),
189 "query" => {
190 if let Some(items) = value.as_array() {
191 query.extend(items.iter().filter_map(|v| v.as_str().map(str::to_owned)));
192 }
193 }
194 other => {
195 merged.insert(other.to_owned(), value.clone());
196 }
197 }
198 }
199
200 if body_file.as_deref() == Some("-") {
203 return Err(
204 "body_file `-` (stdin) is not available over MCP; pass a file path".to_owned(),
205 );
206 }
207
208 let payload = call_method(
209 Some(&self.auth),
210 service,
211 method,
212 merged,
213 api::CallOptions {
214 body_file,
215 verb_override: None,
218 query_extras: query,
219 dry_run,
220 },
221 )
222 .await
223 .map_err(|error| error.to_string())?;
224
225 serde_json::to_string(&payload)
226 .map_err(|error| format!("serialising the result failed: {error}"))
227 }
228}
229
230fn server_info() -> Value {
234 json!({ "name": "grr", "version": env!("CARGO_PKG_VERSION") })
235}
236
237fn server_meta() -> Value {
238 json!({ "io.modelcontextprotocol/serverInfo": server_info() })
239}
240
241fn discover_result() -> Value {
246 json!({
247 "resultType": "complete",
248 "supportedVersions": SUPPORTED_VERSIONS,
249 "capabilities": { "tools": {} },
250 "_meta": server_meta(),
251 "instructions": "Google Workspace and Google Cloud APIs from the embedded Discovery \
252 index. Tool names are dotted method ids (gmail.users.messages.list); \
253 --readonly, --deny-service and --deny-verb gate every write.",
254 })
255}
256
257fn tools_list() -> Vec<Value> {
260 discovery::services()
261 .values()
262 .flat_map(|service| service.methods.iter().map(|m| tool_descriptor(service, m)))
263 .collect()
264}
265
266fn tool_descriptor(service: &Service, method: &Method) -> Value {
267 json!({
268 "name": format!("{}.{}", service.name, method.id),
269 "description": tool_description(method),
270 "inputSchema": input_schema(method),
271 })
272}
273
274fn tool_description(method: &Method) -> String {
277 let verb = method.http_method.to_ascii_uppercase();
278 let mut text = format!("{} [HTTP {verb}]", method.description);
279 if is_write_verb(&verb) {
280 text.push_str(" — write verb: needs auth scopes; refused under --readonly safety profiles");
281 } else {
282 text.push_str(" — needs auth scopes");
283 }
284 text
285}
286
287fn input_schema(method: &Method) -> Value {
291 let mut properties = Map::new();
292 for param in &method.parameters {
293 properties.insert(param.name.clone(), param_schema(param));
294 }
295 properties.insert(
296 "params".into(),
297 json!({
298 "type": "object",
299 "additionalProperties": true,
300 "description": "Parameter map merged into the request; typed parameters win.",
301 }),
302 );
303 properties.insert("body_file".into(), json!({
304 "type": "string",
305 "description": "Read the request body from this file ('-' for stdin) instead of building one from the parameters.",
306 }));
307 properties.insert("query".into(), json!({
308 "type": "array",
309 "items": { "type": "string" },
310 "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
311 }));
312 properties.insert(
313 "dry_run".into(),
314 json!({
315 "type": "boolean",
316 "description": "Return the request that would be sent, without sending it.",
317 }),
318 );
319
320 let mut schema = Map::new();
321 schema.insert("type".into(), json!("object"));
322 schema.insert("properties".into(), Value::Object(properties));
323 if !method.required.is_empty() {
324 schema.insert("required".into(), json!(method.required));
325 }
326 Value::Object(schema)
327}
328
329fn param_schema(param: &Parameter) -> Value {
334 let mut out = Map::new();
335 match param.kind.as_str() {
336 "string" => {
337 out.insert("type".into(), json!("string"));
338 }
339 "integer" => {
340 out.insert("type".into(), json!("integer"));
341 }
342 "number" => {
343 out.insert("type".into(), json!("number"));
344 }
345 "boolean" => {
346 out.insert("type".into(), json!("boolean"));
347 }
348 _ => {}
349 }
350 if !param.description.is_empty() {
351 out.insert("description".into(), json!(param.description));
352 }
353 if let Some(values) = ¶m.enum_values {
354 out.insert("enum".into(), json!(values));
355 }
356 let base = Value::Object(out);
357 if param.repeated {
358 json!({ "type": "array", "items": base })
359 } else {
360 base
361 }
362}
363
364fn error_response(id: &Value, code: i64, message: String) -> Value {
365 json!({
366 "jsonrpc": "2.0",
367 "id": id,
368 "error": { "code": code, "message": message },
369 })
370}
371
372fn ok_result(text: String) -> Value {
373 json!({ "content": [ { "type": "text", "text": text } ] })
374}
375
376fn error_result(message: impl Into<String>) -> Value {
377 json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
378}
379
380pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
385where
386 R: tokio::io::AsyncBufRead + Unpin,
387 W: tokio::io::AsyncWrite + Unpin,
388{
389 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
390
391 let mut lines = reader.lines();
392 while let Some(line) = lines.next_line().await? {
393 if line.trim().is_empty() {
394 continue;
395 }
396 if let Some(response) = server.handle_line(&line).await {
397 writer.write_all(response.to_string().as_bytes()).await?;
398 writer.write_all(b"\n").await?;
399 writer.flush().await?;
400 }
401 }
402 Ok(())
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::core::auth::TokenStorage;
409 use crate::core::config::OAuthConfig;
410
411 async fn test_server() -> McpServer {
412 test_server_with_profile(SafetyProfile::default()).await
413 }
414
415 async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
416 let config = OAuthConfig {
420 client_id: "test-id.apps.googleusercontent.com".into(),
421 client_secret: None,
422 ..Default::default()
423 };
424 let storage = TokenStorage {
425 access_token: "test-token".into(),
426 refresh_token: None,
427 expires_at: u64::MAX,
428 token_type: "Bearer".into(),
429 scope: String::new(),
430 };
431 let auth = GoogleAuth::with_token(config, storage).await.unwrap();
432 McpServer::new(auth, profile)
433 }
434
435 fn request(id: i64, method: &str, params: &str) -> String {
436 format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
437 }
438
439 #[tokio::test]
440 async fn the_stdio_loop_pipes_the_stateless_requests() {
441 let server = test_server().await;
442 let input = format!(
443 "{}\n{}\n{}\n",
444 request(1, "server/discover", "{}"),
445 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
446 request(2, "tools/list", "{}"),
447 );
448 let mut out: Vec<u8> = Vec::new();
449 serve(&server, input.as_bytes(), &mut out).await.unwrap();
450
451 let lines: Vec<Value> = String::from_utf8(out)
453 .unwrap()
454 .lines()
455 .map(|l| serde_json::from_str(l).unwrap())
456 .collect();
457 assert_eq!(lines.len(), 2);
458
459 assert_eq!(lines[0]["jsonrpc"], "2.0");
460 assert_eq!(lines[0]["id"], 1);
461 assert_eq!(lines[0]["result"]["resultType"], "complete");
462 assert_eq!(
463 lines[0]["result"]["supportedVersions"],
464 json!(["2026-07-28"])
465 );
466 assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
467 assert_eq!(
468 lines[0]["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
469 "grr"
470 );
471
472 assert_eq!(lines[1]["id"], 2);
473 let tools = lines[1]["result"]["tools"].as_array().unwrap();
474 assert_eq!(tools.len(), server.tool_count());
475 }
476
477 #[tokio::test]
481 async fn legacy_handshake_methods_are_refused_with_the_revision_to_speak() {
482 let server = test_server().await;
483
484 for method in ["initialize", "ping", "logging/setLevel"] {
485 let line = request(1, method, "{}");
486 let response = server.handle_line(&line).await.unwrap();
487
488 assert_eq!(response["error"]["code"], METHOD_NOT_FOUND, "{method}");
489 let message = response["error"]["message"].as_str().unwrap();
490 assert!(message.contains(PROTOCOL_VERSION), "{method}: {message}");
491 assert!(message.contains("server/discover"), "{method}: {message}");
492 }
493 }
494
495 #[tokio::test]
499 async fn server_discover_answers_without_a_handshake() {
500 let server = test_server().await;
501
502 let line = request(
503 1,
504 "server/discover",
505 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0"}}}"#,
506 );
507 let response = server.handle_line(&line).await.unwrap();
508 let result = &response["result"];
509
510 assert_eq!(result["resultType"], "complete");
511 assert_eq!(
512 result["supportedVersions"],
513 json!(["2026-07-28"]),
514 "the current revision, and only it"
515 );
516 assert!(result["capabilities"]["tools"].is_object());
517 assert_eq!(
518 result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
519 "grr"
520 );
521
522 let inline = request(2, "tools/list", "{}");
524 let response = server.handle_line(&inline).await.unwrap();
525 assert!(response["result"]["tools"].is_array());
526 }
527
528 #[tokio::test]
531 async fn results_identify_the_server_per_request() {
532 let server = test_server().await;
533
534 for method in ["tools/list", "server/discover"] {
535 let line = request(1, method, "{}");
536 let response = server.handle_line(&line).await.unwrap();
537 assert_eq!(
538 response["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "grr",
539 "{method} did not identify the server"
540 );
541 }
542 }
543
544 #[tokio::test]
545 async fn tools_list_covers_every_index_method_with_an_input_schema() {
546 let server = test_server().await;
547 let line = request(2, "tools/list", "{}");
548 let response = server.handle_line(&line).await.unwrap();
549 let tools = response["result"]["tools"].as_array().unwrap();
550
551 let expected: usize = discovery::services()
553 .values()
554 .map(|s| s.methods.len())
555 .sum();
556 assert_eq!(tools.len(), expected);
557
558 for tool in tools {
559 let name = tool["name"].as_str().unwrap();
560 assert!(name.contains('.'), "{name} is not a dotted id");
562 assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
563 assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
564 assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
565 assert!(
566 tool["description"].as_str().unwrap().contains("HTTP"),
567 "{name}"
568 );
569 }
570 }
571
572 #[tokio::test]
573 async fn input_schemas_carry_the_escape_hatches_and_required_params() {
574 let server = test_server().await;
575 let line = request(2, "tools/list", "{}");
576 let response = server.handle_line(&line).await.unwrap();
577 let tools = response["result"]["tools"].as_array().unwrap();
578 let schema = tools
579 .iter()
580 .find(|t| t["name"] == "gmail.users.messages.list")
581 .map(|t| t["inputSchema"].clone())
582 .unwrap();
583
584 for prop in ["params", "body_file", "query", "dry_run"] {
585 assert!(schema["properties"][prop].is_object(), "missing {prop}");
586 }
587 let required: Vec<&str> = schema["required"]
588 .as_array()
589 .unwrap()
590 .iter()
591 .filter_map(|v| v.as_str())
592 .collect();
593 assert!(required.contains(&"userId"), "{required:?}");
594
595 assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
597 assert_eq!(schema["properties"]["labelIds"]["type"], "array");
598 assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
599 }
600
601 #[tokio::test]
602 async fn enum_values_surface_in_the_schema() {
603 let server = test_server().await;
604 let line = request(2, "tools/list", "{}");
605 let response = server.handle_line(&line).await.unwrap();
606 let tools = response["result"]["tools"].as_array().unwrap();
607 let schema = tools
608 .iter()
609 .find(|t| t["name"] == "calendar.calendarList.list")
610 .map(|t| t["inputSchema"].clone())
611 .unwrap();
612 let values = schema["properties"]["minAccessRole"]["enum"]
613 .as_array()
614 .unwrap();
615 assert!(
616 values.iter().any(|v| v.as_str() == Some("owner")),
617 "{values:?}"
618 );
619 }
620
621 #[tokio::test]
622 async fn tools_call_dry_run_matches_the_api_call_payload() {
623 let server = test_server().await;
624 let line = request(
625 3,
626 "tools/call",
627 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
628 );
629 let response = server.handle_line(&line).await.unwrap();
630 assert_eq!(response["result"]["isError"], Value::Null);
631 let text = response["result"]["content"][0]["text"].as_str().unwrap();
632 let payload: Value = serde_json::from_str(text).unwrap();
633
634 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
637 let mut params = Map::new();
638 params.insert("userId".into(), json!("me"));
639 let plan = api::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
640 assert_eq!(payload, api::dry_run_payload(method, &plan));
641 }
642
643 #[tokio::test]
644 async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
645 let server = test_server().await;
646 let line = request(
647 4,
648 "tools/call",
649 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
650 );
651 let response = server.handle_line(&line).await.unwrap();
652 let text = response["result"]["content"][0]["text"].as_str().unwrap();
653 let payload: Value = serde_json::from_str(text).unwrap();
654 assert_eq!(payload["dryRun"], true);
655 }
656
657 #[tokio::test]
658 async fn tool_arguments_win_over_the_params_escape_hatch() {
659 let server = test_server().await;
660 let line = request(
661 5,
662 "tools/call",
663 r#"{"name":"gmail.users.messages.list","arguments":
664 {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
665 );
666 let response = server.handle_line(&line).await.unwrap();
667 let text = response["result"]["content"][0]["text"].as_str().unwrap();
668 let payload: Value = serde_json::from_str(text).unwrap();
669 let url = payload["url"].as_str().unwrap();
671 assert!(url.contains("/users/me/messages"), "{url}");
672 assert!(url.contains("q=is%3Aunread"), "{url}");
673 assert!(!url.contains("someone-else"), "{url}");
674 }
675
676 #[tokio::test]
677 async fn an_unknown_tool_is_an_error_result_not_a_crash() {
678 let server = test_server().await;
679 let line = request(
680 6,
681 "tools/call",
682 r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
683 );
684 let response = server.handle_line(&line).await.unwrap();
685 assert_eq!(response["result"]["isError"], true);
686 let text = response["result"]["content"][0]["text"].as_str().unwrap();
687 assert!(text.contains("unknown tool"), "{text}");
688 }
689
690 #[tokio::test]
691 async fn readonly_refuses_writes_with_an_mcp_error_result() {
692 let server = test_server_with_profile(SafetyProfile::new(true, [], [])).await;
693
694 let line = request(
695 7,
696 "tools/call",
697 r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
698 );
699 let response = server.handle_line(&line).await.unwrap();
700 assert_eq!(response["result"]["isError"], true);
701 let text = response["result"]["content"][0]["text"].as_str().unwrap();
702 assert!(text.contains("gmail.users.messages.send"), "{text}");
704 assert!(text.contains("POST"), "{text}");
705 assert!(text.contains("Rerun without --readonly"), "{text}");
706
707 let line = request(
709 8,
710 "tools/call",
711 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
712 );
713 let response = server.handle_line(&line).await.unwrap();
714 assert_eq!(response["result"]["isError"], Value::Null);
715 }
716
717 #[tokio::test]
718 async fn a_missing_tool_name_is_an_error_result() {
719 let server = test_server().await;
720 let line = request(9, "tools/call", r#"{"arguments":{}}"#);
721 let response = server.handle_line(&line).await.unwrap();
722 assert_eq!(response["result"]["isError"], true);
723 }
724
725 #[tokio::test]
726 async fn a_stdin_body_file_is_refused_over_mcp() {
727 let server = test_server().await;
728 let line = request(
729 10,
730 "tools/call",
731 r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
732 );
733 let response = server.handle_line(&line).await.unwrap();
734 assert_eq!(response["result"]["isError"], true);
735 let text = response["result"]["content"][0]["text"].as_str().unwrap();
736 assert!(text.contains("stdin"), "{text}");
737 }
738
739 #[tokio::test]
740 async fn an_unknown_method_is_a_jsonrpc_error() {
741 let server = test_server().await;
742 let line = request(11, "resources/list", "{}");
743 let response = server.handle_line(&line).await.unwrap();
744 assert_eq!(response["error"]["code"], -32601);
745 assert!(
746 response["error"]["message"]
747 .as_str()
748 .unwrap()
749 .contains("resources/list")
750 );
751 assert_eq!(response["id"], 11);
752 }
753
754 #[tokio::test]
755 async fn a_parse_error_answers_with_a_null_id() {
756 let server = test_server().await;
757 let response = server.handle_line("not json at all").await.unwrap();
758 assert_eq!(response["error"]["code"], -32700);
759 assert!(response["id"].is_null());
760 }
761
762 #[tokio::test]
763 async fn notifications_are_never_answered() {
764 let server = test_server().await;
765 assert!(
766 server
767 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
768 .await
769 .is_none()
770 );
771 assert!(
772 server
773 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
774 .await
775 .is_none()
776 );
777 }
778}