diff --git a/CHANGELOG.md b/CHANGELOG.md index 4822d07..80d68d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- `markkit mcp`: Model Context Protocol server over stdio exposing the `markkit` tool + (`tools/list`, `tools/call`). URL fetching keeps the SSRF guard; `--no-url` keeps it offline, + the `extract` limit flags and `--max-output-chars` bound each call. - Plain text input (`text/plain`, `.txt`), always on: paragraphs and hard breaks through the IR, UTF-8/UTF-16 BOM decoding, control characters dropped, `invalid_utf8` warning on lossy decode. - Markdown input (`markdown` feature, in `all_formats`; `text/markdown`, `.md`): re-parsed with diff --git a/README.md b/README.md index 79909c7..2a1826f 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,13 @@ markkit extract --file page.html --base-url https://example.com/blog/ # From stdin cat page.html | markkit extract - + +# MCP server (stdio) exposing the `markkit` tool to agents +markkit mcp # add --no-url to keep it offline ``` +MCP client config: `{"mcpServers": {"markkit": {"command": "markkit", "args": ["mcp"]}}}`. + ### Python ```python diff --git a/crates/markkit-cli/Cargo.toml b/crates/markkit-cli/Cargo.toml index b7bcc30..9325c0e 100644 --- a/crates/markkit-cli/Cargo.toml +++ b/crates/markkit-cli/Cargo.toml @@ -27,8 +27,8 @@ spreadsheet = ["markkit/spreadsheet"] markdown = ["markkit/markdown"] [dependencies] -markkit = { path = "../markkit", version = "0.1.1", features = ["http_client"] } -tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "time", "fs"] } +markkit = { path = "../markkit", version = "0.1.1", features = ["http_client", "tool"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "time", "fs", "io-std", "io-util"] } clap.workspace = true anyhow.workspace = true serde_json.workspace = true diff --git a/crates/markkit-cli/src/main.rs b/crates/markkit-cli/src/main.rs index dfef440..2757cba 100644 --- a/crates/markkit-cli/src/main.rs +++ b/crates/markkit-cli/src/main.rs @@ -6,6 +6,8 @@ use std::time::Duration; use clap::{Args, Parser, Subcommand}; use markkit::{Limits, Markkit}; +mod mcp; + #[derive(Parser)] #[command( name = "markkit", @@ -53,6 +55,27 @@ enum Commands { #[arg(long)] main_content: bool, + #[command(flatten)] + limits: LimitArgs, + }, + /// Serve the `markkit` tool over MCP (JSON-RPC on stdin/stdout) + Mcp { + /// Do not offer the `url` source (no network access) + #[arg(long)] + no_url: bool, + + /// Maximum markdown characters returned per call + #[arg(long, default_value_t = markkit::tool::DEFAULT_MAX_OUTPUT_CHARS)] + max_output_chars: usize, + + /// Keep only the detected main content (article body) of HTML pages + #[arg(long)] + main_content: bool, + + /// Locale for tool descriptions and errors (en-US, uk-UA) + #[arg(long, default_value = "en-US")] + locale: String, + #[command(flatten)] limits: LimitArgs, }, @@ -133,6 +156,24 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Commands::Mcp { + no_url, + max_output_chars, + main_content, + locale, + limits, + } => { + let limits = limits.apply(Limits::default()); + let max_message = mcp::max_message_bytes(limits.max_input_size); + let tool = markkit::tool::MarkkitTool::builder() + .locale(&locale) + .config(Markkit::builder().limits(limits).main_content(main_content)) + .allow_url(!no_url) + .max_output_chars(max_output_chars) + .build(); + let stdin = tokio::io::BufReader::new(tokio::io::stdin()); + mcp::serve(&tool, stdin, tokio::io::stdout(), max_message).await?; + } Commands::Extract { source, file, @@ -215,7 +256,9 @@ mod tests { fn limits_from(args: &[&str]) -> Limits { let cli = Cli::try_parse_from(["markkit", "extract", "x"].iter().chain(args)).unwrap(); - let Commands::Extract { limits, .. } = cli.command; + let Commands::Extract { limits, .. } = cli.command else { + panic!("expected extract"); + }; limits.apply(Limits::default()) } diff --git a/crates/markkit-cli/src/mcp.rs b/crates/markkit-cli/src/mcp.rs new file mode 100644 index 0000000..fea8744 --- /dev/null +++ b/crates/markkit-cli/src/mcp.rs @@ -0,0 +1,390 @@ +//! `markkit mcp`: Model Context Protocol server over stdio. +//! +//! Decisions (see `knowledge/integrations/mcp-server.md`): +//! - Hand-rolled JSON-RPC 2.0, one message per line (MCP stdio transport). +//! The surface is four methods; an SDK would add more code than it saves. +//! - One tool, `markkit`, backed by `MarkkitTool`: schemas, validation, output +//! cap and localized errors all come from the tool contract. +//! - Messages are untrusted: each line is read with a byte cap derived from +//! `max_input_size` (base64 overhead + slack), oversized lines are drained +//! and answered with a parse error, never buffered whole. +//! - Tool failures are results with `isError: true` (MCP convention), not +//! JSON-RPC errors. Internal errors go to stderr; the client sees a generic +//! message. +//! - Requests run one at a time; stdout carries only protocol messages. + +use markkit::tool::{MarkkitTool, Tool, ToolError}; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +/// Newest protocol version this server speaks; also the fallback when a +/// client asks for one we do not know. +pub const LATEST_PROTOCOL_VERSION: &str = "2025-06-18"; +const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +/// Largest accepted message for a given input size limit: base64 inflates by +/// 4/3, plus 1 MiB for JSON framing and other arguments. +pub fn max_message_bytes(max_input_size: usize) -> usize { + max_input_size + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4) + .saturating_add(1 << 20) +} + +/// Serve MCP until `reader` reaches EOF. +pub async fn serve( + tool: &MarkkitTool, + mut reader: R, + mut writer: W, + max_message: usize, +) -> std::io::Result<()> +where + R: AsyncBufRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut buf = Vec::new(); + loop { + buf.clear(); + let limit = u64::try_from(max_message) + .unwrap_or(u64::MAX) + .saturating_add(1); + let n = (&mut reader) + .take(limit) + .read_until(b'\n', &mut buf) + .await?; + if n == 0 { + return Ok(()); + } + let response = if buf.len() > max_message && buf.last() != Some(&b'\n') { + drain_line(&mut reader).await?; + Some(error_response( + Value::Null, + PARSE_ERROR, + "message too large", + )) + } else { + handle_message(tool, &buf).await + }; + if let Some(response) = response { + let mut line = serde_json::to_vec(&response).map_err(std::io::Error::other)?; + line.push(b'\n'); + writer.write_all(&line).await?; + writer.flush().await?; + } + } +} + +/// Discard input up to and including the next newline (or EOF). +async fn drain_line(reader: &mut R) -> std::io::Result<()> { + loop { + let chunk = reader.fill_buf().await?; + if chunk.is_empty() { + return Ok(()); + } + match chunk.iter().position(|&b| b == b'\n') { + Some(i) => { + reader.consume(i + 1); + return Ok(()); + } + None => { + let len = chunk.len(); + reader.consume(len); + } + } + } +} + +/// Handle one raw line. `None` means no reply (notification or blank line). +pub async fn handle_message(tool: &MarkkitTool, line: &[u8]) -> Option { + if line.iter().all(u8::is_ascii_whitespace) { + return None; + } + let msg: Value = match serde_json::from_slice(line) { + Ok(v) => v, + Err(_) => return Some(error_response(Value::Null, PARSE_ERROR, "parse error")), + }; + let Some(obj) = msg.as_object() else { + // Batches were removed from MCP in 2025-06-18. + return Some(error_response( + Value::Null, + INVALID_REQUEST, + "expected a JSON-RPC object", + )); + }; + let id = obj.get("id").cloned(); + let method = obj.get("method").and_then(Value::as_str); + let valid_id = matches!(id, None | Some(Value::String(_) | Value::Number(_))); + let (Some(method), true, Some("2.0")) = + (method, valid_id, obj.get("jsonrpc").and_then(Value::as_str)) + else { + // Responses from the client (we send no requests) are ignored. + if method.is_none() && (obj.contains_key("result") || obj.contains_key("error")) { + return None; + } + return Some(error_response( + id.filter(|_| valid_id).unwrap_or(Value::Null), + INVALID_REQUEST, + "invalid request", + )); + }; + let params = obj.get("params").cloned().unwrap_or(Value::Null); + + // Notifications (no id) never get a reply. + let id = id?; + + let result = match method { + "initialize" => Ok(initialize(tool, ¶ms)), + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": [tool_descriptor(tool)] })), + "tools/call" => call_tool(tool, params).await, + _ => Err((METHOD_NOT_FOUND, format!("method not found: {method}"))), + }; + Some(match result { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err((code, message)) => error_response(id, code, &message), + }) +} + +fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message }, + }) +} + +fn initialize(tool: &MarkkitTool, params: &Value) -> Value { + let requested = params.get("protocolVersion").and_then(Value::as_str); + let version = requested + .filter(|v| SUPPORTED_PROTOCOL_VERSIONS.contains(v)) + .unwrap_or(LATEST_PROTOCOL_VERSION); + json!({ + "protocolVersion": version, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { + "name": "markkit", + "title": "Markkit", + "version": env!("CARGO_PKG_VERSION"), + }, + "instructions": tool.system_prompt(), + }) +} + +fn tool_descriptor(tool: &MarkkitTool) -> Value { + json!({ + "name": tool.name(), + "title": tool.display_name(), + "description": tool.description(), + "inputSchema": tool.input_schema(), + "outputSchema": tool.output_schema(), + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": tool.input_schema()["properties"].get("url").is_some(), + }, + }) +} + +async fn call_tool(tool: &MarkkitTool, params: Value) -> Result { + let name = params.get("name").and_then(Value::as_str); + if name != Some(tool.name()) { + return Err(( + INVALID_PARAMS, + format!("unknown tool: {}", name.unwrap_or("")), + )); + } + let args = match params.get("arguments") { + None | Some(Value::Null) => json!({}), + Some(v) => v.clone(), + }; + let outcome = match tool.execution(args) { + Ok(execution) => execution.execute().await, + Err(e) => Err(e), + }; + Ok(match outcome { + Ok(output) => json!({ + "content": [{ "type": "text", "text": output.result.to_string() }], + "structuredContent": output.result, + "isError": false, + }), + Err(ToolError::UserFacing(message)) => tool_error(&message), + Err(ToolError::Internal(message)) => { + eprintln!("markkit mcp: internal error: {message}"); + tool_error("internal error") + } + }) +} + +fn tool_error(message: &str) -> Value { + json!({ + "content": [{ "type": "text", "text": message }], + "isError": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool() -> MarkkitTool { + MarkkitTool::builder().build() + } + + async fn call(line: &str) -> Option { + handle_message(&tool(), line.as_bytes()).await + } + + #[tokio::test] + async fn initialize_negotiates_version() { + let r = call(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}"#) + .await + .unwrap(); + assert_eq!(r["result"]["protocolVersion"], "2024-11-05"); + assert_eq!(r["result"]["serverInfo"]["name"], "markkit"); + assert!(r["result"]["capabilities"]["tools"].is_object()); + + let r = call(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}"#) + .await + .unwrap(); + assert_eq!(r["result"]["protocolVersion"], LATEST_PROTOCOL_VERSION); + } + + #[tokio::test] + async fn notifications_get_no_reply() { + assert!( + call(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#) + .await + .is_none() + ); + assert!(call(" \n").await.is_none()); + assert!( + call(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#) + .await + .is_none() + ); + } + + #[tokio::test] + async fn malformed_messages_are_errors() { + let r = call("{not json").await.unwrap(); + assert_eq!(r["error"]["code"], PARSE_ERROR); + assert_eq!(r["id"], Value::Null); + + let r = call(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#) + .await + .unwrap(); + assert_eq!(r["error"]["code"], INVALID_REQUEST); + + let r = call(r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#) + .await + .unwrap(); + assert_eq!(r["error"]["code"], INVALID_REQUEST); + assert_eq!(r["id"], 1); + + let r = call(r#"{"jsonrpc":"2.0","id":{"x":1},"method":"ping"}"#) + .await + .unwrap(); + assert_eq!(r["error"]["code"], INVALID_REQUEST); + assert_eq!(r["id"], Value::Null); + + let r = call(r#"{"jsonrpc":"2.0","id":"a","method":"resources/list"}"#) + .await + .unwrap(); + assert_eq!(r["error"]["code"], METHOD_NOT_FOUND); + assert_eq!(r["id"], "a"); + } + + #[tokio::test] + async fn lists_markkit_tool() { + let r = call(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#) + .await + .unwrap(); + let tools = r["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "markkit"); + assert_eq!(tools[0]["inputSchema"]["type"], "object"); + assert_eq!(tools[0]["annotations"]["readOnlyHint"], true); + } + + #[tokio::test] + async fn calls_tool() { + let r = call(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"markkit","arguments":{"html":"

