diff --git a/crates/automation/README.md b/crates/automation/README.md index 85bb2f7f09..bd3821c7e6 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -34,7 +34,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS `pdfcraft-cli mcp --compact` (or `McpServer::with_compact(true)`) keeps `tools/list` short for agents with small context budgets. It lists only the core tools (`mcp::COMPACT_CORE_TOOLS`: open, info, save, close, render, text extract and find, combine, split, undo) plus the common command/inspection/preview tools and two meta tools: `tool_search` (optional `query` and `category`, the name prefix; or `name` for one tool's full `input_schema`) and `tool_call` (`name` and `arguments`, run through the same `Automation::call`). `tools/call` by name keeps working for every tool, and the server instructions mention the meta tools. Without the flag nothing changes. -Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, `tools/list` (with `readOnlyHint`/`destructiveHint` annotations), `tools/call` (JSON results also returned as `structuredContent`; images as `image/png`), and `resources/list`, `resources/templates/list` and `resources/read`. The resources expose the open documents read-only: `pdfcraft://doc/{doc}/info` (JSON), `…/text`, `…/page/{page}/text` and `…/page/{page}/image{?dpi}` (PNG, 1–600 dpi). Tool failures come back as `isError: true` results, so the agent can read and recover from them. +Implemented: `initialize` (protocol 2026-07-28, 2025-06-18, 2025-03-26, 2024-11-05), `ping`, `tools/list` (with `readOnlyHint`/`destructiveHint` annotations), `tools/call` (JSON results also returned as `structuredContent`; images as `image/png`), and `resources/list`, `resources/templates/list` and `resources/read`. The resources expose the open documents read-only: `pdfcraft://doc/{doc}/info` (JSON), `…/text`, `…/page/{page}/text` and `…/page/{page}/image{?dpi}` (PNG, 1–600 dpi). Tool failures come back as `isError: true` results, so the agent can read and recover from them. ## Adding a tool @@ -66,3 +66,5 @@ reported as an internal tool error, and the server continues serving. Exports complete synchronously. `progressToken` and cancellation notifications are harmlessly ignored; no background-job or cancellation behavior is added. + +Session resources `pdfcraft://document` and `pdfcraft://commands` and modern result/cache hints are described in [docs/mcp.md](../../docs/mcp.md). diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 375041eebe..0a57ffd167 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -19,7 +19,7 @@ use serde_json::{Value, json}; use crate::{Automation, Content, ToolError}; /// Protocol revisions we speak, newest first. -pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; +pub const PROTOCOL_VERSIONS: &[&str] = &["2026-07-28", "2025-06-18", "2025-03-26", "2024-11-05"]; const PARSE_ERROR: i64 = -32700; const INVALID_REQUEST: i64 = -32600; @@ -63,11 +63,12 @@ const TOOL_CALL: &str = "tool_call"; pub struct McpServer { automation: Automation, compact: bool, + modern: bool, } impl McpServer { pub fn new(automation: Automation) -> Self { - Self { automation, compact: false } + Self { automation, compact: false, modern: false } } /// In compact mode `tools/list` returns only [`COMPACT_CORE_TOOLS`] plus `tool_search` and @@ -118,7 +119,22 @@ impl McpServer { let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) }; Some(match result { - Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Ok(mut result) => { + let modern = params + .get("_meta") + .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) + .and_then(Value::as_str) + .map_or(self.modern, |v| v == "2026-07-28"); + if modern + && matches!(method, "tools/list" | "resources/list" | "resources/templates/list" | "resources/read") + && let Some(object) = result.as_object_mut() + { + object.insert("resultType".into(), json!("complete")); + object.insert("ttlMs".into(), json!(if matches!(method, "resources/read" | "resources/list") { 0 } else { 600_000 })); + object.insert("cacheScope".into(), json!("private")); + } + json!({ "jsonrpc": "2.0", "id": id, "result": result }) + } Err((code, message)) => error(id, code, &message), }) } @@ -127,7 +143,8 @@ impl McpServer { match method { "initialize" => { let asked = params.get("protocolVersion").and_then(Value::as_str); - let version = asked.filter(|v| PROTOCOL_VERSIONS.contains(v)).unwrap_or(PROTOCOL_VERSIONS[0]); + let version = asked.filter(|v| PROTOCOL_VERSIONS.contains(v)).unwrap_or("2025-06-18"); + self.modern = version == "2026-07-28"; Ok(json!({ "protocolVersion": version, "capabilities": { "tools": { "listChanged": false }, "resources": { "listChanged": false, "subscribe": false } }, @@ -404,10 +421,29 @@ impl McpServer { } } } + out.extend([ + json!({"uri":"pdfcraft://document","name":"Open documents","mimeType":"application/json","description":"Session document state, as doc_inspect without arguments."}), + json!({"uri":"pdfcraft://commands","name":"Command catalog","mimeType":"application/json","description":"Commands and mapped tool schemas, as command_list without arguments."}), + ]); out } fn read_resource(&mut self, uri: &str) -> Result { + if let Some(tool) = match uri { + "pdfcraft://document" => Some("doc_inspect"), + "pdfcraft://commands" => Some("command_list"), + _ => None, + } { + let content = self.automation.call(tool, &json!({})).map_err(|e| (INVALID_PARAMS, e.to_string()))?; + let value = content + .into_iter() + .find_map(|c| match c { + Content::Json(v) => Some(v), + _ => None, + }) + .ok_or((INVALID_PARAMS, "resource returned no JSON".into()))?; + return Ok(json!({"uri":uri,"mimeType":"application/json","text":value.to_string()})); + } let r = parse_uri(uri).ok_or((INVALID_PARAMS, format!("unknown resource {uri:?}")))?; let call = |a: &mut Automation, tool: &str, args: Value| a.call(tool, &args).map_err(|e| (INVALID_PARAMS, e.to_string())); let json_of = |c: Vec| c.into_iter().find_map(|c| if let Content::Json(v) = c { Some(v) } else { None }).unwrap_or(Value::Null); diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 524d88a56e..a8c6d324a0 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -570,11 +570,11 @@ fn mcp_resources_expose_open_documents() { let dir = workdir("mcp-resources"); let mut s = McpServer::new(auto(&dir)); assert!(rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"]["resources"].is_object()); - assert_eq!(rpc(&mut s, 2, "resources/list", json!({}))["result"]["resources"], json!([])); + assert_eq!(rpc(&mut s, 2, "resources/list", json!({}))["result"]["resources"].as_array().unwrap().len(), 2); assert_eq!(rpc(&mut s, 3, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().len(), 4); rpc(&mut s, 4, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "a.pdf" } })); let list = rpc(&mut s, 5, "resources/list", json!({}))["result"]["resources"].as_array().cloned().unwrap(); - assert_eq!(list.len(), 2 + 3, "info, text and three page images"); + assert_eq!(list.len(), 2 + 3 + 2, "info, text, three page images and two session resources"); assert_eq!(list[0]["uri"], "pdfcraft://doc/1/info"); let read = |s: &mut McpServer, uri: &str| rpc(s, 6, "resources/read", json!({ "uri": uri }))["result"]["contents"][0].clone(); let text = read(&mut s, "pdfcraft://doc/1/text"); @@ -2986,3 +2986,47 @@ fn conventions_annotations_and_strict_keys_in_both_modes() { } } } + +#[cfg(feature = "mcp")] +#[test] +fn conventions_resources_and_modern_results() { + let dir = workdir("conventions-resources"); + let mut s = McpServer::new(auto(&dir)); + let read = |s: &mut McpServer, uri: &str| { + let r = rpc(s, 1, "resources/read", json!({"uri":uri})); + serde_json::from_str::(r["result"]["contents"][0]["text"].as_str().expect("JSON resource")).unwrap() + }; + assert_eq!(read(&mut s, "pdfcraft://document"), json!({"documents":[]})); + rpc(&mut s, 2, "tools/call", json!({"name":"doc_open","arguments":{"path":"a.pdf"}})); + let doc = rpc(&mut s, 3, "tools/call", json!({"name":"doc_inspect","arguments":{}})); + assert_eq!(read(&mut s, "pdfcraft://document"), doc["result"]["structuredContent"]); + let commands = rpc(&mut s, 4, "tools/call", json!({"name":"command_list","arguments":{}})); + assert_eq!(read(&mut s, "pdfcraft://commands"), commands["result"]["structuredContent"]); + for (method, params) in [ + ("tools/list", json!({})), + ("resources/list", json!({})), + ("resources/templates/list", json!({})), + ("resources/read", json!({"uri":"pdfcraft://document"})), + ] { + let legacy = rpc(&mut s, 5, method, params.clone()); + assert!(legacy["result"].get("resultType").is_none()); + let mut modern = params; + modern["_meta"] = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"}); + let r = rpc(&mut s, 6, method, modern); + assert_eq!(r["result"]["resultType"], "complete"); + assert_eq!(r["result"]["cacheScope"], "private"); + assert_eq!(r["result"]["ttlMs"], if method == "resources/read" || method == "resources/list" { 0 } else { 600_000 }); + let mut stripped = r["result"].clone(); + for k in ["resultType", "ttlMs", "cacheScope"] { + stripped.as_object_mut().unwrap().remove(k); + } + assert_eq!(stripped, legacy["result"]); + } + assert_eq!(rpc(&mut s, 7, "initialize", json!({"protocolVersion":"2026-07-28"}))["result"]["protocolVersion"], "2026-07-28"); + assert_eq!(rpc(&mut s, 8, "tools/list", json!({}))["result"]["resultType"], "complete"); + assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":999}}"#).is_none()); + assert_eq!( + rpc(&mut s, 9, "tools/call", json!({"name":"doc_inspect","arguments":{},"_meta":{"progressToken":"quick"}}))["result"]["isError"], + false + ); +} diff --git a/docs/mcp.md b/docs/mcp.md index 2ea1367e7e..eaea0e1003 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -32,3 +32,15 @@ serving. A failed batch includes per-step errors and is marked `isError: true`. Exports complete synchronously. A call's `progressToken` and cancellation notifications are harmlessly ignored; the server does not provide background export progress or cancellation. + +## Resources and protocol versions + +`pdfcraft://document` returns the session's document information (as `doc_inspect` without +arguments), including an empty `documents` array before any PDF is opened. `pdfcraft://commands` +returns the command catalog (as `command_list`). Existing per-document info, text and page-image +resources and their templates are preserved. + +MCP 2026-07-28 clients, negotiated through `initialize` or a request's `_meta`, receive +`resultType: "complete"`, `ttlMs`, and `cacheScope: "private"` on lists and reads. Resource reads +and the changing list of open-document resources have zero TTL; tool and template catalogs use +ten minutes. Older clients keep their previous response shape. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2640b50976..d97b673594 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9362,7 +9362,7 @@ milestone = "M13" status = "shipped" commands = [] tools = ["doc_info", "text_extract", "page_render"] -evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse"] +evidence = ["crates/automation/tests/automation.rs::mcp_resources_expose_open_documents", "crates/automation/src/mcp.rs::resource_uris_parse", "crates/automation/tests/automation.rs::conventions_resources_and_modern_results"] notes = "resources/list, resources/templates/list and resources/read: pdfcraft://doc/{doc}/info (JSON), /text, /page/{page}/text, /page/{page}/image?dpi= (PNG)" [[feature]]