M13: session resources and version-aware MCP result hints

Keep per-document resources and legacy response shapes; add document/command resources and private cache hints for modern clients.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
storybored379andOpenAI Codex committed 2026-10-08 20:55:18 -07:00
1 parent 894e81affd
commit 685ccc0a0f
5 files changed
+102 -8

No files matched your search

+3 -1
View File
@@ -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).
+40 -4
View File
@@ -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, &params)) } else { self.dispatch(method, &params) };
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<Value, (i64, String)> {
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<Content>| c.into_iter().find_map(|c| if let Content::Json(v) = c { Some(v) } else { None }).unwrap_or(Value::Null);
+46 -2
View File
@@ -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::<Value>(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
);
}
+12
View File
@@ -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.
+1 -1
View File
@@ -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]]