Hi

there

"}}}"#) + .await + .unwrap(); + let result = &r["result"]; + assert_eq!(result["isError"], false); + assert_eq!(result["structuredContent"]["markdown"], "# Hi\n\nthere\n"); + let text: Value = + serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(text, result["structuredContent"]); + } + + #[tokio::test] + async fn bad_arguments_are_tool_errors() { + // Two sources: rejected by the tool contract, reported as isError. + let r = call(r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"markkit","arguments":{"html":"

a

","content_base64":"YQ=="}}}"#) + .await + .unwrap(); + assert_eq!(r["result"]["isError"], true); + assert!(r.get("error").is_none()); + + let r = call(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"bash","arguments":{}}}"#) + .await + .unwrap(); + assert_eq!(r["error"]["code"], INVALID_PARAMS); + } + + #[tokio::test] + async fn private_urls_are_blocked() { + let r = call(r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"markkit","arguments":{"url":"http://127.0.0.1:1/"}}}"#) + .await + .unwrap(); + assert_eq!(r["result"]["isError"], true); + } + + #[tokio::test] + async fn url_source_can_be_disabled() { + let tool = MarkkitTool::builder().allow_url(false).build(); + let r = handle_message(&tool, br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) + .await + .unwrap(); + let tool_desc = &r["result"]["tools"][0]; + assert!(tool_desc["inputSchema"]["properties"].get("url").is_none()); + assert_eq!(tool_desc["annotations"]["openWorldHint"], false); + } + + #[tokio::test] + async fn serve_drains_oversized_lines() { + let big = format!( + "{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\",\"pad\":\"{}\"}}\n", + "x".repeat(4096) + ); + let input = format!("{big}{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}}\n"); + let mut out = Vec::new(); + serve(&tool(), input.as_bytes(), &mut out, 1024) + .await + .unwrap(); + let lines: Vec = out + .split(|&b| b == b'\n') + .filter(|l| !l.is_empty()) + .map(|l| serde_json::from_slice(l).unwrap()) + .collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["error"]["code"], PARSE_ERROR); + assert_eq!(lines[1]["id"], 2); + assert_eq!(lines[1]["result"], json!({})); + } + + #[test] + fn message_cap_covers_base64_input() { + assert!(max_message_bytes(3) >= 4); + assert_eq!(max_message_bytes(usize::MAX), usize::MAX); + } +} diff --git a/crates/markkit-cli/tests/mcp_integration.rs b/crates/markkit-cli/tests/mcp_integration.rs new file mode 100644 index 0000000..8bb3cab --- /dev/null +++ b/crates/markkit-cli/tests/mcp_integration.rs @@ -0,0 +1,103 @@ +//! End-to-end `markkit mcp` session over real stdio. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +fn session(args: &[&str], input: &str) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_markkit")) + .arg("mcp") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn markkit mcp"); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).expect("stdout carries only JSON-RPC")) + .collect() +} + +#[test] +fn full_session() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"markkit","arguments":{"content_base64":"PGgxPkhpPC9oMT4=","file_name":"a.html"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"markkit","arguments":{"url":"http://169.254.169.254/latest/meta-data/"}}}"#, + "\n", + "garbage\n", + r#"{"jsonrpc":"2.0","id":5,"method":"ping"}"#, + "\n", + ); + let replies = session(&[], input); + let ids: Vec<&Value> = replies.iter().map(|r| &r["id"]).collect(); + assert_eq!( + ids, + [ + &1.into(), + &2.into(), + &3.into(), + &4.into(), + &Value::Null, + &5.into() + ] + ); + + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18"); + assert_eq!(replies[1]["result"]["tools"][0]["name"], "markkit"); + assert_eq!( + replies[2]["result"]["structuredContent"]["markdown"], + "# Hi\n" + ); + // SSRF guard: metadata address is refused, reported as a tool error. + assert_eq!(replies[3]["result"]["isError"], true); + assert_eq!(replies[4]["error"]["code"], -32700); + assert_eq!(replies[5]["result"], serde_json::json!({})); +} + +#[test] +fn no_url_hides_url_source() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"markkit","arguments":{"url":"https://example.com"}}}"#, + "\n", + ); + let replies = session(&["--no-url"], input); + assert!( + replies[0]["result"]["tools"][0]["inputSchema"]["properties"] + .get("url") + .is_none() + ); + assert_eq!(replies[1]["result"]["isError"], true); +} + +#[test] +fn oversized_message_is_rejected_and_session_continues() { + let big = format!( + "{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{{\"name\":\"markkit\",\"arguments\":{{\"html\":\"{}\"}}}}}}\n", + "a".repeat(4 << 20) + ); + let input = format!("{big}{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}}\n"); + let replies = session(&["--max-size", "1024"], &input); + assert_eq!(replies.len(), 2); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[1]["id"], 2); +} diff --git a/knowledge/integrations/index.md b/knowledge/integrations/index.md index 33bf266..fca9066 100644 --- a/knowledge/integrations/index.md +++ b/knowledge/integrations/index.md @@ -1,3 +1,4 @@ # Integrations * [Tool Contract](tool-contract.md) - MarkkitTool LLM tool, its schemas, limits, and error semantics under the Everruns toolkit-library contract. +* [MCP Server](mcp-server.md) - `markkit mcp` stdio server exposing the tool, methods, limits, error mapping. diff --git a/knowledge/integrations/mcp-server.md b/knowledge/integrations/mcp-server.md new file mode 100644 index 0000000..4972ceb --- /dev/null +++ b/knowledge/integrations/mcp-server.md @@ -0,0 +1,74 @@ +--- +type: Interface Contract +title: MCP Server +description: The markkit mcp stdio Model Context Protocol server exposing MarkkitTool, its methods, limits and error mapping. +tags: + - markkit + - tools + - llm + - mcp +--- + +# MCP Server + +## Status +Implemented as the `markkit mcp` CLI subcommand (`crates/markkit-cli/src/mcp.rs`). + +## Summary + +`markkit mcp` serves one tool, `markkit`, over the MCP stdio transport: JSON-RPC 2.0, +one message per line on stdin, replies on stdout, diagnostics on stderr. Everything +about the tool (schemas, validation, output cap, localized errors, SSRF policy) comes +from [Tool Contract](tool-contract.md); the server only frames it. + +Client config: + +```json +{ "mcpServers": { "markkit": { "command": "markkit", "args": ["mcp"] } } } +``` + +## Methods + +| Method | Reply | +|--------|-------| +| `initialize` | `protocolVersion` (client's if in `2025-06-18`, `2025-03-26`, `2024-11-05`, else `2025-06-18`), `capabilities.tools`, `serverInfo`, `instructions` = tool system prompt | +| `notifications/*` | none | +| `ping` | `{}` | +| `tools/list` | one tool: `name`, `title`, `description`, `inputSchema`, `outputSchema`, read-only annotations (`openWorldHint` only when `url` is offered) | +| `tools/call` | `content` (one text block with the result JSON), `structuredContent`, `isError: false` | +| anything else | `-32601` method not found | + +## Error rules + +- Malformed JSON or oversized line: `-32700`, `id: null`. +- Not an object (incl. batches, dropped in MCP 2025-06-18), bad `jsonrpc`, non string/number `id`: `-32600`. +- Unknown tool name: `-32602`. +- Tool argument or extraction failure (`ToolError::UserFacing`): result with `isError: true` and the localized message, so the model can correct itself. +- `ToolError::Internal`: logged to stderr, client sees `internal error`. +- Client responses (no `method`, has `result`/`error`) are ignored; the server sends no requests. + +## Flags + +`--no-url` (drop the `url` source, no network), every `extract` limit flag (`--max-input-size` alias `--max-size`, `--timeout`, `--max-nodes`, ...; see `markkit::Limits`), +`--max-output-chars` (default 100 000), `--main-content`, `--locale` (`en-US`, `uk-UA`). + +## Limits + +- Each line is read with a cap of `ceil(max_input_size / 3) * 4 + 1 MiB` (base64 payload plus + framing). A longer line is drained without buffering and answered with `-32700`; the + session continues. +- Requests are handled sequentially; one call holds at most one document. +- URL calls keep the core SSRF guard (private, loopback, link-local and metadata + addresses refused). + +## Verification + +- Unit: `crates/markkit-cli/src/mcp.rs` tests (negotiation, notifications, malformed + messages, tool errors, SSRF, `--no-url`, oversized line draining). +- End to end: `crates/markkit-cli/tests/mcp_integration.rs` spawns the binary. + +## See also + +- [Tool Contract](tool-contract.md), the tool this server exposes +- [Threat Model](../security/threat-model.md), MCP messages attack surface and T3 SSRF +- [Testing](../operations/testing.md) diff --git a/knowledge/integrations/tool-contract.md b/knowledge/integrations/tool-contract.md index 4bdbc68..d9843f5 100644 --- a/knowledge/integrations/tool-contract.md +++ b/knowledge/integrations/tool-contract.md @@ -12,7 +12,7 @@ tags: # Tool Contract ## Status -Implemented (Rust core, `tool` feature) and exposed in the [WASM Package](../runtimes/wasm-package.md) as `MarkkitTool` (no `url` source there). Python/Node bindings and an MCP server are not wired yet. +Implemented (Rust core, `tool` feature) and exposed in the [WASM Package](../runtimes/wasm-package.md) as `MarkkitTool` (no `url` source there). Served over MCP by `markkit mcp` ([MCP Server](mcp-server.md)). Python/Node bindings are not wired yet. ## Summary @@ -113,3 +113,4 @@ stream closing, and the `tower` service. - [Markkit Architecture](../foundations/architecture.md), the extraction pipeline the tool wraps - [Threat Model](../security/threat-model.md), T3 SSRF and the tool arguments surface - [Examples](../operations/examples.md), `agent_tool.rs` +- [MCP Server](mcp-server.md), stdio transport for this tool diff --git a/knowledge/log.md b/knowledge/log.md index d6177cb..64d0daf 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-27 +* **MCP server**: `markkit mcp` serves `MarkkitTool` over MCP stdio (initialize, ping, tools/list, tools/call); line size capped from `--max-size`, tool failures as `isError` results. New [MCP Server](integrations/mcp-server.md) concept; MCP messages row in the [Threat Model](security/threat-model.md). * **Spreadsheet follow-ups (#11)**: single-row sheets render as data under column letters; XLSX/ODS/XLS titles come from document properties; XLSB content type no longer maps to XLSX; XLSX without `_rels/.rels` parses; trailing-newline output documented as intended; release profile switched to `panic = "unwind"` so `catch_unwind` is effective in markkit's own binaries. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. * **Plain text and Markdown input**: text is always on and goes through the IR; Markdown (`markdown` feature) is re-parsed with pulldown-cmark rather than passed through, so raw HTML never reaches output. Neither is sniffed from bytes. Shares the call's `Limits` budget (timeout, nodes, nesting depth). New threat T7 and invariant S18. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md). diff --git a/knowledge/operations/testing.md b/knowledge/operations/testing.md index b83ad64..530e9d7 100644 --- a/knowledge/operations/testing.md +++ b/knowledge/operations/testing.md @@ -26,6 +26,7 @@ tags: | `crates/markkit/tests/limits.rs` | Every `Limits` field enforced for HTML, DOCX, spreadsheets; small-stack depth ceiling | | `crates/markkit/tests/common/` | In-memory DOCX/XLSX/XLS/ODS fixture builders | | `crates/markkit-cli/tests/cli_integration.rs` | CLI flags and output | +| `crates/markkit-cli/tests/mcp_integration.rs` | `markkit mcp` stdio session end to end | | `crates/markkit/benches/extraction.rs` | Criterion micro-benchmarks | | `crates/markkit-bench/` | Benchmark harness; `cargo test` runs every case once | diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 469def6..3598fde 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -19,6 +19,7 @@ tags: | Byte input | `extract_bytes()` with content-type | Untrusted | | Document input | `extract_document()`, `extract_document_sync()` | Untrusted (bytes and `DocumentHint`) | | LLM tool arguments | `MarkkitTool::execution()` (tool feature) | Untrusted (model output); validated before execution, see [Tool Contract](../integrations/tool-contract.md) | +| MCP messages | `markkit mcp` stdin (JSON-RPC lines) | Untrusted; line size capped, malformed input answered with errors, tool args go through `MarkkitTool`, see [MCP Server](../integrations/mcp-server.md) | | Configuration | `MarkkitBuilder` methods | Trusted (caller-controlled) | | CSS selectors | `include_selector()`, `exclude_selector()` | Semi-trusted (caller-provided) |