Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 94 additions & 19 deletions crates/crw-mcp-proto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,61 @@ impl JsonRpcResponse {

// --- Tool definitions ---

fn extract_accepted_output_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"success": { "type": "boolean" },
"id": { "type": "string" },
"status": { "type": "string", "enum": ["processing"] },
"urls": { "type": "integer", "minimum": 0 }
},
"required": ["success", "id", "status", "urls"]
})
}

fn extract_status_output_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"success": { "type": "boolean" },
"id": { "type": "string" },
"status": {
"type": "string",
"enum": ["processing", "cancelling", "completed", "failed", "cancelled"]
},
"results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": { "type": "string" },
"status": {
"type": "string",
"enum": ["processing", "completed", "failed", "cancelled"]
},
"data": { "type": "object", "additionalProperties": true },
"error": { "type": "string" },
"llmUsage": { "type": "object" },
"basis": { "type": "array", "items": { "type": "object" } },
"basisWarnings": { "type": "array", "items": { "type": "object" } },
"llmInputHash": { "type": "string" }
},
"required": ["url", "status"]
}
},
"error": { "type": "string" },
"expiresAt": { "type": "string", "format": "date-time" },
"creditsUsed": { "type": "integer" },
"tokensUsed": { "type": "integer" }
},
"required": ["success", "id", "status", "results", "expiresAt", "creditsUsed", "tokensUsed"]
})
}

pub fn tool_definitions(proxy_mode: bool) -> Value {
let mut tools = vec![
json!({
Expand Down Expand Up @@ -298,7 +353,8 @@ pub fn tool_definitions(proxy_mode: bool) -> Value {
"llmModel": { "type": "string" }
},
"required": ["urls"]
}
},
"outputSchema": extract_accepted_output_schema()
}),
json!({
"name": "crw_check_extract_status",
Expand All @@ -316,7 +372,27 @@ pub fn tool_definitions(proxy_mode: bool) -> Value {
"id": { "type": "string", "description": "Extract job id from crw_extract" }
},
"required": ["id"]
}
},
"outputSchema": extract_status_output_schema()
}),
json!({
"name": "crw_cancel_extract",
"title": "Cancel extract job",
"description": "Request cancellation of an extract job. Returns the canonical status; cancelling remains non-terminal until the claimed URL settles.",
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Extract job id from crw_extract" }
},
"required": ["id"]
},
"outputSchema": extract_status_output_schema()
}),
];

