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 let output_schema = method
306 .response_ref
307 .as_deref()
308 .and_then(|name| service.schemas.get(name))
309 .map(|schema| serde_json::to_value(schema).expect("Schema serializes"));
310 let mut descriptor = json!({
311 "name": format!("{}.{}", service.name, method.id),
312 "description": tool_description(service, method),
313 "inputSchema": input_schema(service, method),
314 });
315 if let Some(schema) = output_schema {
316 descriptor
317 .as_object_mut()
318 .expect("descriptor is an object")
319 .insert("outputSchema".into(), schema);
320 }
321 descriptor
322}
323
324const DESCRIPTION_OVERRIDES: &[(&str, &str)] = &[(
330 "scripts.run",
331 "Executes a function in an Apps Script project (Apps Script API script.run). The script \
332 runs headlessly with the scopes its manifest declares — review them before calling: \
333 arbitrary script code executes with your authorization. Returns the script function's \
334 response (OPS_ERROR / ops for failures); script-side failures come back as tool errors, \
335 not dead servers. Prefer `script processes list` to observe running executions and \
336 `script projects get` to read a project's source before running it.",
337)];
338
339fn sibling_summary(service: &Service, method: &Method) -> String {
343 let verb = method.id.rsplit('.').next().unwrap_or("");
346 let resource = &method.id[..method.id.len().saturating_sub(verb.len()).saturating_sub(1)];
347 if resource.is_empty() {
348 return String::new();
349 }
350 let mut siblings: Vec<&str> = service
351 .methods
352 .iter()
353 .filter(|m| {
354 m.id != method.id
355 && m.id
356 .strip_suffix(&format!(".{}", m.id.rsplit('.').next().unwrap_or("")))
357 == Some(resource)
358 })
359 .map(|m| m.id.rsplit('.').next().unwrap_or(""))
360 .collect();
361 siblings.sort_unstable();
362 siblings.dedup();
363 if siblings.is_empty() {
364 String::new()
365 } else {
366 format!(" Siblings over the same resource: {}.", siblings.join(", "))
367 }
368}
369
370fn returns_summary(method: &Method) -> &'static str {
373 match method.http_method.to_ascii_uppercase().as_str() {
374 "GET" => "Returns the requested resource as JSON",
375 "DELETE" => "Returns the post-delete resource state (or empty) as JSON",
376 _ => "Returns the API's response resource as JSON",
377 }
378}
379
380fn tool_description(service: &Service, method: &Method) -> String {
386 let verb = method.http_method.to_ascii_uppercase();
387 let base = DESCRIPTION_OVERRIDES
388 .iter()
389 .find(|(id, _)| *id == method.id)
390 .map(|(_, text)| text.to_string())
391 .unwrap_or_else(|| {
392 if method.description.is_empty() {
393 format!(
394 "{}s {} via the {} API",
395 verb.to_ascii_lowercase(),
396 method.id.rsplit('.').next().unwrap_or("resource"),
397 service.name
398 )
399 } else {
400 method.description.clone()
401 }
402 });
403
404 let base = base.trim_end_matches('.');
407 let mut text = format!("{base} [HTTP {verb}]");
408
409 match verb.as_str() {
411 "POST" => text.push_str(" — creates or triggers a change"),
412 "DELETE" => text.push_str(
413 " — removes the resource; irreversible unless the API documents a trash/undo flow",
414 ),
415 "PATCH" | "PUT" => text.push_str(" — updates the resource"),
416 _ => {}
417 }
418 if is_write_verb(&verb) {
419 text.push_str("; refused under --readonly safety profiles");
420 }
421
422 if text.ends_with(' ') {
427 text.pop();
428 }
429 match &method.request_ref {
430 Some(body_type) => {
431 let fields = request_body_fields(service, body_type);
432 text.push_str(&format!(
433 ". {} — request body is a JSON {body_type} object{fields}; pass it via `params` or whole via `body_file`",
434 returns_summary(method)
435 ));
436 }
437 None => text.push_str(&format!(
438 ". {} — request bodies are built from the typed parameters (typed flags win over the `params` map), or read whole from `body_file`",
439 returns_summary(method)
440 )),
441 }
442
443 text.push_str(&sibling_summary(service, method));
444 text
445}
446
447fn request_body_fields(service: &Service, schema_name: &str) -> String {
451 let Some(schema) = service.schemas.get(schema_name) else {
452 return String::new();
453 };
454 let Some(properties) = &schema.properties else {
455 return String::new();
456 };
457 let mut names: Vec<&String> = properties.keys().collect();
458 names.sort_unstable();
459 if names.is_empty() {
460 return String::new();
461 }
462 if names.len() > 8 {
463 format!(
464 " accepting {} and {} more fields",
465 names[..8]
466 .iter()
467 .map(|s| s.as_str())
468 .collect::<Vec<_>>()
469 .join(", "),
470 names.len() - 8
471 )
472 } else {
473 format!(
474 " accepting {}",
475 names
476 .iter()
477 .map(|s| s.as_str())
478 .collect::<Vec<_>>()
479 .join(", ")
480 )
481 }
482}
483
484fn input_schema(service: &Service, method: &Method) -> Value {
488 let mut properties = Map::new();
489 for param in &method.parameters {
490 properties.insert(param.name.clone(), param_schema(param));
491 }
492 properties.insert(
493 "params".into(),
494 json!({
495 "type": "object",
496 "additionalProperties": true,
497 "description": "Parameter map merged into the request; typed parameters win.",
498 }),
499 );
500 let body_file_desc = match &method.request_ref {
501 Some(name) => format!(
502 "Read the request body from this file ('-' for stdin) instead of building one from the parameters. The body is a JSON {name} object."
503 ),
504 None => "Read the request body from this file ('-' for stdin) instead of building one from the parameters.".to_owned(),
505 };
506 properties.insert(
507 "body_file".into(),
508 json!({
509 "type": "string",
510 "description": body_file_desc,
511 }),
512 );
513 properties.insert("query".into(), json!({
514 "type": "array",
515 "items": { "type": "string" },
516 "description": "Extra KEY=VALUE query pairs for endpoints whose parameters Discovery does not describe.",
517 }));
518 if let Some(name) = &method.request_ref
521 && let Some(schema) = service.schemas.get(name)
522 {
523 let mut body_schema = serde_json::to_value(schema).expect("Schema serializes");
524 if let Some(obj) = body_schema.as_object_mut() {
525 obj.insert(
526 "description".into(),
527 json!(format!("Request body of type {name}: pass it via `params` (merged into the built body) or whole via `body_file`.")),
528 );
529 }
530 properties.insert("request_body".into(), body_schema);
531 }
532 properties.insert(
533 "dry_run".into(),
534 json!({
535 "type": "boolean",
536 "description": "Return the request that would be sent, without sending it.",
537 }),
538 );
539
540 let mut schema = Map::new();
541 schema.insert("type".into(), json!("object"));
542 schema.insert("properties".into(), Value::Object(properties));
543 if !method.required.is_empty() {
544 schema.insert("required".into(), json!(method.required));
545 }
546 Value::Object(schema)
547}
548
549fn param_schema(param: &Parameter) -> Value {
554 let mut out = Map::new();
555 match param.kind.as_str() {
556 "string" => {
557 out.insert("type".into(), json!("string"));
558 }
559 "integer" => {
560 out.insert("type".into(), json!("integer"));
561 }
562 "number" => {
563 out.insert("type".into(), json!("number"));
564 }
565 "boolean" => {
566 out.insert("type".into(), json!("boolean"));
567 }
568 _ => {}
569 }
570 if let Some(format) = ¶m.format {
571 out.insert("format".into(), json!(format));
575 }
576 if let Some(minimum) = param.minimum {
577 out.insert("minimum".into(), json!(minimum));
578 }
579 if let Some(maximum) = param.maximum {
580 out.insert("maximum".into(), json!(maximum));
581 }
582 if let Some(pattern) = ¶m.pattern {
583 out.insert("pattern".into(), json!(pattern));
584 }
585 if let Some(default) = ¶m.default_value {
586 out.insert("default".into(), json!(default));
587 }
588 let mut description = String::new();
589 if let Some(location) = ¶m.location {
590 let hint = match location.as_str() {
591 "path" => "URL path segment",
592 "query" => "query parameter",
593 other => other,
594 };
595 description.push_str(&format!("[{hint}] "));
596 }
597 if !param.description.is_empty() {
598 description.push_str(¶m.description);
599 }
600 if let (Some(values), Some(texts)) = (¶m.enum_values, ¶m.enum_descriptions)
601 && values.len() == texts.len()
602 && !texts.is_empty()
603 {
604 let pairs: Vec<String> = values
605 .iter()
606 .zip(texts)
607 .map(|(v, t)| format!("`{v}` = {t}"))
608 .collect();
609 if !description.is_empty() {
610 description.push(' ');
611 }
612 description.push_str(&format!("Values: {}.", pairs.join("; ")));
613 }
614 if !description.is_empty() {
615 out.insert("description".into(), json!(description));
616 }
617 if let Some(values) = ¶m.enum_values {
618 out.insert("enum".into(), json!(values));
619 }
620 let base = Value::Object(out);
621 if param.repeated {
622 json!({ "type": "array", "items": base })
623 } else {
624 base
625 }
626}
627
628fn error_response(id: &Value, code: i64, message: String) -> Value {
629 json!({
630 "jsonrpc": "2.0",
631 "id": id,
632 "error": { "code": code, "message": message },
633 })
634}
635
636fn ok_result(text: String) -> Value {
637 json!({ "content": [ { "type": "text", "text": text } ] })
638}
639
640fn error_result(message: impl Into<String>) -> Value {
641 json!({ "content": [ { "type": "text", "text": message.into() } ], "isError": true })
642}
643
644pub async fn serve<R, W>(server: &McpServer, reader: R, writer: &mut W) -> anyhow::Result<()>
649where
650 R: tokio::io::AsyncBufRead + Unpin,
651 W: tokio::io::AsyncWrite + Unpin,
652{
653 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
654
655 let mut lines = reader.lines();
656 while let Some(line) = lines.next_line().await? {
657 if line.trim().is_empty() {
658 continue;
659 }
660 if let Some(response) = server.handle_line(&line).await {
661 writer.write_all(response.to_string().as_bytes()).await?;
662 writer.write_all(b"\n").await?;
663 writer.flush().await?;
664 }
665 }
666 Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::core::auth::TokenStorage;
673 use crate::core::config::OAuthConfig;
674
675 async fn test_server() -> McpServer {
676 test_server_with_profile(SafetyProfile::default()).await
677 }
678
679 async fn test_server_with_profile(profile: SafetyProfile) -> McpServer {
680 let config = OAuthConfig {
684 client_id: "test-id.apps.googleusercontent.com".into(),
685 client_secret: None,
686 ..Default::default()
687 };
688 let storage = TokenStorage {
689 access_token: "test-token".into(),
690 refresh_token: None,
691 expires_at: u64::MAX,
692 token_type: "Bearer".into(),
693 scope: String::new(),
694 };
695 let auth = GoogleAuth::with_token(config, storage).await.unwrap();
696 McpServer::new(auth, profile)
697 }
698
699 fn request(id: i64, method: &str, params: &str) -> String {
700 format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"{method}","params":{params}}}"#)
701 }
702
703 #[tokio::test]
704 async fn the_stdio_loop_pipes_the_stateless_requests() {
705 let server = test_server().await;
706 let input = format!(
707 "{}\n{}\n{}\n",
708 request(1, "server/discover", "{}"),
709 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
710 request(2, "tools/list", "{}"),
711 );
712 let mut out: Vec<u8> = Vec::new();
713 serve(&server, input.as_bytes(), &mut out).await.unwrap();
714
715 let lines: Vec<Value> = String::from_utf8(out)
717 .unwrap()
718 .lines()
719 .map(|l| serde_json::from_str(l).unwrap())
720 .collect();
721 assert_eq!(lines.len(), 2);
722
723 assert_eq!(lines[0]["jsonrpc"], "2.0");
724 assert_eq!(lines[0]["id"], 1);
725 assert_eq!(lines[0]["result"]["resultType"], "complete");
726 assert_eq!(
727 lines[0]["result"]["supportedVersions"],
728 json!(["2026-07-28"])
729 );
730 assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
731 assert_eq!(
732 lines[0]["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
733 "grr"
734 );
735
736 assert_eq!(lines[1]["id"], 2);
737 let tools = lines[1]["result"]["tools"].as_array().unwrap();
738 assert_eq!(tools.len(), server.tool_count());
739 }
740
741 #[tokio::test]
746 async fn legacy_handshake_methods_are_refused_with_the_revision_to_speak() {
747 let server = test_server().await;
748
749 for method in ["ping", "logging/setLevel"] {
750 let line = request(1, method, "{}");
751 let response = server.handle_line(&line).await.unwrap();
752
753 assert_eq!(response["error"]["code"], METHOD_NOT_FOUND, "{method}");
754 }
755 }
756
757 #[tokio::test]
762 async fn initialize_answers_a_minimal_legacy_handshake() {
763 let server = test_server().await;
764
765 let line = request(
766 1,
767 "initialize",
768 r#"{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}"#,
769 );
770 let response = server.handle_line(&line).await.unwrap();
771 let result = &response["result"];
772 assert_eq!(result["protocolVersion"], "2025-06-18");
773 assert_eq!(result["serverInfo"]["name"], "grr");
774 assert!(result["capabilities"]["tools"].is_object());
775 let line = request(2, "tools/list", "{}");
777 let response = server.handle_line(&line).await.unwrap();
778 assert_eq!(
779 response["result"]["tools"].as_array().unwrap().len(),
780 server.tool_count()
781 );
782 }
783
784 #[tokio::test]
788 async fn server_discover_answers_without_a_handshake() {
789 let server = test_server().await;
790
791 let line = request(
792 1,
793 "server/discover",
794 r#"{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0"}}}"#,
795 );
796 let response = server.handle_line(&line).await.unwrap();
797 let result = &response["result"];
798
799 assert_eq!(result["resultType"], "complete");
800 assert_eq!(
801 result["supportedVersions"],
802 json!(["2026-07-28"]),
803 "the current revision, and only it"
804 );
805 assert!(result["capabilities"]["tools"].is_object());
806 assert_eq!(
807 result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
808 "grr"
809 );
810
811 let inline = request(2, "tools/list", "{}");
813 let response = server.handle_line(&inline).await.unwrap();
814 assert!(response["result"]["tools"].is_array());
815 }
816
817 #[tokio::test]
820 async fn results_identify_the_server_per_request() {
821 let server = test_server().await;
822
823 for method in ["tools/list", "server/discover"] {
824 let line = request(1, method, "{}");
825 let response = server.handle_line(&line).await.unwrap();
826 assert_eq!(
827 response["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "grr",
828 "{method} did not identify the server"
829 );
830 }
831 }
832
833 #[tokio::test]
834 async fn tools_list_covers_every_index_method_with_an_input_schema() {
835 let server = test_server().await;
836 let line = request(2, "tools/list", "{}");
837 let response = server.handle_line(&line).await.unwrap();
838 let tools = response["result"]["tools"].as_array().unwrap();
839
840 let expected: usize = discovery::services()
842 .values()
843 .map(|s| s.methods.len())
844 .sum();
845 assert_eq!(tools.len(), expected);
846
847 for tool in tools {
848 let name = tool["name"].as_str().unwrap();
849 assert!(name.contains('.'), "{name} is not a dotted id");
851 assert!(discovery::resolve(name).is_ok(), "{name} does not resolve");
852 assert_eq!(tool["inputSchema"]["type"], "object", "{name}");
853 assert!(tool["inputSchema"]["properties"].is_object(), "{name}");
854 assert!(
855 tool["description"].as_str().unwrap().contains("HTTP"),
856 "{name}"
857 );
858 }
859 }
860
861 #[tokio::test]
866 async fn output_schema_mirrors_the_response_ref() {
867 let server = test_server().await;
868 let line = request(2, "tools/list", "{}");
869 let response = server.handle_line(&line).await.unwrap();
870 let tools = response["result"]["tools"].as_array().unwrap();
871
872 let find = |name: &str| {
873 tools
874 .iter()
875 .find(|t| t["name"] == name)
876 .unwrap_or_else(|| panic!("{name} missing"))
877 };
878
879 let list = find("gmail.users.threads.list");
881 let output = &list["outputSchema"];
882 assert!(
883 output.is_object(),
884 "threads.list must carry an outputSchema"
885 );
886 assert_eq!(output["type"], "object");
887 assert!(
888 output["properties"]["nextPageToken"]["type"] == "string",
889 "list envelope fields survive distillation"
890 );
891 assert!(
892 output["properties"]["threads"]["items"]["properties"]["id"]["type"] == "string",
893 "nested array items are inlined one $ref deep"
894 );
895
896 let empty = find("gmail.users.stop");
898 assert!(
899 empty.get("outputSchema").is_none(),
900 "no fabricated output schema"
901 );
902
903 let raw = serde_json::to_string(&response).unwrap();
905 assert!(
906 !raw.contains("\"outputSchema\":{\"description"),
907 "pruned schemas carry no descriptions"
908 );
909 }
910
911 #[tokio::test]
916 async fn request_body_schemas_carry_the_body_shape_and_type_name() {
917 let server = test_server().await;
918 let line = request(2, "tools/list", "{}");
919 let response = server.handle_line(&line).await.unwrap();
920 let tools = response["result"]["tools"].as_array().unwrap();
921 let find = |name: &str| {
922 tools
923 .iter()
924 .find(|t| t["name"] == name)
925 .unwrap_or_else(|| panic!("{name} missing"))
926 };
927
928 let query = find("searchconsole.searchanalytics.query");
929 let body = &query["inputSchema"]["properties"]["request_body"];
930 assert!(body.is_object(), "query must expose its request body shape");
931 assert_eq!(body["type"], "object");
932 assert!(
933 body["properties"]["startDate"].is_object(),
934 "body field names survive distillation"
935 );
936 let description = query["description"].as_str().unwrap();
937 assert!(
938 description.contains("SearchAnalyticsQueryRequest"),
939 "description names the body type: {description}"
940 );
941 assert!(
942 description.contains("startDate"),
943 "description lists top-level body fields: {description}"
944 );
945
946 let get = find("gmail.users.threads.list");
949 assert!(
950 get["inputSchema"]["properties"]
951 .get("request_body")
952 .is_none(),
953 "no fabricated request body"
954 );
955 let body_file = get["inputSchema"]["properties"]["body_file"]["description"]
956 .as_str()
957 .unwrap();
958 assert!(
959 !body_file.contains("JSON"),
960 "generic wording when no body type exists: {body_file}"
961 );
962 }
963
964 #[tokio::test]
968 async fn parameter_schemas_carry_location_enums_format_and_bounds() {
969 let server = test_server().await;
970 let line = request(2, "tools/list", "{}");
971 let response = server.handle_line(&line).await.unwrap();
972 let tools = response["result"]["tools"].as_array().unwrap();
973 let find = |name: &str| {
974 tools
975 .iter()
976 .find(|t| t["name"] == name)
977 .unwrap_or_else(|| panic!("{name} missing"))
978 };
979
980 let user_id =
981 find("gmail.users.threads.list")["inputSchema"]["properties"]["userId"]["description"]
982 .as_str()
983 .unwrap();
984 assert!(
985 user_id.starts_with("[URL path segment]"),
986 "path parameters announce their location: {user_id}"
987 );
988
989 let max_results = find("calendar.events.list")["inputSchema"]["properties"]["maxResults"]
991 .as_object()
992 .expect("maxResults present");
993 assert_eq!(max_results["format"], "int32");
994 assert_eq!(max_results["minimum"], 1.0);
995 assert_eq!(max_results["default"], "250");
996 }
997
998 #[tokio::test]
1003 async fn descriptions_carry_returns_body_and_siblings() {
1004 let server = test_server().await;
1005 let line = request(2, "tools/list", "{}");
1006 let response = server.handle_line(&line).await.unwrap();
1007 let tools = response["result"]["tools"].as_array().unwrap();
1008 let by_name = |name: &str| {
1009 tools
1010 .iter()
1011 .find(|t| t["name"] == name)
1012 .unwrap_or_else(|| panic!("{name} missing"))["description"]
1013 .as_str()
1014 .unwrap()
1015 .to_owned()
1016 };
1017
1018 let delete = by_name("gmail.users.messages.delete");
1019 assert!(
1020 delete.contains("irreversible"),
1021 "delete should warn about irreversibility: {delete}"
1022 );
1023 assert!(
1024 delete.contains("refused under --readonly"),
1025 "write verbs name the safety gate: {delete}"
1026 );
1027
1028 let list = by_name("calendar.events.list");
1029 assert!(
1030 list.contains("Siblings over the same resource"),
1031 "list tools name their CRUD neighbours: {list}"
1032 );
1033 assert!(list.contains("as JSON"), "returns are described: {list}");
1034 assert!(
1035 list.contains("body_file"),
1036 "body building is described: {list}"
1037 );
1038
1039 let run = by_name("script.scripts.run");
1040 assert!(
1041 run.starts_with("Executes a function"),
1042 "Discovery shipped no description for scripts.run; the curated override must supply one: {run}"
1043 );
1044
1045 for tool in tools {
1048 let d = tool["description"].as_str().unwrap();
1049 assert!(
1050 !d.contains(". [HTTP"),
1051 "{}: doubled period before the verb tag in {d}",
1052 tool["name"]
1053 );
1054 }
1055 }
1056
1057 #[tokio::test]
1058 async fn input_schemas_carry_the_escape_hatches_and_required_params() {
1059 let server = test_server().await;
1060 let line = request(2, "tools/list", "{}");
1061 let response = server.handle_line(&line).await.unwrap();
1062 let tools = response["result"]["tools"].as_array().unwrap();
1063 let schema = tools
1064 .iter()
1065 .find(|t| t["name"] == "gmail.users.messages.list")
1066 .map(|t| t["inputSchema"].clone())
1067 .unwrap();
1068
1069 for prop in ["params", "body_file", "query", "dry_run"] {
1070 assert!(schema["properties"][prop].is_object(), "missing {prop}");
1071 }
1072 let required: Vec<&str> = schema["required"]
1073 .as_array()
1074 .unwrap()
1075 .iter()
1076 .filter_map(|v| v.as_str())
1077 .collect();
1078 assert!(required.contains(&"userId"), "{required:?}");
1079
1080 assert_eq!(schema["properties"]["maxResults"]["type"], "integer");
1082 assert_eq!(schema["properties"]["labelIds"]["type"], "array");
1083 assert_eq!(schema["properties"]["labelIds"]["items"]["type"], "string");
1084 }
1085
1086 #[tokio::test]
1087 async fn enum_values_surface_in_the_schema() {
1088 let server = test_server().await;
1089 let line = request(2, "tools/list", "{}");
1090 let response = server.handle_line(&line).await.unwrap();
1091 let tools = response["result"]["tools"].as_array().unwrap();
1092 let schema = tools
1093 .iter()
1094 .find(|t| t["name"] == "calendar.calendarList.list")
1095 .map(|t| t["inputSchema"].clone())
1096 .unwrap();
1097 let values = schema["properties"]["minAccessRole"]["enum"]
1098 .as_array()
1099 .unwrap();
1100 assert!(
1101 values.iter().any(|v| v.as_str() == Some("owner")),
1102 "{values:?}"
1103 );
1104 }
1105
1106 #[tokio::test]
1107 async fn tools_call_dry_run_matches_the_api_call_payload() {
1108 let server = test_server().await;
1109 let line = request(
1110 3,
1111 "tools/call",
1112 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
1113 );
1114 let response = server.handle_line(&line).await.unwrap();
1115 assert_eq!(response["result"]["isError"], Value::Null);
1116 let text = response["result"]["content"][0]["text"].as_str().unwrap();
1117 let payload: Value = serde_json::from_str(text).unwrap();
1118
1119 let (service, method) = discovery::resolve("gmail.users.messages.list").unwrap();
1122 let mut params = Map::new();
1123 params.insert("userId".into(), json!("me"));
1124 let plan = api::plan_request(service, method, ¶ms, None, None, &[]).unwrap();
1125 assert_eq!(payload, api::dry_run_payload(method, &plan));
1126 }
1127
1128 #[tokio::test]
1129 async fn tools_call_accepts_the_camel_case_dry_run_spelling() {
1130 let server = test_server().await;
1131 let line = request(
1132 4,
1133 "tools/call",
1134 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dryRun":true}}"#,
1135 );
1136 let response = server.handle_line(&line).await.unwrap();
1137 let text = response["result"]["content"][0]["text"].as_str().unwrap();
1138 let payload: Value = serde_json::from_str(text).unwrap();
1139 assert_eq!(payload["dryRun"], true);
1140 }
1141
1142 #[tokio::test]
1143 async fn tool_arguments_win_over_the_params_escape_hatch() {
1144 let server = test_server().await;
1145 let line = request(
1146 5,
1147 "tools/call",
1148 r#"{"name":"gmail.users.messages.list","arguments":
1149 {"params":{"userId":"someone-else","q":"is:unread"},"userId":"me","dry_run":true}}"#,
1150 );
1151 let response = server.handle_line(&line).await.unwrap();
1152 let text = response["result"]["content"][0]["text"].as_str().unwrap();
1153 let payload: Value = serde_json::from_str(text).unwrap();
1154 let url = payload["url"].as_str().unwrap();
1156 assert!(url.contains("/users/me/messages"), "{url}");
1157 assert!(url.contains("q=is%3Aunread"), "{url}");
1158 assert!(!url.contains("someone-else"), "{url}");
1159 }
1160
1161 #[tokio::test]
1162 async fn an_unknown_tool_is_an_error_result_not_a_crash() {
1163 let server = test_server().await;
1164 let line = request(
1165 6,
1166 "tools/call",
1167 r#"{"name":"gmail.users.messages.lst","arguments":{}}"#,
1168 );
1169 let response = server.handle_line(&line).await.unwrap();
1170 assert_eq!(response["result"]["isError"], true);
1171 let text = response["result"]["content"][0]["text"].as_str().unwrap();
1172 assert!(text.contains("unknown tool"), "{text}");
1173 }
1174
1175 #[tokio::test]
1176 async fn readonly_refuses_writes_with_an_mcp_error_result() {
1177 let server = test_server_with_profile(SafetyProfile::new(true, [], [])).await;
1178
1179 let line = request(
1180 7,
1181 "tools/call",
1182 r#"{"name":"gmail.users.messages.send","arguments":{"userId":"me","dry_run":true}}"#,
1183 );
1184 let response = server.handle_line(&line).await.unwrap();
1185 assert_eq!(response["result"]["isError"], true);
1186 let text = response["result"]["content"][0]["text"].as_str().unwrap();
1187 assert!(text.contains("gmail.users.messages.send"), "{text}");
1189 assert!(text.contains("POST"), "{text}");
1190 assert!(text.contains("Rerun without --readonly"), "{text}");
1191
1192 let line = request(
1194 8,
1195 "tools/call",
1196 r#"{"name":"gmail.users.messages.list","arguments":{"userId":"me","dry_run":true}}"#,
1197 );
1198 let response = server.handle_line(&line).await.unwrap();
1199 assert_eq!(response["result"]["isError"], Value::Null);
1200 }
1201
1202 #[tokio::test]
1203 async fn a_missing_tool_name_is_an_error_result() {
1204 let server = test_server().await;
1205 let line = request(9, "tools/call", r#"{"arguments":{}}"#);
1206 let response = server.handle_line(&line).await.unwrap();
1207 assert_eq!(response["result"]["isError"], true);
1208 }
1209
1210 #[tokio::test]
1211 async fn a_stdin_body_file_is_refused_over_mcp() {
1212 let server = test_server().await;
1213 let line = request(
1214 10,
1215 "tools/call",
1216 r#"{"name":"gmail.users.messages.list","arguments":{"body_file":"-"}}"#,
1217 );
1218 let response = server.handle_line(&line).await.unwrap();
1219 assert_eq!(response["result"]["isError"], true);
1220 let text = response["result"]["content"][0]["text"].as_str().unwrap();
1221 assert!(text.contains("stdin"), "{text}");
1222 }
1223
1224 #[tokio::test]
1225 async fn an_unknown_method_is_a_jsonrpc_error() {
1226 let server = test_server().await;
1227 let line = request(11, "resources/list", "{}");
1228 let response = server.handle_line(&line).await.unwrap();
1229 assert_eq!(response["error"]["code"], -32601);
1230 assert!(
1231 response["error"]["message"]
1232 .as_str()
1233 .unwrap()
1234 .contains("resources/list")
1235 );
1236 assert_eq!(response["id"], 11);
1237 }
1238
1239 #[tokio::test]
1240 async fn a_parse_error_answers_with_a_null_id() {
1241 let server = test_server().await;
1242 let response = server.handle_line("not json at all").await.unwrap();
1243 assert_eq!(response["error"]["code"], -32700);
1244 assert!(response["id"].is_null());
1245 }
1246
1247 #[tokio::test]
1248 async fn notifications_are_never_answered() {
1249 let server = test_server().await;
1250 assert!(
1251 server
1252 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
1253 .await
1254 .is_none()
1255 );
1256 assert!(
1257 server
1258 .handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled"}"#)
1259 .await
1260 .is_none()
1261 );
1262 }
1263}