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