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(service, method),
304 "inputSchema": input_schema(method),
305 })
306}
307
308const DESCRIPTION_OVERRIDES: &[(&str, &str)] = &[(
314 "scripts.run",
315 "Executes a function in an Apps Script project (Apps Script API script.run). The script \
316 runs headlessly with the scopes its manifest declares — review them before calling: \
317 arbitrary script code executes with your authorization. Returns the script function's \
318 response (OPS_ERROR / ops for failures); script-side failures come back as tool errors, \
319 not dead servers. Prefer `script processes list` to observe running executions and \
320 `script projects get` to read a project's source before running it.",
321)];
322
323fn sibling_summary(service: &Service, method: &Method) -> String {
327 let verb = method.id.rsplit('.').next().unwrap_or("");
330 let resource = &method.id[..method.id.len().saturating_sub(verb.len()).saturating_sub(1)];
331 if resource.is_empty() {
332 return String::new();
333 }
334 let mut siblings: Vec<&str> = service
335 .methods
336 .iter()
337 .filter(|m| {
338 m.id != method.id
339 && m.id
340 .strip_suffix(&format!(".{}", m.id.rsplit('.').next().unwrap_or("")))
341 == Some(resource)
342 })
343 .map(|m| m.id.rsplit('.').next().unwrap_or(""))
344 .collect();
345 siblings.sort_unstable();
346 siblings.dedup();
347 if siblings.is_empty() {
348 String::new()
349 } else {
350 format!(" Siblings over the same resource: {}.", siblings.join(", "))
351 }
352}
353
354fn returns_summary(method: &Method) -> &'static str {
357 match method.http_method.to_ascii_uppercase().as_str() {
358 "GET" => "Returns the requested resource as JSON",
359 "DELETE" => "Returns the post-delete resource state (or empty) as JSON",
360 _ => "Returns the API's response resource as JSON",
361 }
362}
363
364fn tool_description(service: &Service, method: &Method) -> String {
370 let verb = method.http_method.to_ascii_uppercase();
371 let base = DESCRIPTION_OVERRIDES
372 .iter()
373 .find(|(id, _)| *id == method.id)
374 .map(|(_, text)| text.to_string())
375 .unwrap_or_else(|| {
376 if method.description.is_empty() {
377 format!(
378 "{}s {} via the {} API",
379 verb.to_ascii_lowercase(),
380 method.id.rsplit('.').next().unwrap_or("resource"),
381 service.name
382 )
383 } else {
384 method.description.clone()
385 }
386 });
387
388 let base = base.trim_end_matches('.');
391 let mut text = format!("{base} [HTTP {verb}]");
392
393 match verb.as_str() {
395 "POST" => text.push_str(" — creates or triggers a change"),
396 "DELETE" => text.push_str(
397 " — removes the resource; irreversible unless the API documents a trash/undo flow",
398 ),
399 "PATCH" | "PUT" => text.push_str(" — updates the resource"),
400 _ => {}
401 }
402 if is_write_verb(&verb) {
403 text.push_str("; refused under --readonly safety profiles");
404 }
405
406 if text.ends_with(' ') {
411 text.pop();
412 }
413 text.push_str(&format!(
414 ". {} — request bodies are built from the typed parameters (typed flags win over the `params` map), or read whole from `body_file`",
415 returns_summary(method)
416 ));
417
418 text.push_str(&sibling_summary(service, method));
419 text
420}
421
422fn input_schema(method: &Method) -> Value {
426 let mut properties = Map::new();
427 for param in &method.parameters {
428 properties.insert(param.name.clone(), param_schema(param));
429 }
430 properties.insert(
431 "params".into(),
432 json!({
433 "type": "object",
434 "additionalProperties": true,
435 "description": "Parameter map merged into the request; typed parameters win.",
436 }),
437 );
438 properties.insert("body_file".into(), json!({
439 "type": "string",
440 "description": "Read the request body from this file ('-' for stdin) instead of building one from the parameters.",
441 }));
442 properties.insert("query".into(), json!({
443 "type": "array",
444 "items": { "type": "string" },
445 "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
446 }));
447 properties.insert(
448 "dry_run".into(),
449 json!({
450 "type": "boolean",
451 "description": "Return the request that would be sent, without sending it.",
452 }),
453 );
454
455 let mut schema = Map::new();
456 schema.insert("type".into(), json!("object"));
457 schema.insert("properties".into(), Value::Object(properties));
458 if !method.required.is_empty() {
459 schema.insert("required".into(), json!(method.required));
460 }
461 Value::Object(schema)
462}
463
464fn param_schema(param: &Parameter) -> Value {
469 let mut out = Map::new();
470 match param.kind.as_str() {
471 "string" => {
472 out.insert("type".into(), json!("string"));
473 }
474 "integer" => {
475 out.insert("type".into(), json!("integer"));
476 }
477 "number" => {
478 out.insert("type".into(), json!("number"));
479 }
480 "boolean" => {
481 out.insert("type".into(), json!("boolean"));
482 }
483 _ => {}
484 }
485 if !param.description.is_empty() {
486 out.insert("description".into(), json!(param.description));
487 }
488 if let Some(values) = ¶m.enum_values {
489 out.insert("enum".into(), json!(values));
490 }
491 let base = Value::Object(out);
492 if param.repeated {
493 json!({ "type": "array", "items": base })
494 } else {
495 base
496 }
497}
498
499fn error_response(id: &Value, code: i64, message: String) -> Value {
500 json!({
501 "jsonrpc": "2.0",
502 "id": id,
503 "error": { "code": code, "message": message },
504 })
505}
506
507fn ok_result(text: String) -> Value {
508 json!({ "content": [ { "type": "text", "text": text } ] })
509}
510
511fn error_result(message: impl Into<String>) -> Value {
512 json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
513}
514
515pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
520where
521 R: tokio::io::AsyncBufRead + Unpin,
522 W: tokio::io::AsyncWrite + Unpin,
523{
524 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
525
526 let mut lines = reader.lines();
527 while let Some(line) = lines.next_line().await? {
528 if line.trim().is_empty() {
529 continue;
530 }
531 if let Some(response) = server.handle_line(&line).await {
532 writer.write_all(response.to_string().as_bytes()).await?;
533 writer.write_all(b"\n").await?;
534 writer.flush().await?;
535 }
536 }
537 Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use crate::core::auth::TokenStorage;
544 use crate::core::config::OAuthConfig;
545
546 async fn test_server() -> McpServer {
547 test_server_with_profile(SafetyProfile::default()).await
548 }
549
550 async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
551 let config = OAuthConfig {
555 client_id: "test-id.apps.googleusercontent.com".into(),
556 client_secret: None,
557 ..Default::default()
558 };
559 let storage = TokenStorage {
560 access_token: "test-token".into(),
561 refresh_token: None,
562 expires_at: u64::MAX,
563 token_type: "Bearer".into(),
564 scope: String::new(),
565 };
566 let auth = GoogleAuth::with_token(config, storage).await.unwrap();
567 McpServer::new(auth, profile)
568 }
569
570 fn request(id: i64, method: &str, params: &str) -> String {
571 format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
572 }
573
574 #[tokio::test]
575 async fn the_stdio_loop_pipes_the_stateless_requests() {
576 let server = test_server().await;
577 let input = format!(
578 "{}\n{}\n{}\n",
579 request(1, "server/discover", "{}"),
580 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
581 request(2, "tools/list", "{}"),
582 );
583 let mut out: Vec<u8> = Vec::new();
584 serve(&server, input.as_bytes(), &mut out).await.unwrap();
585
586 let lines: Vec<Value> = String::from_utf8(out)
588 .unwrap()
589 .lines()
590 .map(|l| serde_json::from_str(l).unwrap())
591 .collect();
592 assert_eq!(lines.len(), 2);
593
594 assert_eq!(lines[0]["jsonrpc"], "2.0");
595 assert_eq!(lines[0]["id"], 1);
596 assert_eq!(lines[0]["result"]["resultType"], "complete");
597 assert_eq!(
598 lines[0]["result"]["supportedVersions"],
599 json!(["2026-07-28"])
600 );
601 assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
602 assert_eq!(
603 lines[0]["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
604 "grr"
605 );
606
607 assert_eq!(lines[1]["id"], 2);
608 let tools = lines[1]["result"]["tools"].as_array().unwrap();
609 assert_eq!(tools.len(), server.tool_count());
610 }
611
612 #[tokio::test]
617 async fn legacy_handshake_methods_are_refused_with_the_revision_to_speak() {
618 let server = test_server().await;
619
620 for method in ["ping", "logging/setLevel"] {
621 let line = request(1, method, "{}");
622 let response = server.handle_line(&line).await.unwrap();
623
624 assert_eq!(response["error"]["code"], METHOD_NOT_FOUND, "{method}");
625 }
626 }
627
628 #[tokio::test]
633 async fn initialize_answers_a_minimal_legacy_handshake() {
634 let server = test_server().await;
635
636 let line = request(
637 1,
638 "initialize",
639 r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}"#,
640 );
641 let response = server.handle_line(&line).await.unwrap();
642 let result = &response["result"];
643 assert_eq!(result["protocolVersion"], "2025-06-18");
644 assert_eq!(result["serverInfo"]["name"], "grr");
645 assert!(result["capabilities"]["tools"].is_object());
646 let line = request(2, "tools/list", "{}");
648 let response = server.handle_line(&line).await.unwrap();
649 assert_eq!(
650 response["result"]["tools"].as_array().unwrap().len(),
651 server.tool_count()
652 );
653 }
654
655 #[tokio::test]
659 async fn server_discover_answers_without_a_handshake() {
660 let server = test_server().await;
661
662 let line = request(
663 1,
664 "server/discover",
665 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0"}}}"#,
666 );
667 let response = server.handle_line(&line).await.unwrap();
668 let result = &response["result"];
669
670 assert_eq!(result["resultType"], "complete");
671 assert_eq!(
672 result["supportedVersions"],
673 json!(["2026-07-28"]),
674 "the current revision, and only it"
675 );
676 assert!(result["capabilities"]["tools"].is_object());
677 assert_eq!(
678 result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
679 "grr"
680 );
681
682 let inline = request(2, "tools/list", "{}");
684 let response = server.handle_line(&inline).await.unwrap();
685 assert!(response["result"]["tools"].is_array());
686 }
687
688 #[tokio::test]
691 async fn results_identify_the_server_per_request() {
692 let server = test_server().await;
693
694 for method in ["tools/list", "server/discover"] {
695 let line = request(1, method, "{}");
696 let response = server.handle_line(&line).await.unwrap();
697 assert_eq!(
698 response["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "grr",
699 "{method} did not identify the server"
700 );
701 }
702 }
703
704 #[tokio::test]
705 async fn tools_list_covers_every_index_method_with_an_input_schema() {
706 let server = test_server().await;
707 let line = request(2, "tools/list", "{}");
708 let response = server.handle_line(&line).await.unwrap();
709 let tools = response["result"]["tools"].as_array().unwrap();
710
711 let expected: usize = discovery::services()
713 .values()
714 .map(|s| s.methods.len())
715 .sum();
716 assert_eq!(tools.len(), expected);
717
718 for tool in tools {
719 let name = tool["name"].as_str().unwrap();
720 assert!(name.contains('.'), "{name} is not a dotted id");
722 assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
723 assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
724 assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
725 assert!(
726 tool["description"].as_str().unwrap().contains("HTTP"),
727 "{name}"
728 );
729 }
730 }
731
732 #[tokio::test]
737 async fn descriptions_carry_returns_body_and_siblings() {
738 let server = test_server().await;
739 let line = request(2, "tools/list", "{}");
740 let response = server.handle_line(&line).await.unwrap();
741 let tools = response["result"]["tools"].as_array().unwrap();
742 let by_name = |name: &str| {
743 tools
744 .iter()
745 .find(|t| t["name"] == name)
746 .unwrap_or_else(|| panic!("{name} missing"))["description"]
747 .as_str()
748 .unwrap()
749 .to_owned()
750 };
751
752 let delete = by_name("gmail.users.messages.delete");
753 assert!(
754 delete.contains("irreversible"),
755 "delete should warn about irreversibility: {delete}"
756 );
757 assert!(
758 delete.contains("refused under --readonly"),
759 "write verbs name the safety gate: {delete}"
760 );
761
762 let list = by_name("calendar.events.list");
763 assert!(
764 list.contains("Siblings over the same resource"),
765 "list tools name their CRUD neighbours: {list}"
766 );
767 assert!(list.contains("as JSON"), "returns are described: {list}");
768 assert!(
769 list.contains("body_file"),
770 "body building is described: {list}"
771 );
772
773 let run = by_name("script.scripts.run");
774 assert!(
775 run.starts_with("Executes a function"),
776 "Discovery shipped no description for scripts.run; the curated override must supply one: {run}"
777 );
778
779 for tool in tools {
782 let d = tool["description"].as_str().unwrap();
783 assert!(
784 !d.contains(". [HTTP"),
785 "{}: doubled period before the verb tag in {d}",
786 tool["name"]
787 );
788 }
789 }
790
791 #[tokio::test]
792 async fn input_schemas_carry_the_escape_hatches_and_required_params() {
793 let server = test_server().await;
794 let line = request(2, "tools/list", "{}");
795 let response = server.handle_line(&line).await.unwrap();
796 let tools = response["result"]["tools"].as_array().unwrap();
797 let schema = tools
798 .iter()
799 .find(|t| t["name"] == "gmail.users.messages.list")
800 .map(|t| t["inputSchema"].clone())
801 .unwrap();
802
803 for prop in ["params", "body_file", "query", "dry_run"] {
804 assert!(schema["properties"][prop].is_object(), "missing {prop}");
805 }
806 let required: Vec<&str> = schema["required"]
807 .as_array()
808 .unwrap()
809 .iter()
810 .filter_map(|v| v.as_str())
811 .collect();
812 assert!(required.contains(&"userId"), "{required:?}");
813
814 assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
816 assert_eq!(schema["properties"]["labelIds"]["type"], "array");
817 assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
818 }
819
820 #[tokio::test]
821 async fn enum_values_surface_in_the_schema() {
822 let server = test_server().await;
823 let line = request(2, "tools/list", "{}");
824 let response = server.handle_line(&line).await.unwrap();
825 let tools = response["result"]["tools"].as_array().unwrap();
826 let schema = tools
827 .iter()
828 .find(|t| t["name"] == "calendar.calendarList.list")
829 .map(|t| t["inputSchema"].clone())
830 .unwrap();
831 let values = schema["properties"]["minAccessRole"]["enum"]
832 .as_array()
833 .unwrap();
834 assert!(
835 values.iter().any(|v| v.as_str() == Some("owner")),
836 "{values:?}"
837 );
838 }
839
840 #[tokio::test]
841 async fn tools_call_dry_run_matches_the_api_call_payload() {
842 let server = test_server().await;
843 let line = request(
844 3,
845 "tools/call",
846 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
847 );
848 let response = server.handle_line(&line).await.unwrap();
849 assert_eq!(response["result"]["isError"], Value::Null);
850 let text = response["result"]["content"][0]["text"].as_str().unwrap();
851 let payload: Value = serde_json::from_str(text).unwrap();
852
853 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
856 let mut params = Map::new();
857 params.insert("userId".into(), json!("me"));
858 let plan = api::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
859 assert_eq!(payload, api::dry_run_payload(method, &plan));
860 }
861
862 #[tokio::test]
863 async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
864 let server = test_server().await;
865 let line = request(
866 4,
867 "tools/call",
868 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
869 );
870 let response = server.handle_line(&line).await.unwrap();
871 let text = response["result"]["content"][0]["text"].as_str().unwrap();
872 let payload: Value = serde_json::from_str(text).unwrap();
873 assert_eq!(payload["dryRun"], true);
874 }
875
876 #[tokio::test]
877 async fn tool_arguments_win_over_the_params_escape_hatch() {
878 let server = test_server().await;
879 let line = request(
880 5,
881 "tools/call",
882 r#"{"name":"gmail.users.messages.list","arguments":
883 {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
884 );
885 let response = server.handle_line(&line).await.unwrap();
886 let text = response["result"]["content"][0]["text"].as_str().unwrap();
887 let payload: Value = serde_json::from_str(text).unwrap();
888 let url = payload["url"].as_str().unwrap();
890 assert!(url.contains("/users/me/messages"), "{url}");
891 assert!(url.contains("q=is%3Aunread"), "{url}");
892 assert!(!url.contains("someone-else"), "{url}");
893 }
894
895 #[tokio::test]
896 async fn an_unknown_tool_is_an_error_result_not_a_crash() {
897 let server = test_server().await;
898 let line = request(
899 6,
900 "tools/call",
901 r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
902 );
903 let response = server.handle_line(&line).await.unwrap();
904 assert_eq!(response["result"]["isError"], true);
905 let text = response["result"]["content"][0]["text"].as_str().unwrap();
906 assert!(text.contains("unknown tool"), "{text}");
907 }
908
909 #[tokio::test]
910 async fn readonly_refuses_writes_with_an_mcp_error_result() {
911 let server = test_server_with_profile(SafetyProfile::new(true, [], [])).await;
912
913 let line = request(
914 7,
915 "tools/call",
916 r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
917 );
918 let response = server.handle_line(&line).await.unwrap();
919 assert_eq!(response["result"]["isError"], true);
920 let text = response["result"]["content"][0]["text"].as_str().unwrap();
921 assert!(text.contains("gmail.users.messages.send"), "{text}");
923 assert!(text.contains("POST"), "{text}");
924 assert!(text.contains("Rerun without --readonly"), "{text}");
925
926 let line = request(
928 8,
929 "tools/call",
930 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
931 );
932 let response = server.handle_line(&line).await.unwrap();
933 assert_eq!(response["result"]["isError"], Value::Null);
934 }
935
936 #[tokio::test]
937 async fn a_missing_tool_name_is_an_error_result() {
938 let server = test_server().await;
939 let line = request(9, "tools/call", r#"{"arguments":{}}"#);
940 let response = server.handle_line(&line).await.unwrap();
941 assert_eq!(response["result"]["isError"], true);
942 }
943
944 #[tokio::test]
945 async fn a_stdin_body_file_is_refused_over_mcp() {
946 let server = test_server().await;
947 let line = request(
948 10,
949 "tools/call",
950 r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
951 );
952 let response = server.handle_line(&line).await.unwrap();
953 assert_eq!(response["result"]["isError"], true);
954 let text = response["result"]["content"][0]["text"].as_str().unwrap();
955 assert!(text.contains("stdin"), "{text}");
956 }
957
958 #[tokio::test]
959 async fn an_unknown_method_is_a_jsonrpc_error() {
960 let server = test_server().await;
961 let line = request(11, "resources/list", "{}");
962 let response = server.handle_line(&line).await.unwrap();
963 assert_eq!(response["error"]["code"], -32601);
964 assert!(
965 response["error"]["message"]
966 .as_str()
967 .unwrap()
968 .contains("resources/list")
969 );
970 assert_eq!(response["id"], 11);
971 }
972
973 #[tokio::test]
974 async fn a_parse_error_answers_with_a_null_id() {
975 let server = test_server().await;
976 let response = server.handle_line("not json at all").await.unwrap();
977 assert_eq!(response["error"]["code"], -32700);
978 assert!(response["id"].is_null());
979 }
980
981 #[tokio::test]
982 async fn notifications_are_never_answered() {
983 let server = test_server().await;
984 assert!(
985 server
986 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
987 .await
988 .is_none()
989 );
990 assert!(
991 server
992 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
993 .await
994 .is_none()
995 );
996 }
997}