Expand Down Expand Up @@ -821,12 +897,12 @@ mod tests {
///
/// Baseline before the Phase 1 trim was 8233 bytes (~2744 est-tok). After the
/// Phase 1 trim + Phase 3 annotations/titles + the two native extract tools the
/// full 8-tool list is ~8017 bytes (~2673 est-tok). The ceiling is floor + ~3%
/// headroom so the gate catches real bloat without churning on minor edits.
// Raised from 2300 when the two native extract tools (crw_extract +
// crw_check_extract_status) were added — proportional to the existing
// per-tool footprint, not description bloat.
const TOOLS_LIST_TOKEN_CEILING: usize = 2750;
/// full 8-tool list was ~8017 bytes (~2673 est-tok). The canonical lifecycle
/// adds one cancel tool plus required output schemas for start/status/cancel;
/// after closing lifecycle statuses and typing every per-URL result field,
/// the 9-tool list is ~10705 bytes (~3569 est-tok). The ceiling keeps ~2%
/// headroom so further growth still fails.
const TOOLS_LIST_TOKEN_CEILING: usize = 3650;

#[test]
fn tools_list_token_budget() {
Expand Down Expand Up @@ -1306,20 +1382,14 @@ mod tests {
}

/// A1 — every tool advertises annotations + a title; crw_crawl and crw_extract
/// are the non-read-only / non-idempotent tools; crw_parse_file is the only closed-world.
/// are non-idempotent, while cancel is destructive but idempotent.
#[test]
fn a1_tools_advertise_annotations_and_title() {
let defs = tool_definitions(false);
for t in defs["tools"].as_array().unwrap() {
assert!(t["annotations"].is_object(), "{} annotations", t["name"]);
assert!(t["title"].is_string(), "{} title", t["name"]);
// destructiveHint is explicitly false everywhere (the JSON default is true).
assert_eq!(
t["annotations"]["destructiveHint"],
json!(false),
"{}",
t["name"]
);
assert!(t["annotations"]["destructiveHint"].is_boolean());
}
let crawl = tool_by_name(&defs, "crw_crawl");
assert_eq!(crawl["annotations"]["readOnlyHint"], json!(false));
Expand All @@ -1328,14 +1398,18 @@ mod tests {
let extract = tool_by_name(&defs, "crw_extract");
assert_eq!(extract["annotations"]["readOnlyHint"], json!(false));
assert_eq!(extract["annotations"]["idempotentHint"], json!(false));
let cancel = tool_by_name(&defs, "crw_cancel_extract");
assert_eq!(cancel["annotations"]["readOnlyHint"], json!(false));
assert_eq!(cancel["annotations"]["destructiveHint"], json!(true));
assert_eq!(cancel["annotations"]["idempotentHint"], json!(true));
let scrape = tool_by_name(&defs, "crw_scrape");
assert_eq!(scrape["annotations"]["readOnlyHint"], json!(true));
assert_eq!(scrape["annotations"]["openWorldHint"], json!(true));
let parse = tool_by_name(&defs, "crw_parse_file");
assert_eq!(parse["annotations"]["openWorldHint"], json!(false));
}

/// A2 — is_known_tool recognizes all 8 tool names, rejects others.
/// A2 — is_known_tool recognizes all 9 tool names, rejects others.
#[test]
fn a2_is_known_tool() {
for name in [
Expand All @@ -1347,6 +1421,7 @@ mod tests {
"crw_parse_file",
"crw_extract",
"crw_check_extract_status",
"crw_cancel_extract",
] {
assert!(is_known_tool(name), "{name} should be known");
}
Expand Down Expand Up @@ -1378,10 +1453,10 @@ mod tests {
}
let with = list(true);
assert!(with.contains(&"crw_search".to_string()));
assert_eq!(with.len(), 8);
assert_eq!(with.len(), 9);
let without = list(false);
assert!(!without.contains(&"crw_search".to_string()));
assert_eq!(without.len(), 7);
assert_eq!(without.len(), 8);
}

/// A4 — initialize advertises server usage `instructions` (the model-facing
Expand Down
1 change: 1 addition & 0 deletions crates/crw-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ MCP (Model Context Protocol) server for the [CRW](https://github.com/us/crw) web
| `crw_map` | Discover all URLs on a website |
| `crw_extract` | Extract structured JSON from URLs via a prompt and/or JSON schema (async job, returns job ID) |
| `crw_check_extract_status` | Poll extract job status and retrieve results |
| `crw_cancel_extract` | Idempotently cancel an extract job and retrieve canonical status |
| `crw_search` | Search the web (needs a configured search backend; always available in proxy mode, available in embedded mode only when a search backend is configured) |
| `crw_parse_file` | Parse a local PDF (base64) to markdown |

Expand Down
17 changes: 17 additions & 0 deletions crates/crw-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! - `crw_search` — web search (embedded: only when a search backend is configured; proxy: forwards to the remote API)
//! - `crw_extract` — start an async multi-URL structured extraction job
//! - `crw_check_extract_status` — poll extract job status
//! - `crw_cancel_extract` — idempotently cancel an extract job
//! - `crw_parse_file` — parse a local PDF (base64) to markdown
//!
//! # Usage
Expand Down Expand Up @@ -261,6 +262,8 @@ async fn proxy_call_tool(
parse_response(resp).await
}
"crw_extract" => {
let mut headers = headers;
headers.insert("prefer", "respond-async".parse().unwrap());
let resp = client
.post(format!("{base_url}/v1/extract"))
.headers(headers)
Expand All @@ -285,6 +288,20 @@ async fn proxy_call_tool(
.map_err(|e| format!("HTTP request failed: {e}"))?;
parse_response(resp).await
}
"crw_cancel_extract" => {
let id = args
.get("id")
.and_then(|v| v.as_str())
.ok_or("missing required parameter: id")?;
let resp = client
.delete(format!("{base_url}/v1/extract/{id}"))
.headers(headers)
.timeout(TIMEOUT_CRAWL_STATUS)
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
parse_response(resp).await
}
"crw_parse_file" => {
use base64::Engine;
let b64 = args
Expand Down
69 changes: 64 additions & 5 deletions crates/crw-server/openapi/openapi-3.0.json
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,22 @@
"extract"
],
"summary": "Start an async multi-URL structured extraction job",
"description": "Native extraction. Returns a per-URL array via GET /v1/extract/{id}. At least one of `prompt` or `schema` is required.",
"description": "Native extraction. Returns a fixed-cardinality per-URL array via GET /v1/extract/{id}. At least one of `prompt` or `schema` is required. SDK async starters send `Prefer: respond-async` for managed/self-hosted parity.",
"operationId": "extractStart",
"parameters": [
{
"name": "Prefer",
"in": "header",
"required": false,
"schema": {
"type": "string",
"enum": [
"respond-async"
]
},
"description": "Request the canonical async lifecycle across managed and self-hosted deployments."
}
],
"requestBody": {
"required": true,
"content": {
Expand Down Expand Up @@ -464,6 +478,46 @@
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
}
},
"delete": {
"tags": [
"extract"
],
"summary": "Cancel an extract job",
"description": "Idempotent. Processing becomes cancelling while a claimed URL settles, then cancelled. Completed, failed, and cancelled states are immutable.",
"operationId": "extractCancel",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Extract job id returned by POST /v1/extract"
}
],
"responses": {
"200": {
"description": "Canonical job status + ordered per-URL results",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExtractStatus"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
Expand Down Expand Up @@ -1386,14 +1440,16 @@
"status": {
"type": "string",
"enum": [
"processing",
"completed",
"failed"
"failed",
"cancelled"
]
},
"data": {
"type": "object",
"additionalProperties": true,
"description": "Extracted JSON object (present when status is completed)."
"description": "Extracted JSON object (present when status is completed). Untouched cancelled slots omit data, error, usage, and basis."
},
"error": {
"type": "string"
Expand Down Expand Up @@ -1427,6 +1483,7 @@
"required": [
"id",
"status",
"results",
"expiresAt",
"creditsUsed",
"tokensUsed"
Expand All @@ -1439,16 +1496,18 @@
"type": "string",
"enum": [
"processing",
"cancelling",
"completed",
"failed"
"failed",
"cancelled"
]
},
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ExtractUrlResult"
},
"description": "Per-URL results in original request order."
"description": "Fixed-cardinality per-URL slots in original request order. Pending slots are processing; untouched terminal slots are cancelled."
},
"error": {
"type": "string",
Expand Down
Loading
Loading