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" => {
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 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 self.profile.check(&service.name, method)?;
202
203 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 "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 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 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
264fn 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
275fn 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
291fn 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 json!({
302 "name": format!("{}.{}", service.name, method.id),
303 "description": tool_description(method),
304 "inputSchema": input_schema(method),
305 })
306}
307
308fn tool_description(method: &Method) -> String {
311 let verb = method.http_method.to_ascii_uppercase();
312 let mut text = format!("{} [HTTP {verb}]", method.description);
313 if is_write_verb(&verb) {
314 text.push_str(" — write verb: needs auth scopes; refused under --readonly safety profiles");
315 } else {
316 text.push_str(" — needs auth scopes");
317 }
318 text
319}
320
321fn input_schema(method: &Method) -> Value {
325 let mut properties = Map::new();
326 for param in &method.parameters {
327 properties.insert(param.name.clone(), param_schema(param));
328 }
329 properties.insert(
330 "params".into(),
331 json!({
332 "type": "object",
333 "additionalProperties": true,
334 "description": "Parameter map merged into the request; typed parameters win.",
335 }),
336 );
337 properties.insert("body_file".into(), json!({
338 "type": "string",
339 "description": "Read the request body from this file ('-' for stdin) instead of building one from the parameters.",
340 }));
341 properties.insert("query".into(), json!({
342 "type": "array",
343 "items": { "type": "string" },
344 "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
345 }));
346 properties.insert(
347 "dry_run".into(),
348 json!({
349 "type": "boolean",
350 "description": "Return the request that would be sent, without sending it.",
351 }),
352 );
353
354 let mut schema = Map::new();
355 schema.insert("type".into(), json!("object"));
356 schema.insert("properties".into(), Value::Object(properties));
357 if !method.required.is_empty() {
358 schema.insert("required".into(), json!(method.required));
359 }
360 Value::Object(schema)
361}
362
363fn param_schema(param: &Parameter) -> Value {
368 let mut out = Map::new();
369 match param.kind.as_str() {
370 "string" => {
371 out.insert("type".into(), json!("string"));
372 }
373 "integer" => {
374 out.insert("type".into(), json!("integer"));
375 }
376 "number" => {
377 out.insert("type".into(), json!("number"));
378 }
379 "boolean" => {
380 out.insert("type".into(), json!("boolean"));
381 }
382 _ => {}
383 }
384 if !param.description.is_empty() {
385 out.insert("description".into(), json!(param.description));
386 }
387 if let Some(values) = ¶m.enum_values {
388 out.insert("enum".into(), json!(values));
389 }
390 let base = Value::Object(out);
391 if param.repeated {
392 json!({ "type": "array", "items": base })
393 } else {
394 base
395 }
396}
397
398fn error_response(id: &Value, code: i64, message: String) -> Value {
399 json!({
400 "jsonrpc": "2.0",
401 "id": id,
402 "error": { "code": code, "message": message },
403 })
404}
405
406fn ok_result(text: String) -> Value {
407 json!({ "content": [ { "type": "text", "text": text } ] })
408}
409
410fn error_result(message: impl Into<String>) -> Value {
411 json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
412}
413
414pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
419where
420 R: tokio::io::AsyncBufRead + Unpin,
421 W: tokio::io::AsyncWrite + Unpin,
422{
423 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
424
425 let mut lines = reader.lines();
426 while let Some(line) = lines.next_line().await? {
427 if line.trim().is_empty() {
428 continue;
429 }
430 if let Some(response) = server.handle_line(&line).await {
431 writer.write_all(response.to_string().as_bytes()).await?;
432 writer.write_all(b"\n").await?;
433 writer.flush().await?;
434 }
435 }
436 Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::core::auth::TokenStorage;
443 use crate::core::config::OAuthConfig;
444
445 async fn test_server() -> McpServer {
446 test_server_with_profile(SafetyProfile::default()).await
447 }
448
449 async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
450 let config = OAuthConfig {
454 client_id: "test-id.apps.googleusercontent.com".into(),
455 client_secret: None,
456 ..Default::default()
457 };
458 let storage = TokenStorage {
459 access_token: "test-token".into(),
460 refresh_token: None,
461 expires_at: u64::MAX,
462 token_type: "Bearer".into(),
463 scope: String::new(),
464 };
465 let auth = GoogleAuth::with_token(config, storage).await.unwrap();
466 McpServer::new(auth, profile)
467 }
468
469 fn request(id: i64, method: &str, params: &str) -> String {
470 format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
471 }
472
473 #[tokio::test]
474 async fn the_stdio_loop_pipes_the_stateless_requests() {
475 let server = test_server().await;
476 let input = format!(
477 "{}\n{}\n{}\n",
478 request(1, "server/discover", "{}"),
479 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
480 request(2, "tools/list", "{}"),
481 );
482 let mut out: Vec<u8> = Vec::new();
483 serve(&server, input.as_bytes(), &mut out).await.unwrap();
484
485 let lines: Vec<Value> = String::from_utf8(out)
487 .unwrap()
488 .lines()
489 .map(|l| serde_json::from_str(l).unwrap())
490 .collect();
491 assert_eq!(lines.len(), 2);
492
493 assert_eq!(lines[0]["jsonrpc"], "2.0");
494 assert_eq!(lines[0]["id"], 1);
495 assert_eq!(lines[0]["result"]["resultType"], "complete");
496 assert_eq!(
497 lines[0]["result"]["supportedVersions"],
498 json!(["2026-07-28"])
499 );
500 assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
501 assert_eq!(
502 lines[0]["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
503 "grr"
504 );
505
506 assert_eq!(lines[1]["id"], 2);
507 let tools = lines[1]["result"]["tools"].as_array().unwrap();
508 assert_eq!(tools.len(), server.tool_count());
509 }
510
511 #[tokio::test]
516 async fn legacy_handshake_methods_are_refused_with_the_revision_to_speak() {
517 let server = test_server().await;
518
519 for method in ["ping", "logging/setLevel"] {
520 let line = request(1, method, "{}");
521 let response = server.handle_line(&line).await.unwrap();
522
523 assert_eq!(response["error"]["code"], METHOD_NOT_FOUND, "{method}");
524 }
525 }
526
527 #[tokio::test]
532 async fn initialize_answers_a_minimal_legacy_handshake() {
533 let server = test_server().await;
534
535 let line = request(
536 1,
537 "initialize",
538 r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}"#,
539 );
540 let response = server.handle_line(&line).await.unwrap();
541 let result = &response["result"];
542 assert_eq!(result["protocolVersion"], "2025-06-18");
543 assert_eq!(result["serverInfo"]["name"], "grr");
544 assert!(result["capabilities"]["tools"].is_object());
545 let line = request(2, "tools/list", "{}");
547 let response = server.handle_line(&line).await.unwrap();
548 assert_eq!(
549 response["result"]["tools"].as_array().unwrap().len(),
550 server.tool_count()
551 );
552 }
553
554 #[tokio::test]
558 async fn server_discover_answers_without_a_handshake() {
559 let server = test_server().await;
560
561 let line = request(
562 1,
563 "server/discover",
564 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0"}}}"#,
565 );
566 let response = server.handle_line(&line).await.unwrap();
567 let result = &response["result"];
568
569 assert_eq!(result["resultType"], "complete");
570 assert_eq!(
571 result["supportedVersions"],
572 json!(["2026-07-28"]),
573 "the current revision, and only it"
574 );
575 assert!(result["capabilities"]["tools"].is_object());
576 assert_eq!(
577 result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
578 "grr"
579 );
580
581 let inline = request(2, "tools/list", "{}");
583 let response = server.handle_line(&inline).await.unwrap();
584 assert!(response["result"]["tools"].is_array());
585 }
586
587 #[tokio::test]
590 async fn results_identify_the_server_per_request() {
591 let server = test_server().await;
592
593 for method in ["tools/list", "server/discover"] {
594 let line = request(1, method, "{}");
595 let response = server.handle_line(&line).await.unwrap();
596 assert_eq!(
597 response["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "grr",
598 "{method} did not identify the server"
599 );
600 }
601 }
602
603 #[tokio::test]
604 async fn tools_list_covers_every_index_method_with_an_input_schema() {
605 let server = test_server().await;
606 let line = request(2, "tools/list", "{}");
607 let response = server.handle_line(&line).await.unwrap();
608 let tools = response["result"]["tools"].as_array().unwrap();
609
610 let expected: usize = discovery::services()
612 .values()
613 .map(|s| s.methods.len())
614 .sum();
615 assert_eq!(tools.len(), expected);
616
617 for tool in tools {
618 let name = tool["name"].as_str().unwrap();
619 assert!(name.contains('.'), "{name} is not a dotted id");
621 assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
622 assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
623 assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
624 assert!(
625 tool["description"].as_str().unwrap().contains("HTTP"),
626 "{name}"
627 );
628 }
629 }
630
631 #[tokio::test]
632 async fn input_schemas_carry_the_escape_hatches_and_required_params() {
633 let server = test_server().await;
634 let line = request(2, "tools/list", "{}");
635 let response = server.handle_line(&line).await.unwrap();
636 let tools = response["result"]["tools"].as_array().unwrap();
637 let schema = tools
638 .iter()
639 .find(|t| t["name"] == "gmail.users.messages.list")
640 .map(|t| t["inputSchema"].clone())
641 .unwrap();
642
643 for prop in ["params", "body_file", "query", "dry_run"] {
644 assert!(schema["properties"][prop].is_object(), "missing {prop}");
645 }
646 let required: Vec<&str> = schema["required"]
647 .as_array()
648 .unwrap()
649 .iter()
650 .filter_map(|v| v.as_str())
651 .collect();
652 assert!(required.contains(&"userId"), "{required:?}");
653
654 assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
656 assert_eq!(schema["properties"]["labelIds"]["type"], "array");
657 assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
658 }
659
660 #[tokio::test]
661 async fn enum_values_surface_in_the_schema() {
662 let server = test_server().await;
663 let line = request(2, "tools/list", "{}");
664 let response = server.handle_line(&line).await.unwrap();
665 let tools = response["result"]["tools"].as_array().unwrap();
666 let schema = tools
667 .iter()
668 .find(|t| t["name"] == "calendar.calendarList.list")
669 .map(|t| t["inputSchema"].clone())
670 .unwrap();
671 let values = schema["properties"]["minAccessRole"]["enum"]
672 .as_array()
673 .unwrap();
674 assert!(
675 values.iter().any(|v| v.as_str() == Some("owner")),
676 "{values:?}"
677 );
678 }
679
680 #[tokio::test]
681 async fn tools_call_dry_run_matches_the_api_call_payload() {
682 let server = test_server().await;
683 let line = request(
684 3,
685 "tools/call",
686 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
687 );
688 let response = server.handle_line(&line).await.unwrap();
689 assert_eq!(response["result"]["isError"], Value::Null);
690 let text = response["result"]["content"][0]["text"].as_str().unwrap();
691 let payload: Value = serde_json::from_str(text).unwrap();
692
693 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
696 let mut params = Map::new();
697 params.insert("userId".into(), json!("me"));
698 let plan = api::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
699 assert_eq!(payload, api::dry_run_payload(method, &plan));
700 }
701
702 #[tokio::test]
703 async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
704 let server = test_server().await;
705 let line = request(
706 4,
707 "tools/call",
708 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
709 );
710 let response = server.handle_line(&line).await.unwrap();
711 let text = response["result"]["content"][0]["text"].as_str().unwrap();
712 let payload: Value = serde_json::from_str(text).unwrap();
713 assert_eq!(payload["dryRun"], true);
714 }
715
716 #[tokio::test]
717 async fn tool_arguments_win_over_the_params_escape_hatch() {
718 let server = test_server().await;
719 let line = request(
720 5,
721 "tools/call",
722 r#"{"name":"gmail.users.messages.list","arguments":
723 {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
724 );
725 let response = server.handle_line(&line).await.unwrap();
726 let text = response["result"]["content"][0]["text"].as_str().unwrap();
727 let payload: Value = serde_json::from_str(text).unwrap();
728 let url = payload["url"].as_str().unwrap();
730 assert!(url.contains("/users/me/messages"), "{url}");
731 assert!(url.contains("q=is%3Aunread"), "{url}");
732 assert!(!url.contains("someone-else"), "{url}");
733 }
734
735 #[tokio::test]
736 async fn an_unknown_tool_is_an_error_result_not_a_crash() {
737 let server = test_server().await;
738 let line = request(
739 6,
740 "tools/call",
741 r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
742 );
743 let response = server.handle_line(&line).await.unwrap();
744 assert_eq!(response["result"]["isError"], true);
745 let text = response["result"]["content"][0]["text"].as_str().unwrap();
746 assert!(text.contains("unknown tool"), "{text}");
747 }
748
749 #[tokio::test]
750 async fn readonly_refuses_writes_with_an_mcp_error_result() {
751 let server = test_server_with_profile(SafetyProfile::new(true, [], [])).await;
752
753 let line = request(
754 7,
755 "tools/call",
756 r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
757 );
758 let response = server.handle_line(&line).await.unwrap();
759 assert_eq!(response["result"]["isError"], true);
760 let text = response["result"]["content"][0]["text"].as_str().unwrap();
761 assert!(text.contains("gmail.users.messages.send"), "{text}");
763 assert!(text.contains("POST"), "{text}");
764 assert!(text.contains("Rerun without --readonly"), "{text}");
765
766 let line = request(
768 8,
769 "tools/call",
770 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
771 );
772 let response = server.handle_line(&line).await.unwrap();
773 assert_eq!(response["result"]["isError"], Value::Null);
774 }
775
776 #[tokio::test]
777 async fn a_missing_tool_name_is_an_error_result() {
778 let server = test_server().await;
779 let line = request(9, "tools/call", r#"{"arguments":{}}"#);
780 let response = server.handle_line(&line).await.unwrap();
781 assert_eq!(response["result"]["isError"], true);
782 }
783
784 #[tokio::test]
785 async fn a_stdin_body_file_is_refused_over_mcp() {
786 let server = test_server().await;
787 let line = request(
788 10,
789 "tools/call",
790 r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
791 );
792 let response = server.handle_line(&line).await.unwrap();
793 assert_eq!(response["result"]["isError"], true);
794 let text = response["result"]["content"][0]["text"].as_str().unwrap();
795 assert!(text.contains("stdin"), "{text}");
796 }
797
798 #[tokio::test]
799 async fn an_unknown_method_is_a_jsonrpc_error() {
800 let server = test_server().await;
801 let line = request(11, "resources/list", "{}");
802 let response = server.handle_line(&line).await.unwrap();
803 assert_eq!(response["error"]["code"], -32601);
804 assert!(
805 response["error"]["message"]
806 .as_str()
807 .unwrap()
808 .contains("resources/list")
809 );
810 assert_eq!(response["id"], 11);
811 }
812
813 #[tokio::test]
814 async fn a_parse_error_answers_with_a_null_id() {
815 let server = test_server().await;
816 let response = server.handle_line("not json at all").await.unwrap();
817 assert_eq!(response["error"]["code"], -32700);
818 assert!(response["id"].is_null());
819 }
820
821 #[tokio::test]
822 async fn notifications_are_never_answered() {
823 let server = test_server().await;
824 assert!(
825 server
826 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
827 .await
828 .is_none()
829 );
830 assert!(
831 server
832 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
833 .await
834 .is_none()
835 );
836 }
837}