diff --git a/.claude/context/linear.md b/.claude/context/linear.md index 1f7c012..2e52573 100644 --- a/.claude/context/linear.md +++ b/.claude/context/linear.md @@ -50,6 +50,17 @@ Selector rules: - `--query` matches a title substring. `--updated-after` needs RFC3339 and is rejected before any request when malformed. - Issue update `--state` names resolve from the issue identifier team (`GUZ-22` → `GUZ`). `--team` overrides. UUID issue ids still need `--team` or a state UUID. +### Chart + +```bash +# Render an HTML Mermaid chart of issues, sub-issues, and blockers, then open it +mcptools linear chart GUZ-185 GUZ-186 GUZ-188 +mcptools linear chart GUZ-185 --out /tmp/linear-chart.html +mcptools linear chart GUZ-185 --no-open +``` + +`chart` walks the transitive closure of children and `blocked_by` blockers (cap 300 issues, warning on the cap), drops canceled issues, and classifies the rest as complete, in progress, frontier (ready to start), or fog (prerequisite incomplete). Output defaults to `/mcptools-linear-chart.html`. Empty issue ids reject before any request. + ### Discovery ```bash diff --git a/.claude/context/mcp-server.md b/.claude/context/mcp-server.md index 360a7f9..27ba867 100644 --- a/.claude/context/mcp-server.md +++ b/.claude/context/mcp-server.md @@ -20,6 +20,9 @@ mcptools mcp sse --port 3000 --host 127.0.0.1 For web-based clients using Server-Sent Events over HTTP. +mcptools mcp catalog # Print the compact tool catalog (domain, name, summary) +mcptools mcp declarations [NAME...] # Print TypeScript declarations for named tools (all tools without names) + ## Claude Desktop Configuration Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -131,6 +134,109 @@ Or manually add to `~/Library/Application Support/Claude/claude_code_config.json | `ui_annotations_resolve` | Mark annotation as resolved | | `ui_annotations_clear` | Clear all annotations | +### Find tools + +| Tool | Description | +|------|-------------| +| `find_tools` | Ranks registered tools for a task description. Returns `none` score, `tools` list with a TypeScript `declaration` per tool, `backend` ("local" or "jev"), optional `fallback` ("unreachable" | "http_status" | "invalid_response" | "invalid_config"), and `usage`. | + +`find_tools` is advisory: it never dispatches a tool, and a Jev outage never blocks the caller. + +**Jev environment variables** + +| Variable | Default | Description | +|----------|---------|-------------| +| `JEV_PROVIDER` | unset | `opencode`, `openrouter`, `vercel` or `typesafe` to enable Jev classifier | +| `JEV_ENDPOINT` | provider preset | Override endpoint URL | +| `JEV_MODEL` | provider preset | Override model name | +| `JEV_API_KEY` | (none) | API key; falls back to the provider's conventional key var | +| `OPENCODE_API_KEY` | - | Conventional key var when `JEV_PROVIDER=opencode` | +| `OPENROUTER_API_KEY` | - | Conventional key var when `JEV_PROVIDER=openrouter` | +| `AI_GATEWAY_API_KEY` | - | Conventional key var when `JEV_PROVIDER=vercel` | +| `TYPESAFE_API_KEY` | - | Conventional key var when `JEV_PROVIDER=typesafe` | + +### Execute + +| Tool | Description | +|------|-------------| +| `execute` | Run JavaScript in a sandbox that binds the tools `find_tools` returns | + +Inputs: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `code` | string | (required) | JavaScript to run; the final expression or top-level `return` value becomes `result` | +| `allowWrites` | boolean | `false` | Also bind tools of kind Write | +| `allowSpend` | boolean | `false` | Also bind tools of kind Spend | + +Result shape, the `structuredContent` object: + +| Field | Type | Description | +|-------|------|-------------| +| `logs` | string[] | Lines captured from `console.log` up to the failure point | +| `result` | any | Final expression or top-level return value; `null` when `error` is set | +| `error` | object \| null | `null` on success; otherwise `{message, name}` | + +The envelope carries `isError: true` exactly when `error` is non-null. On +failure `result` is `null` and `logs` still holds the lines printed before the +failure. + +Error names: + +- `TimeoutError` — the run exceeded `--execute-timeout-secs` +- `OutputLimitError` — the logs and result exceeded `--execute-output-kb` +- JavaScript error names pass through unchanged (`TypeError`, `Error`, and so + on); memory exhaustion surfaces as `InternalError` + +Flag gating is by tool kind only: read tools bind by default, `allowWrites` +adds write tools, `allowSpend` adds spend tools, and `execute` never binds as +a global inside its own sandbox. A missing gated tool rejects with a +`ReferenceError` whose message names the tool, its kind, and the flag to pass +(e.g. `jira_update is a write tool; pass allowWrites: true to execute`); any +other missing global keeps QuickJS's plain ` is not defined` message. + +Limits come from the server flags `--execute-timeout-secs` (default 30), +`--execute-memory-mb` (default 64), and `--execute-output-kb` (default 256). + +## Agent loop + +1. Call `find_tools` with a task that names the system (e.g. "mark the Jira ticket done", not "mark it done"). +2. Read the returned `declaration` on the tool(s) of interest. +3. Call the tool by name through `tools/call`. +4. For a same-domain follow-up (e.g. `jira_comment_list` then `jira_comment_update`), call the follow-up tool directly; do not call `find_tools` again. +5. `find_tools` is advisory: it never dispatches a tool, and Jev is consulted only inside `find_tools` itself, never in front of or around any other tool call. +6. In discovery mode the loop needs a host that can call a tool absent from `tools/list` — see the Known limitation in [Discovery mode](#discovery-mode) below; this section restates none of that text. + +## Discovery mode + +Enable with the `--discovery` flag or the `MCPTOOLS_DISCOVERY` environment +variable. Env values are case-insensitive booleans: `y`, `yes`, `t`, `true`, +`on`, `1` enable the mode; `n`, `no`, `f`, `false`, `off`, `0` keep it off; +any other value, including an empty one, exits with code 2 and the error +`value was not a boolean`. In this mode `tools/list` returns exactly one tool, +`find_tools`; `tools/call` still dispatches every real tool by name. Without +this mode, hosts load all 63 schemas and Jev saves no context. + +Known limitation, planned and undesigned: hosts that declare only listed +tools to the model, such as Claude Code, cannot yet call a tool directly +when it is absent from `tools/list`. The returned declarations name the +tools/call arguments and results of every tool they carry. Code mode closes +this gap in practice: `tools/list` returns `execute` beside `find_tools`, and +tools bind inside `execute` as async globals, under the flag gating +documented in [Execute](#execute) above. + +## Code mode + +Enable with the `--code-mode` flag or the `MCPTOOLS_CODE_MODE` environment +variable, with the same accepted values as `MCPTOOLS_DISCOVERY`. In this mode +`tools/list` returns exactly two tools, in order: `find_tools`, then +`execute`. Code mode wins over Discovery mode when both are enabled. +`tools/call` still dispatches every real tool by name. The `find_tools` +response `usage` field, also for nested `find_tools` calls inside `execute`, +is `Call each declared function as an async global inside execute.`; other +modes keep `Each declaration is the call signature: the input interface is +the tools/call arguments object, the Promise type is the result.` + ## Testing with curl ```bash diff --git a/.claude/context/testing.md b/.claude/context/testing.md index 8e81bba..3383e6d 100644 --- a/.claude/context/testing.md +++ b/.claude/context/testing.md @@ -7,6 +7,8 @@ All CLI arguments can be provided via environment variables, useful for scriptin | Variable | Description | |----------|-------------| | `MCPTOOLS_VERBOSE` | Enable verbose output (default: false) | +| `MCPTOOLS_DISCOVERY` | List only `find_tools` via `tools/list`. Accepts `y`, `yes`, `t`, `true`, `on`, `1` (on) and `n`, `no`, `f`, `false`, `off`, `0` (off), any case; other values exit 2 (default: false) | +| `MCPTOOLS_CODE_MODE` | List `find_tools,execute` via `tools/list`; wins over `MCPTOOLS_DISCOVERY`. Same accepted values, other values exit 2 (default: false) | ## Atlassian Variables @@ -83,6 +85,33 @@ All CLI arguments can be provided via environment variables, useful for scriptin | `OPENAI_API_KEY` | Key for `--api openai` | | `OPENAI_BASE_URL` | Base URL for `--api openai` (default: `https://api.openai.com/v1`) | +## Jev Variables + +| Variable | Description | +|----------|-------------| +| `JEV_PROVIDER` | `opencode`, `openrouter`, `vercel` or `typesafe` to enable Jev (unset selects local) | +| `JEV_ENDPOINT` | Override gateway endpoint (defaults from provider) | +| `JEV_MODEL` | Override model (defaults from provider) | +| `JEV_API_KEY` | API key (falls back to the provider's conventional variable) | +| `OPENCODE_API_KEY` | Key used by `opencode` preset | +| `OPENROUTER_API_KEY` | Key used by `openrouter` preset | +| `AI_GATEWAY_API_KEY` | Key used by `vercel` preset | +| `TYPESAFE_API_KEY` | Key used by `typesafe` preset | + +On `jev-1.13-free` the full 61-tool catalog plus `none` returns in under 1.2 s per call. `none` separates in-scope from out-of-scope tasks except for one near-miss. The 0.05-floor selection rule matches the live probabilities for every query the spike ran. This covers the `opencode` preset only; other presets are unverified beyond gateway documentation. + +Live run against the `opencode` preset, one row per query (`JEV_PROVIDER=opencode`, `-k 5`, timed per call): + +| task | backend | fallback | none | tools | latency | +|------|---------|----------|------|-------|---------| +| `mark the ticket done` | `jev` | — | 0.01 | `jira_update`, `linear_issue_update` | 1.2 s | +| `table of contents of this pdf` | `jev` | — | 0.0 | `pdf_toc` | 1.4 s | +| `table of contents of this web page` | `jev` | — | 0.0 | `md_toc` | 1.2 s | +| `update the jira comment I just listed` | `jev` | — | 0.0 | `jira_comment_update` | 1.2 s | +| `jira_comment_update` | `jev` | — | 0.0 | `jira_comment_update` | 1.9 s | +| `pdf_toc` | `jev` | — | 0.0 | `pdf_toc` | 1.1 s | +| `book a flight to Paris` | `jev` | — | 1.0 | — | 1.1 s | + ## Usage Examples ### Scripting diff --git a/CLAUDE.md b/CLAUDE.md index 8bc93de..dd50eed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,19 @@ export ATLASSIAN_API_TOKEN="your-api-token" | `ATLAS_FILE_MODEL` | `gpt-5.6-luna` | Model for file descriptions | | `ATLAS_DIR_MODEL` | `gpt-5.6-luna` | Model for directory descriptions | +**Jev** + +| Variable | Default | Description | +|----------|---------|-------------| +| `JEV_PROVIDER` | unset | `opencode`, `openrouter`, `vercel` or `typesafe` to enable Jev classifier | +| `JEV_ENDPOINT` | provider preset | Override endpoint URL | +| `JEV_MODEL` | provider preset | Override model name | +| `JEV_API_KEY` | (none) | API key; falls back to the provider's conventional key var | +| `OPENCODE_API_KEY` | - | Conventional key var when `JEV_PROVIDER=opencode` | +| `OPENROUTER_API_KEY` | - | Conventional key var when `JEV_PROVIDER=openrouter` | +| `AI_GATEWAY_API_KEY` | - | Conventional key var when `JEV_PROVIDER=vercel` | +| `TYPESAFE_API_KEY` | - | Conventional key var when `JEV_PROVIDER=typesafe` | + ## Detailed Documentation For detailed usage of each feature, see the context files: @@ -142,6 +155,7 @@ mcptools linear issue list --assignee me --json mcptools linear teams list mcptools linear projects list --team GUZ mcptools linear users list --query "Ada" +mcptools linear chart GUZ-185 GUZ-186 GUZ-188 ``` ### Bitbucket @@ -192,9 +206,15 @@ mcptools md fetch https://docs.example.com --selector "main" ```bash mcptools mcp stdio # For local agents (Claude Desktop) +mcptools mcp stdio --discovery # tools/list returns only find_tools (env: MCPTOOLS_DISCOVERY=true) +mcptools mcp stdio --code-mode # tools/list returns find_tools,execute (env: MCPTOOLS_CODE_MODE=true) mcptools mcp sse # For web clients +mcptools mcp catalog # Print the compact tool catalog (domain, name, summary) +mcptools mcp declarations [NAME...] # Print TypeScript declarations for named tools (all tools without names) ``` +Find `find_tools` with a task that names the system, read the declaration, call the tool by name, and call same-domain follow-ups directly without another `find_tools` call. See [.claude/context/mcp-server.md#agent-loop](.claude/context/mcp-server.md#agent-loop) for the full loop and the discovery-mode limitation. + ## Setup Guides - [Atlassian Setup](docs/ATLASSIAN_SETUP.md) - Detailed setup instructions diff --git a/CONTEXT.md b/CONTEXT.md index ac083de..9558bc3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,10 @@ _Avoid_: team name, team id Time-ordered issue events from Linear history, always starting with created. _Avoid_: history, timeline, audit log +**Discovery mode**: +MCP server mode in which `tools/list` returns only `find_tools`. +_Avoid_: finder mode, list gating + ## Relationships - An **Issue identifier** contains exactly one **Team key** @@ -28,6 +32,49 @@ _Avoid_: history, timeline, audit log ## Behavior Linear command reference: [Linear](.claude/context/linear.md). +MCP server reference: [MCP Server](.claude/context/mcp-server.md). + +### Requirement: Discovery mode tool list +The `--discovery` flag or `MCPTOOLS_DISCOVERY=true` starts the MCP server in **Discovery mode** on stdio and SSE. `MCPTOOLS_DISCOVERY` also accepts `y`, `yes`, `t`, `true`, `on`, `1`, `n`, `no`, `f`, `false`, `off`, and `0`, in upper or lower case. `tools/call` dispatches every registered tool by name in every mode. + +#### Scenario: Flag or env enables the mode +- **WHEN** the server starts with `--discovery`, or with `MCPTOOLS_DISCOVERY=true` and no flag +- **THEN** `tools/list` returns exactly one tool, `find_tools` + +#### Scenario: Default list +- **WHEN** the server starts with neither the flag nor the env var +- **THEN** `tools/list` returns every registered tool + +#### Scenario: Unlisted tool call +- **WHEN** a client in **Discovery mode** sends `tools/call` with the name of a tool that `tools/list` did not return +- **THEN** the server dispatches that tool and returns its result + +#### Scenario: Invalid env value +- **WHEN** `MCPTOOLS_DISCOVERY` holds a value outside the accepted list, including an empty value +- **THEN** the process exits with code 2 and an error that says `value was not a boolean` + +### Requirement: Code mode tool list +The `--code-mode` flag or `MCPTOOLS_CODE_MODE=true` starts the MCP server in code mode on stdio and SSE. `MCPTOOLS_CODE_MODE` accepts the same values as `MCPTOOLS_DISCOVERY`; any other value exits with code 2. `tools/call` dispatches every registered tool by name in every mode. + +#### Scenario: Flag or env enables the mode +- **WHEN** the server starts with `--code-mode`, or with `MCPTOOLS_CODE_MODE=true` and no flag +- **THEN** `tools/list` returns exactly two tools, in order: `find_tools`, then `execute` + +#### Scenario: Code mode wins over discovery +- **WHEN** the server starts with `--discovery` and `--code-mode`, or with both env vars enabled +- **THEN** `tools/list` returns `find_tools` and `execute`, not one tool + +#### Scenario: Unlisted tool call +- **WHEN** a client in code mode sends `tools/call` with the name of a tool that `tools/list` did not return +- **THEN** the server dispatches that tool and returns its result + +#### Scenario: Code-mode usage text +- **WHEN** `find_tools` runs in code mode, including a nested call inside `execute` +- **THEN** its `usage` field is `Call each declared function as an async global inside execute.` + +#### Scenario: Usage text without code mode +- **WHEN** `find_tools` runs in any other mode +- **THEN** its `usage` field is `Each declaration is the call signature: the input interface is the tools/call arguments object, the Promise type is the result.` ### Requirement: Issue get payload `linear issue get` and `linear_issue_get` return the same payload: snapshot fields, comments, and **Activity**. **Activity** always includes a created row. Comments stay on `comments`. @@ -69,3 +116,100 @@ Issue update resolves a workflow state name against the **Team key** in the **Is #### Scenario: Stdin sentinel - **WHEN** `--body-file -` - **THEN** the process reads stdin to EOF for the body + +### Requirement: Execute tool +The `execute` MCP tool runs JavaScript in a sandbox and returns `logs`, `result`, and `error` in `structuredContent`. Limits come from the `--execute-timeout-secs`, `--execute-output-kb`, and `--execute-memory-mb` server flags. Gating is by tool kind: read tools bind by default, `allowWrites` adds write tools, `allowSpend` adds spend tools, and `execute` never binds as a global inside its own sandbox. A missing gated tool keeps `error.name` at `ReferenceError` and reports a message naming the tool, its kind, and the flag to pass; any other missing global keeps the plain `is not defined` message. + +#### Scenario: Success payload +- **WHEN** the script finishes without throwing +- **THEN** `structuredContent` holds `logs`, the final value as `result`, and `error: null` +- **AND** the envelope has no `isError` + +#### Scenario: Script throws +- **WHEN** the script throws a JavaScript error +- **THEN** `error.name` is the error's own name and `error.message` is its message +- **AND** `result` is `null`, `logs` keeps the lines printed before the throw, and the envelope has `isError: true` + +#### Scenario: Timeout +- **WHEN** the run exceeds `--execute-timeout-secs` +- **THEN** `error.name` is `TimeoutError` +- **AND** `logs` keeps the lines printed before the deadline + +#### Scenario: Output cap +- **WHEN** the logs and result exceed `--execute-output-kb` +- **THEN** `error.name` is `OutputLimitError` +- **AND** `logs` holds the lines that fit under the cap + +#### Scenario: Memory exhaustion +- **WHEN** the script exhausts `--execute-memory-mb` +- **THEN** `error.name` is `InternalError` + +#### Scenario: Kind gating +- **WHEN** `execute` runs with neither `allowWrites` nor `allowSpend` +- **THEN** only read tools bind as globals in the sandbox + +#### Scenario: Gated tool message +- **WHEN** the script references a write or spend tool that did not bind +- **THEN** `error.name` is `ReferenceError` and `error.message` names the tool, its kind, and the flag to pass +- **AND** a missing global that is not a gated tool keeps the plain `is not defined` message + +#### Scenario: Missing code +- **WHEN** `tools/call` invokes `execute` without `code` +- **THEN** the server returns a JSON-RPC error with code `-32602` + +### Requirement: Linear chart rendering +`linear chart` takes one or more **Issue identifier**s, fetches the transitive closure of sub-issues and `blocked_by` blockers (cap 300 issues, stderr warning when the cap stops expansion), drops canceled issues, and classifies the rest as complete, in progress, frontier (no incomplete blocker), or fog (at least one incomplete blocker). It writes a full-viewport dark-themed HTML Mermaid page — one subgraph per parent issue, state legend — to `/mcptools-linear-chart.html` unless `--out` overrides it, prints the output path and a stats line, and opens the page in the default browser unless `--no-open`. + +#### Scenario: Closure and classification +- **WHEN** chart runs on a set of **Issue identifier**s +- **THEN** every transitive sub-issue and blocker appears as a node, grouped into one subgraph per parent issue +- **AND** completed issues are complete, started issues are in progress, not-yet-started issues with no incomplete blocker are frontier, and issues with at least one incomplete blocker are fog +- **AND** canceled issues are absent and do not block dependents + +#### Scenario: Cap stops expansion +- **WHEN** the closure exceeds 300 issues +- **THEN** expansion stops, stderr prints a cap warning, and the partial chart still renders + +#### Scenario: Blocker outside the fetched set +- **WHEN** an issue is blocked by an issue outside the fetched set +- **THEN** stderr warns and the blocked issue renders as fog + +#### Scenario: Output location and browser launch +- **WHEN** chart runs with `--out PATH` +- **THEN** the page is written to PATH; without `--out` it is written to `/mcptools-linear-chart.html` +- **AND** the default browser opens it unless `--no-open` is set +- **AND** stdout prints the output path, then ` issues: complete, in progress, frontier, fog` + +#### Scenario: Blank issue id +- **WHEN** chart runs with an empty or blank issue id +- **THEN** the command fails with an error naming the problem before any Linear request + +#### Scenario: Deterministic output +- **WHEN** chart runs twice on the same issue set +- **THEN** both runs write byte-identical HTML + +### Requirement: Tool output field projection +`pdf_read`, `md_fetch`, `bitbucket_pr_read`, `jira_search`, and `linear_issue_list` accept an optional `fields` argument: an array of dotted paths. When present and non-empty, the tool returns only the named fields. Projection runs after the handler builds its output and after all existing output shaping (lineLimit truncation, pagination assembly), before serialization. `content[0].text` and `structuredContent` carry the same projected value. Each tool's `outputSchema` keeps the full output shape with every field optional, and each tool description documents `fields`. + +#### Scenario: Named fields only +- **WHEN** `tools/call` invokes `pdf_read` with `fields: ["id", "title"]` +- **THEN** `structuredContent` contains exactly the keys `id` and `title` +- **AND** `content[0].text` parses back to the same value as `structuredContent` + +#### Scenario: Array outputs project per element +- **WHEN** `tools/call` invokes `linear_issue_list` with `fields: ["nodes.identifier", "nodes.title", "pageInfo"]` +- **THEN** every element of `nodes` contains exactly `identifier` and `title`, and `pageInfo` is still present + +#### Scenario: Absent or empty fields +- **WHEN** `tools/call` invokes any of the five tools without `fields`, or with `fields: []` +- **THEN** the full output returns unchanged, byte-identical to the pre-`fields` behavior + +#### Scenario: Unknown field path +- **WHEN** `tools/call` invokes any of the five tools with a `fields` path that resolves to no field in the actual output, e.g. `["bogus"]` +- **THEN** the server returns a JSON-RPC error with code `-32602` and message `Unknown field path: "bogus"` + +#### Scenario: Catalog advertises the parameter +- **WHEN** `tools/list` returns any of the five tools +- **THEN** the input schema shows `fields` as an optional array of strings +- **AND** the description documents `fields` +- **AND** the output schema contains no `required` key anywhere diff --git a/Cargo.lock b/Cargo.lock index 7fee70f..fd444a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -37,6 +51,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -102,6 +122,16 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -114,6 +144,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -140,6 +181,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.8" @@ -213,6 +277,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -237,6 +322,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -299,6 +390,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -399,6 +492,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -646,6 +748,24 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deranged" version = "0.5.8" @@ -782,6 +902,12 @@ dependencies = [ "shared_thread", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -809,6 +935,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -912,6 +1047,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -963,6 +1109,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -975,6 +1132,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -999,6 +1162,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1143,9 +1322,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1216,7 +1397,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1225,6 +1406,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1242,7 +1434,7 @@ checksum = "333344ecb4b6a91ddd2e6a3c4fdb54aaddfbd2c82847f9c58fe42dd88afcf08e" dependencies = [ "anyhow", "auto_generate_cdp", - "base64", + "base64 0.22.1", "derive_builder", "log", "rand 0.9.2", @@ -1285,7 +1477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4" dependencies = [ "html5ever 0.27.0", - "jni", + "jni 0.19.0", "lazy_static", "markup5ever_rcdom", "percent-encoding", @@ -1432,7 +1624,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1747,18 +1939,77 @@ checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" dependencies = [ "cesu8", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.1", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1769,6 +2020,62 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "reqwest 0.13.5", + "rustls", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1940,7 +2247,7 @@ dependencies = [ "anstream", "async-channel", "axum", - "base64", + "base64 0.22.1", "clap", "color-eyre", "colored", @@ -1949,12 +2256,14 @@ dependencies = [ "headless_chrome", "ignore", "indicatif", + "jsonschema", "mcptools_core", "pdf", "prettytable", "rand 0.8.5", "regex", - "reqwest", + "reqwest 0.12.28", + "rquickjs", "rusqlite", "schemars", "serde", @@ -1971,6 +2280,7 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-typescript", "urlencoding", + "wiremock", ] [[package]] @@ -1983,7 +2293,7 @@ dependencies = [ "html2md", "md5", "regex", - "reqwest", + "reqwest 0.12.28", "schemars", "scraper", "serde", @@ -2023,6 +2333,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -2113,12 +2429,81 @@ dependencies = [ "nom", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2128,6 +2513,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -2209,6 +2604,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.2.3" @@ -2626,6 +3027,23 @@ dependencies = [ "syn", ] +[[package]] +name = "referencing" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -2640,9 +3058,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2655,13 +3073,22 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2697,6 +3124,45 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2711,6 +3177,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rquickjs" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a22bf72f515cc4189dd7c63d016f6de92a11eee98ddf7dd64794198f6eb34e" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6657979a24d543e5fc66f32cef2f47ca82ce8eb55cec5f7d59a12b7b570bbf24" +dependencies = [ + "async-lock", + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cee271d0eeba64f0915b846cb7ae02e16faf3dfdffdca91731101d9d30fe3423" +dependencies = [ + "cc", +] + [[package]] name = "rusqlite" version = "0.34.0" @@ -2759,6 +3255,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -2768,6 +3265,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2777,12 +3286,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3098,6 +3635,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.2" @@ -3201,6 +3754,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3743,6 +4317,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3800,7 +4380,7 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "percent-encoding", @@ -3818,7 +4398,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" dependencies = [ - "base64", + "base64 0.22.1", "http", "httparse", "log", @@ -3866,6 +4446,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3884,6 +4474,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -4052,6 +4648,15 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -4369,6 +4974,29 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4591,6 +5219,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 8321910..691f328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,3 +64,5 @@ terminal_size = "0.4" tera = "1.20.0" toml = "0.8" schemars = "1.2" +jsonschema = "0.56" +rquickjs = { version = "0.14", features = ["futures"] } diff --git a/crates/core/src/atlassian/jira.rs b/crates/core/src/atlassian/jira.rs index 306a5f2..db57ff8 100644 --- a/crates/core/src/atlassian/jira.rs +++ b/crates/core/src/atlassian/jira.rs @@ -126,7 +126,7 @@ pub struct JiraExtendedIssueResponse { pub fields: JiraExtendedFields, } -#[derive(Debug, Serialize, Clone, Deserialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct JiraComment { #[serde(rename = "id")] pub comment_id: String, @@ -151,7 +151,7 @@ pub struct TicketOutput { pub due_date: Option, pub labels: Vec, pub components: Vec, - pub comments: Vec, + pub comments: Vec, pub attachments: Vec, } @@ -213,19 +213,15 @@ pub fn extract_description(value: Option) -> Option { }) } -/// Render ADF (Atlassian Document Format) to readable text -/// -/// ADF is a JSON-based document format used by Atlassian products. -/// This function walks the ADF tree and extracts human-readable text. -/// -/// # Arguments -/// * `value` - The ADF document as JSON -/// -/// # Returns -/// * `Option` - Rendered text, or None if empty pub fn render_adf(value: &serde_json::Value) -> Option { + if let Some(s) = value.as_str() { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + return Some(trimmed.to_string()); + } let mut output = String::new(); - if let Some(content) = value.get("content").and_then(|c| c.as_array()) { for node in content { if let Some(rendered) = render_adf_node(node, 0) { @@ -236,7 +232,6 @@ pub fn render_adf(value: &serde_json::Value) -> Option { } } } - if output.is_empty() { None } else { @@ -244,7 +239,6 @@ pub fn render_adf(value: &serde_json::Value) -> Option { } } -/// Render a single ADF node recursively fn render_adf_node(node: &serde_json::Value, depth: usize) -> Option { let node_type = node.get("type")?.as_str()?; let indent = " ".repeat(depth); @@ -326,8 +320,18 @@ fn render_adf_node(node: &serde_json::Value, depth: usize) -> Option { .and_then(|t| t.as_str()) .map(|text| text.to_string()), "hardBreak" => Some("\n".to_string()), + "mention" => node + .get("attrs") + .and_then(|a| a.get("text")) + .and_then(|t| t.as_str()) + .map(|t| { + if t.starts_with('@') { + t.to_string() + } else { + format!("@{}", t) + } + }), _ => { - // For unknown node types, try to extract text content if let Some(content) = node.get("content").and_then(|c| c.as_array()) { let mut text = String::new(); for child in content { @@ -400,8 +404,13 @@ pub fn transform_ticket_response( comments: Vec, attachments: Vec, ) -> TicketOutput { + let key = issue.key; + let mapped_comments: Vec = comments + .into_iter() + .map(|c| transform_comment_response(&key, c)) + .collect(); TicketOutput { - key: issue.key, + key, summary: issue.fields.summary, description: extract_description(issue.fields.description), status: issue.fields.status.name, @@ -427,7 +436,7 @@ pub fn transform_ticket_response( .into_iter() .map(|c| c.name) .collect(), - comments, + comments: mapped_comments, attachments, } } diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs new file mode 100644 index 0000000..0454bd4 --- /dev/null +++ b/crates/core/src/catalog.rs @@ -0,0 +1,79 @@ +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct CatalogEntry { + pub name: String, + pub domain: String, + pub summary: String, +} + +pub const SUMMARY_MAX_CHARS: usize = 80; + +pub fn build_catalog<'a>(tools: impl IntoIterator) -> Vec { + tools + .into_iter() + .map(|(name, summary)| { + let domain = name + .split_once('_') + .map_or(name.to_string(), |(prefix, _)| prefix.to_string()); + CatalogEntry { + name: name.to_string(), + domain, + summary: summary.to_string(), + } + }) + .collect() +} + +pub fn render_catalog(entries: &[CatalogEntry]) -> String { + entries + .iter() + .map(|entry| format!("{}\t{}\t{}\n", entry.domain, entry.name, entry.summary)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_catalog_derives_domain_from_prefix() { + let result = build_catalog([("ui_annotations_list", "List")]); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "ui_annotations_list"); + assert_eq!(result[0].domain, "ui"); + assert_eq!(result[0].summary, "List"); + } + + #[test] + fn build_catalog_keeps_name_without_underscore_as_domain() { + let result = build_catalog([("ping", "Ping")]); + assert_eq!(result[0].domain, "ping"); + } + + #[test] + fn build_catalog_keeps_input_order() { + let result = build_catalog([("a_b", "A"), ("c_d", "C")]); + assert_eq!( + result.iter().map(|e| e.name.as_str()).collect::>(), + vec!["a_b", "c_d"] + ); + } + + #[test] + fn render_catalog_emits_one_tab_separated_line_per_entry() { + let entries = vec![ + CatalogEntry { + name: "jira_get".to_string(), + domain: "jira".to_string(), + summary: "Get".to_string(), + }, + CatalogEntry { + name: "hn_read_item".to_string(), + domain: "hn".to_string(), + summary: "Read".to_string(), + }, + ]; + let output = render_catalog(&entries); + assert_eq!(output, "jira\tjira_get\tGet\nhn\thn_read_item\tRead\n"); + assert_eq!(render_catalog(&[]), ""); + } +} diff --git a/crates/core/src/find_tools.rs b/crates/core/src/find_tools.rs new file mode 100644 index 0000000..e73b8f8 --- /dev/null +++ b/crates/core/src/find_tools.rs @@ -0,0 +1,243 @@ +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use crate::catalog::CatalogEntry; + +static ISSUE_KEY_RE: OnceLock = OnceLock::new(); +static TOKEN_RE: OnceLock = OnceLock::new(); + +fn get_issue_key_re() -> &'static Regex { + ISSUE_KEY_RE.get_or_init(|| Regex::new(r"\b[A-Za-z][A-Za-z0-9]*-\d+\b").expect("valid regex")) +} + +fn get_token_re() -> &'static Regex { + TOKEN_RE.get_or_init(|| Regex::new(r"[a-z0-9]+").expect("valid regex")) +} + +fn stem(token: &str) -> String { + if token.ends_with("ing") && token.len() > 5 { + token[..token.len() - 3].to_string() + } else if (token.ends_with("ed") || token.ends_with("es")) && token.len() > 4 { + token[..token.len() - 2].to_string() + } else if token.ends_with("s") && token.len() > 3 && !token.ends_with("ss") { + token[..token.len() - 1].to_string() + } else { + token.to_string() + } +} + +fn synonym(stem: &str) -> &str { + match stem { + "mark" => "update", + "done" | "resolv" | "resolve" | "finish" => "close", + "ticket" => "issue", + _ => stem, + } +} + +fn tokenize(text: &str) -> Vec { + let lower = text.to_lowercase().replace('_', " "); + get_token_re() + .find_iter(&lower) + .map(|m| synonym(&stem(m.as_str())).to_string()) + .collect() +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct RankedTool { + pub name: String, + pub domain: String, + pub score: f64, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct Ranking { + pub none: f64, + pub tools: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum FindToolsError { + #[error("task is empty")] + EmptyTask, + #[error("k must be greater than zero")] + ZeroK, +} + +pub const DEFAULT_K: usize = 5; + +pub const USAGE: &str = "Each declaration is the call signature: the input interface is the tools/call arguments object, the Promise type is the result."; + +pub const CODE_MODE_USAGE: &str = "Call each declared function as an async global inside execute."; + +pub fn rank_tools( + task: &str, + catalog: &[CatalogEntry], + k: usize, +) -> Result { + let trimmed = task.trim(); + if trimmed.is_empty() { + return Err(FindToolsError::EmptyTask); + } + if k == 0 { + return Err(FindToolsError::ZeroK); + } + let rewritten = get_issue_key_re() + .replace_all(trimmed, "issue ticket") + .to_string(); + let task_tokens = tokenize(&rewritten); + let unique_task: HashSet = task_tokens.into_iter().collect(); + if catalog.is_empty() { + return Ok(Ranking { + none: 1.0, + tools: vec![], + }); + } + let n = catalog.len(); + let entry_data: Vec<_> = catalog + .iter() + .map(|e| { + let nt: HashSet = tokenize(&e.name).into_iter().collect(); + let dt: HashSet = tokenize(&e.domain).into_iter().collect(); + let st: HashSet = tokenize(&e.summary).into_iter().collect(); + (e, nt, dt, st) + }) + .collect(); + let mut df: HashMap = HashMap::new(); + for (_, nt, dt, st) in &entry_data { + let mut all: HashSet = HashSet::new(); + all.extend(nt.iter().cloned()); + all.extend(dt.iter().cloned()); + all.extend(st.iter().cloned()); + for t in all { + *df.entry(t).or_insert(0) += 1; + } + } + let idf = |t: &str| -> f64 { + if let Some(&d) = df.get(t) { + (n as f64 / d as f64).ln() + } else { + (n as f64 / 1.0).ln() + } + }; + let sum_idf: f64 = unique_task.iter().map(|t| idf(t.as_str())).sum(); + let mut scored: Vec<(String, String, f64)> = entry_data + .into_iter() + .map(|(e, nt, dt, st)| { + let score = if sum_idf <= 0.0 { + 0.0 + } else { + let number: f64 = unique_task + .iter() + .map(|t| { + let w = if nt.contains(t) { + 2.0 + } else if dt.contains(t) { + 1.5 + } else if st.contains(t) { + 1.0 + } else { + 0.0 + }; + idf(t.as_str()) * w + }) + .sum(); + number / (2.0 * sum_idf) + }; + (e.name.clone(), e.domain.clone(), score) + }) + .collect(); + scored.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); + let take = k.min(catalog.len()); + let top: Vec = scored + .into_iter() + .take(take) + .map(|(name, domain, score)| RankedTool { + name, + domain, + score, + }) + .collect(); + let top1 = top.first().map(|t| t.score).unwrap_or(1.0); + let none = 1.0 - top1; + Ok(Ranking { none, tools: top }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::build_catalog; + + #[test] + fn tokenize_maps_synonyms() { + assert_eq!( + tokenize("mark the ticket done"), + vec!["update", "the", "issue", "close"] + ); + assert_eq!(tokenize("resolved"), vec!["close"]); + assert_eq!(tokenize("resolve"), vec!["close"]); + } + + #[test] + fn rank_tools_orders_by_score_descending() { + let catalog = build_catalog([ + ("other", "nothing relevant"), + ( + "linear_issue_update", + "Update a Linear issue by id or identifier", + ), + ("jira_search", "Search Jira issues"), + ]); + let r = rank_tools("close GUZ-22", &catalog, 5).unwrap(); + assert_eq!(r.tools[0].name, "linear_issue_update"); + assert!(r.tools[0].score > r.tools[1].score); + } + + #[test] + fn rank_tools_rejects_empty_task() { + let catalog = build_catalog([("x", "y")]); + let e = rank_tools(" ", &catalog, 5); + assert!(matches!(e, Err(FindToolsError::EmptyTask))); + } + + #[test] + fn rank_tools_rejects_zero_k() { + let catalog = build_catalog([("x", "y")]); + let e = rank_tools("close it", &catalog, 0); + assert!(matches!(e, Err(FindToolsError::ZeroK))); + } + + #[test] + fn rank_tools_rewrites_issue_keys() { + let catalog = build_catalog([("linear_issue_update", "Update Linear issue")]); + let r1 = rank_tools("close GUZ-22", &catalog, 5).unwrap(); + let r2 = rank_tools("close issue ticket", &catalog, 5).unwrap(); + assert_eq!(r1.tools.len(), r2.tools.len()); + assert_eq!(r1.tools[0].score, r2.tools[0].score); + assert_eq!(r1.none, r2.none); + } + + #[test] + fn rank_tools_scores_stay_within_unit_interval() { + let pairs = [ + ( + "close GUZ-22", + [("linear_issue_update", "update linear"), ("other", "foo")], + ), + ( + "list stories", + [("hn_list_items", "list hacker news"), ("jira", "search")], + ), + ("!!!", [("a", "b"), ("c", "d")]), + ]; + for (task, entries) in pairs { + let cat = build_catalog(entries); + let r = rank_tools(task, &cat, 5).unwrap(); + for t in &r.tools { + assert!(t.score >= 0.0 && t.score <= 1.0); + } + assert!(r.none >= 0.0 && r.none <= 1.0); + } + } +} diff --git a/crates/core/src/jev.rs b/crates/core/src/jev.rs new file mode 100644 index 0000000..8ab5330 --- /dev/null +++ b/crates/core/src/jev.rs @@ -0,0 +1,408 @@ +use serde_json::json; + +pub struct Secret(String); + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[redacted]") + } +} + +impl Clone for Secret { + fn clone(&self) -> Self { + Secret(self.0.clone()) + } +} + +impl Secret { + pub fn new(value: impl Into) -> Self { + Secret(value.into()) + } + pub fn expose(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provider { + OpenCode, + OpenRouter, + Vercel, + TypeSafe, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preset { + pub endpoint: &'static str, + pub model: &'static str, + pub key_var: &'static str, +} + +impl Provider { + pub fn preset(&self) -> Preset { + match self { + Provider::OpenCode => Preset { + endpoint: "https://opencode.ai/zen/v1/systemone", + model: "jev-1.13", + key_var: "OPENCODE_API_KEY", + }, + Provider::OpenRouter => Preset { + endpoint: "https://openrouter.ai/api/v1/systemone", + model: "typesafe/jev-1.13", + key_var: "OPENROUTER_API_KEY", + }, + Provider::Vercel => Preset { + endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone", + model: "typesafe-ai/jev", + key_var: "AI_GATEWAY_API_KEY", + }, + Provider::TypeSafe => Preset { + endpoint: "https://api.typesafe.ai/v1/systemone", + model: "jev-1.13.0", + key_var: "TYPESAFE_API_KEY", + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct GatewayConfig { + pub endpoint: String, + pub model: String, + pub api_key: Secret, +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("unknown JEV_PROVIDER")] + UnknownProvider, + #[error("missing API key")] + MissingKey, +} + +pub fn gateway_config( + var: impl Fn(&str) -> Option, +) -> Result, ConfigError> { + match var("JEV_PROVIDER") { + None => Ok(None), + Some(name) => { + let provider = match name.as_str() { + "opencode" => Provider::OpenCode, + "openrouter" => Provider::OpenRouter, + "vercel" => Provider::Vercel, + "typesafe" => Provider::TypeSafe, + _ => return Err(ConfigError::UnknownProvider), + }; + let preset = provider.preset(); + let endpoint = var("JEV_ENDPOINT").unwrap_or_else(|| preset.endpoint.to_string()); + let model = var("JEV_MODEL").unwrap_or_else(|| preset.model.to_string()); + let key = var("JEV_API_KEY").or_else(|| var(preset.key_var)); + match key { + Some(k) => Ok(Some(GatewayConfig { + endpoint, + model, + api_key: Secret::new(k), + })), + None => Err(ConfigError::MissingKey), + } + } + } +} + +pub fn build_request( + task: &str, + catalog: &[crate::catalog::CatalogEntry], + model: &str, +) -> serde_json::Value { + let mut criteria = serde_json::Map::new(); + for entry in catalog { + criteria.insert(entry.name.clone(), json!(entry.summary.clone())); + } + criteria.insert("none".to_string(), json!("No listed tool can do this task")); + json!({ + "model": model, + "state": task, + "questions": { + "tool": { + "type": "choice", + "instructions": "Which tool does this task?", + "criteria": criteria + } + } + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum ClassifyError { + #[error("malformed response body")] + MalformedBody, + #[error("missing answer")] + MissingAnswer, +} + +pub fn parse_ranking( + body: &str, + catalog: &[crate::catalog::CatalogEntry], +) -> Result { + let v: serde_json::Value = + serde_json::from_str(body).map_err(|_| ClassifyError::MalformedBody)?; + let probs = v + .get("answers") + .and_then(|a| a.get("tool")) + .and_then(|t| t.get("probabilities")) + .and_then(|p| p.as_object()) + .ok_or(ClassifyError::MalformedBody)?; + if !probs.contains_key("none") { + return Err(ClassifyError::MissingAnswer); + } + let none = probs + .get("none") + .and_then(|n| n.as_f64()) + .ok_or(ClassifyError::MalformedBody)?; + if !(0.0..=1.0).contains(&none) { + return Err(ClassifyError::MalformedBody); + } + let mut tools = vec![]; + for (k, val) in probs { + if k == "none" { + continue; + } + let score = val.as_f64().ok_or(ClassifyError::MalformedBody)?; + if !(0.0..=1.0).contains(&score) { + return Err(ClassifyError::MalformedBody); + } + if let Some(entry) = catalog.iter().find(|c| c.name == *k) { + tools.push(crate::find_tools::RankedTool { + name: k.clone(), + domain: entry.domain.clone(), + score, + }); + } + } + tools.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(crate::find_tools::Ranking { none, tools }) +} + +pub const MIN_PROBABILITY: f64 = 0.05; +pub const MAX_JEV_TOOLS: usize = 3; + +pub fn select(ranking: crate::find_tools::Ranking, k: usize) -> crate::find_tools::Ranking { + let mut tools: Vec<_> = ranking + .tools + .into_iter() + .filter(|t| t.score >= MIN_PROBABILITY) + .collect(); + let cap = k.min(MAX_JEV_TOOLS); + if tools.len() > cap { + tools.truncate(cap); + } + crate::find_tools::Ranking { + none: ranking.none, + tools, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::build_catalog; + + #[test] + fn provider_preset_matches_the_gateway_table() { + assert_eq!( + Provider::OpenCode.preset(), + Preset { + endpoint: "https://opencode.ai/zen/v1/systemone", + model: "jev-1.13", + key_var: "OPENCODE_API_KEY" + } + ); + assert_eq!( + Provider::OpenRouter.preset(), + Preset { + endpoint: "https://openrouter.ai/api/v1/systemone", + model: "typesafe/jev-1.13", + key_var: "OPENROUTER_API_KEY" + } + ); + assert_eq!( + Provider::Vercel.preset(), + Preset { + endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone", + model: "typesafe-ai/jev", + key_var: "AI_GATEWAY_API_KEY" + } + ); + assert_eq!( + Provider::TypeSafe.preset(), + Preset { + endpoint: "https://api.typesafe.ai/v1/systemone", + model: "jev-1.13.0", + key_var: "TYPESAFE_API_KEY" + } + ); + } + + #[test] + fn gateway_config_is_none_when_provider_unset() { + let res = gateway_config(|k| { + if k == "OPENCODE_API_KEY" { + Some("x".into()) + } else { + None + } + }); + assert!(matches!(res, Ok(None))); + } + + #[test] + fn gateway_config_uses_preset_for_known_provider() { + let res = gateway_config(|k| match k { + "JEV_PROVIDER" => Some("opencode".into()), + "OPENCODE_API_KEY" => Some("k1".into()), + _ => None, + }) + .unwrap() + .unwrap(); + assert_eq!(res.endpoint, "https://opencode.ai/zen/v1/systemone"); + assert_eq!(res.model, "jev-1.13"); + } + + #[test] + fn gateway_config_overrides_endpoint_and_model() { + let res = gateway_config(|k| match k { + "JEV_PROVIDER" => Some("opencode".into()), + "JEV_ENDPOINT" => Some("https://ex".into()), + "JEV_MODEL" => Some("m2".into()), + "OPENCODE_API_KEY" => Some("k".into()), + _ => None, + }) + .unwrap() + .unwrap(); + assert_eq!(res.endpoint, "https://ex"); + assert_eq!(res.model, "m2"); + } + + #[test] + fn gateway_config_prefers_jev_api_key_over_conventional_var() { + let res = gateway_config(|k| match k { + "JEV_PROVIDER" => Some("opencode".into()), + "JEV_API_KEY" => Some("jevkey".into()), + "OPENCODE_API_KEY" => Some("conv".into()), + _ => None, + }) + .unwrap() + .unwrap(); + assert_eq!(res.api_key.expose(), "jevkey"); + } + + #[test] + fn gateway_config_rejects_unknown_provider() { + let res = gateway_config(|k| { + if k == "JEV_PROVIDER" { + Some("bogus".into()) + } else { + None + } + }); + assert!(matches!(res, Err(ConfigError::UnknownProvider))); + } + + #[test] + fn gateway_config_rejects_missing_key() { + let res = gateway_config(|k| { + if k == "JEV_PROVIDER" { + Some("opencode".into()) + } else { + None + } + }); + assert!(matches!(res, Err(ConfigError::MissingKey))); + } + + #[test] + fn build_request_criteria_equals_catalog_plus_none() { + let cat = build_catalog([("jira_update", "update jira"), ("foo_bar", "foo")]); + let v = build_request("do", &cat, "m"); + let crit = &v["questions"]["tool"]["criteria"]; + let obj = crit.as_object().unwrap(); + assert_eq!(obj.len(), 3); + assert_eq!(obj.get("jira_update").unwrap(), "update jira"); + assert_eq!(obj.get("foo_bar").unwrap(), "foo"); + assert_eq!(obj.get("none").unwrap(), "No listed tool can do this task"); + } + + #[test] + fn parse_ranking_drops_non_catalog_key() { + let cat = build_catalog([("jira_update", "u")]); + let body = + r#"{"answers":{"tool":{"probabilities":{"jira_update":0.8,"none":0.1,"bogus":0.1}}}}"#; + let r = parse_ranking(body, &cat).unwrap(); + assert_eq!(r.tools.len(), 1); + assert_eq!(r.tools[0].name, "jira_update"); + assert_eq!(r.none, 0.1); + } + + #[test] + fn parse_ranking_errors_on_missing_none() { + let cat = build_catalog([("jira_update", "u")]); + let body = r#"{"answers":{"tool":{"probabilities":{"jira_update":0.9}}}}"#; + let e = parse_ranking(body, &cat); + assert!(matches!(e, Err(ClassifyError::MissingAnswer))); + } + + #[test] + fn parse_ranking_errors_on_out_of_range_probability() { + let cat = build_catalog([("jira_update", "u")]); + let body = r#"{"answers":{"tool":{"probabilities":{"jira_update":1.4,"none":0.0}}}}"#; + let e = parse_ranking(body, &cat); + assert!(matches!(e, Err(ClassifyError::MalformedBody))); + } + + #[test] + fn parse_ranking_errors_on_non_numeric_probability() { + let cat = build_catalog([("jira_update", "u")]); + let body = r#"{"answers":{"tool":{"probabilities":{"jira_update":"x","none":0.1}}}}"#; + let e = parse_ranking(body, &cat); + assert!(matches!(e, Err(ClassifyError::MalformedBody))); + } + + #[test] + fn select_drops_below_floor_and_caps_at_max() { + let cat = build_catalog([("a", "s"), ("b", "s"), ("c", "s"), ("d", "s"), ("e", "s")]); + let full = parse_ranking( + r#"{"answers":{"tool":{"probabilities":{"a":0.9,"b":0.06,"c":0.04,"d":0.03,"e":0.01,"none":0.0}}}}"#, + &cat, + ) + .unwrap(); + let sel = select(full, 5); + assert_eq!(sel.tools.len(), 2); + assert_eq!(sel.tools[0].name, "a"); + assert_eq!(sel.tools[1].name, "b"); + } + + #[test] + fn select_returns_empty_list_when_all_below_floor() { + let cat = build_catalog([("a", "s"), ("b", "s")]); + let r = parse_ranking( + r#"{"answers":{"tool":{"probabilities":{"a":0.01,"b":0.01,"none":0.98}}}}"#, + &cat, + ) + .unwrap(); + let sel = select(r, 5); + assert!(sel.tools.is_empty()); + assert_eq!(sel.none, 0.98); + } + + #[test] + fn secret_debug_is_redacted() { + let s = Secret::new("sentinel"); + let d = format!("{:?}", s); + assert!(!d.contains("sentinel")); + assert!(d.contains("[redacted]")); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8331bc9..0441913 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -77,11 +77,17 @@ pub mod agent; pub mod annotations; pub mod atlas; pub mod atlassian; +pub mod catalog; +pub mod find_tools; pub mod hn; pub mod images; +pub mod jev; pub mod linear; pub mod llm_stream; pub mod md; pub mod pagination; +pub mod projection; pub mod queries; +pub mod sandbox; +pub mod ts_decl; pub mod upgrade; diff --git a/crates/core/src/linear/chart.rs b/crates/core/src/linear/chart.rs new file mode 100644 index 0000000..047d808 --- /dev/null +++ b/crates/core/src/linear/chart.rs @@ -0,0 +1,420 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChartNode { + pub identifier: String, + pub title: String, + pub state_type: String, + pub parent: Option, + pub blocked_by: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChartClass { + Complete, + InProgress, + Frontier, + Fog, +} + +impl ChartClass { + fn name(self) -> &'static str { + match self { + ChartClass::Complete => "complete", + ChartClass::InProgress => "inprogress", + ChartClass::Frontier => "frontier", + ChartClass::Fog => "fog", + } + } + + fn legend_id(self) -> &'static str { + match self { + ChartClass::Complete => "LComplete", + ChartClass::InProgress => "LProgress", + ChartClass::Frontier => "LFrontier", + ChartClass::Fog => "LFog", + } + } +} + +const CLASS_ORDER: [ChartClass; 4] = [ + ChartClass::Frontier, + ChartClass::Complete, + ChartClass::InProgress, + ChartClass::Fog, +]; + +const SUBGRAPH_HUES: [&str; 8] = [ + "#8b7bb8", "#2dd4bf", "#f59e0b", "#4ade80", "#60a5fa", "#f472b6", "#a78bfa", "#38bdf8", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChartStats { + pub total: usize, + pub complete: usize, + pub inprogress: usize, + pub frontier: usize, + pub fog: usize, +} + +pub fn classify_nodes(nodes: &[ChartNode]) -> BTreeMap { + let by_id: BTreeMap<&str, &ChartNode> = nodes + .iter() + .map(|node| (node.identifier.as_str(), node)) + .collect(); + let mut classes = BTreeMap::new(); + for node in nodes { + if node.state_type == "canceled" { + continue; + } + let class = match node.state_type.as_str() { + "completed" => ChartClass::Complete, + "started" => ChartClass::InProgress, + _ => { + let blocked = + node.blocked_by + .iter() + .any(|blocker| match by_id.get(blocker.as_str()) { + None => true, + Some(other) if other.state_type == "canceled" => false, + Some(other) => other.state_type != "completed", + }); + if blocked { + ChartClass::Fog + } else { + ChartClass::Frontier + } + } + }; + classes.insert(node.identifier.clone(), class); + } + classes +} + +pub fn chart_stats(nodes: &[ChartNode]) -> ChartStats { + let classes = classify_nodes(nodes); + let mut stats = ChartStats { + total: classes.len(), + complete: 0, + inprogress: 0, + frontier: 0, + fog: 0, + }; + for class in classes.values() { + match class { + ChartClass::Complete => stats.complete += 1, + ChartClass::InProgress => stats.inprogress += 1, + ChartClass::Frontier => stats.frontier += 1, + ChartClass::Fog => stats.fog += 1, + } + } + stats +} + +pub fn stats_line(stats: &ChartStats) -> String { + format!( + "{} issues: {} complete, {} in progress, {} frontier, {} fog", + stats.total, stats.complete, stats.inprogress, stats.frontier, stats.fog + ) +} + +pub fn build_mermaid(nodes: &[ChartNode]) -> String { + let classes = classify_nodes(nodes); + let rendered: Vec<&ChartNode> = nodes + .iter() + .filter(|node| node.state_type != "canceled") + .collect(); + let rendered_ids: HashSet<&str> = rendered + .iter() + .map(|node| node.identifier.as_str()) + .collect(); + + let mut children: BTreeMap<&str, Vec<&ChartNode>> = BTreeMap::new(); + for node in &rendered { + if let Some(parent) = node.parent.as_deref() { + if rendered_ids.contains(parent) { + children.entry(parent).or_default().push(node); + } + } + } + let containers: HashSet<&str> = children.keys().copied().collect(); + let top_level: Vec<&ChartNode> = rendered + .iter() + .copied() + .filter(|node| { + !node + .parent + .as_deref() + .is_some_and(|parent| rendered_ids.contains(parent)) + }) + .collect(); + + let mut sorted: Vec<&ChartNode> = rendered.clone(); + sorted.sort_by_key(|node| (issue_number(&node.identifier), node.identifier.as_str())); + + let mut out = String::from("flowchart TB\n"); + emit_scope(&top_level, &children, &containers, 1, &mut out); + + let mut edges: BTreeSet<(String, String)> = BTreeSet::new(); + for node in &rendered { + for blocker in &node.blocked_by { + if !rendered_ids.contains(blocker.as_str()) { + continue; + } + edges.insert(( + target_ref(blocker, &containers), + target_ref(&node.identifier, &containers), + )); + } + } + for (from, to) in &edges { + out.push_str(&format!(" {from} --> {to}\n")); + } + + let mut styled: Vec<&str> = containers.iter().copied().collect(); + styled.sort_by_key(|identifier| (issue_number(identifier), *identifier)); + for (index, identifier) in styled.iter().enumerate() { + let hue = SUBGRAPH_HUES[index % SUBGRAPH_HUES.len()]; + out.push_str(&format!( + " style {} fill:#12141d,stroke:{},stroke-width:1.5px\n", + subgraph_id(identifier), + hue + )); + } + + out.push_str(" subgraph Legend[\"State legend\"]\n"); + out.push_str(" LFrontier[\"Frontier: ready to start\"]\n"); + out.push_str(" LComplete[\"Complete\"]\n"); + out.push_str(" LProgress[\"In progress\"]\n"); + out.push_str(" LFog[\"Fog: prerequisite incomplete\"]\n"); + out.push_str(" end\n"); + out.push_str(" style Legend fill:#12141d,stroke:#f59e0b,stroke-width:1.5px\n"); + out.push_str( + " classDef complete fill:#dcfce7,stroke:#15803d,color:#14532d,stroke-width:2px\n", + ); + out.push_str( + " classDef inprogress fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:4px\n", + ); + out.push_str( + " classDef frontier fill:#fef3c7,stroke:#b45309,color:#78350f,stroke-width:3px\n", + ); + out.push_str(" classDef fog fill:#f1f5f9,stroke:#64748b,color:#334155,stroke-dasharray:5 5\n"); + + for class in CLASS_ORDER { + let members: Vec = sorted + .iter() + .filter(|node| { + !containers.contains(node.identifier.as_str()) + && classes.get(node.identifier.as_str()) == Some(&class) + }) + .map(|node| node_id(&node.identifier)) + .collect(); + if members.is_empty() { + continue; + } + out.push_str(&format!( + " class {},{} {}\n", + class.legend_id(), + members.join(","), + class.name() + )); + } + out +} + +fn emit_scope<'a>( + items: &[&'a ChartNode], + children: &BTreeMap<&'a str, Vec<&'a ChartNode>>, + containers: &HashSet<&'a str>, + depth: usize, + out: &mut String, +) { + let mut sorted: Vec<&&ChartNode> = items.iter().collect(); + sorted.sort_by_key(|node| (issue_number(&node.identifier), node.identifier.as_str())); + for node in sorted { + let indent = " ".repeat(depth); + let identifier = node.identifier.as_str(); + if containers.contains(identifier) { + out.push_str(&format!( + "{}subgraph {}[\"{}\"]\n", + indent, + subgraph_id(identifier), + label(identifier, &node.title) + )); + let kids: Vec<&ChartNode> = children.get(identifier).cloned().unwrap_or_default(); + emit_scope(&kids, children, containers, depth + 1, out); + out.push_str(&format!("{}end\n", indent)); + } else { + out.push_str(&format!( + "{}{}[\"{}\"]\n", + indent, + node_id(identifier), + label(identifier, &node.title) + )); + } + } +} + +fn target_ref(identifier: &str, containers: &HashSet<&str>) -> String { + match containers.contains(identifier) { + true => subgraph_id(identifier), + false => node_id(identifier), + } +} + +fn node_id(identifier: &str) -> String { + identifier + .chars() + .map(|c| match c.is_ascii_alphanumeric() { + true => c, + false => '_', + }) + .collect() +} + +fn subgraph_id(identifier: &str) -> String { + let tail = identifier.rsplit('-').next().unwrap_or(""); + match !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) { + true => format!("E{tail}"), + false => format!("E{}", node_id(identifier)), + } +} + +fn issue_number(identifier: &str) -> u32 { + identifier + .rsplit('-') + .next() + .and_then(|tail| tail.parse::().ok()) + .unwrap_or(0) +} + +fn label(identifier: &str, title: &str) -> String { + format!("{identifier}: {title}") + .replace('"', "#quot;") + .replace(['[', ']'], "") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(id: &str, state: &str, parent: Option<&str>, blocked_by: &[&str]) -> ChartNode { + ChartNode { + identifier: id.to_string(), + title: format!("Title {id}"), + state_type: state.to_string(), + parent: parent.map(str::to_string), + blocked_by: blocked_by.iter().map(|item| item.to_string()).collect(), + } + } + + #[test] + fn classification_covers_every_state() { + let nodes = vec![ + node("GUZ-1", "completed", None, &[]), + node("GUZ-2", "started", None, &[]), + node("GUZ-3", "backlog", None, &[]), + node("GUZ-4", "unstarted", None, &["GUZ-3"]), + node("GUZ-5", "unstarted", None, &["GUZ-6"]), + node("GUZ-6", "completed", None, &[]), + node("GUZ-7", "backlog", None, &["GUZ-99"]), + ]; + let classes = classify_nodes(&nodes); + assert_eq!(classes["GUZ-1"], ChartClass::Complete); + assert_eq!(classes["GUZ-2"], ChartClass::InProgress); + assert_eq!(classes["GUZ-3"], ChartClass::Frontier); + assert_eq!(classes["GUZ-4"], ChartClass::Fog); + assert_eq!(classes["GUZ-5"], ChartClass::Frontier); + assert_eq!(classes["GUZ-7"], ChartClass::Fog); + let stats = chart_stats(&nodes); + assert_eq!(stats.total, 7); + assert_eq!(stats.complete, 2); + assert_eq!(stats.inprogress, 1); + assert_eq!(stats.frontier, 2); + assert_eq!(stats.fog, 2); + } + + #[test] + fn canceled_blocker_is_dropped_and_satisfies_dependents() { + let nodes = vec![ + node("GUZ-8", "canceled", None, &[]), + node("GUZ-9", "unstarted", None, &["GUZ-8"]), + ]; + let classes = classify_nodes(&nodes); + assert!(!classes.contains_key("GUZ-8")); + assert_eq!(classes["GUZ-9"], ChartClass::Frontier); + let stats = chart_stats(&nodes); + assert_eq!(stats.total, 1); + let mermaid = build_mermaid(&nodes); + assert!(!mermaid.contains("GUZ_8")); + assert!(!mermaid.contains("-->")); + } + + #[test] + fn mermaid_builder_emits_template_shape() { + let nodes = vec![ + node("GUZ-185", "started", None, &[]), + node("GUZ-191", "started", Some("GUZ-185"), &[]), + node("GUZ-192", "backlog", Some("GUZ-185"), &[]), + node("GUZ-200", "backlog", None, &["GUZ-191"]), + ]; + let mut quoted = nodes[1].clone(); + quoted.title = "Data \"model\" [v2]".to_string(); + let mut input = nodes.clone(); + input[1] = quoted.clone(); + + let mermaid = build_mermaid(&input); + assert!(mermaid.starts_with("flowchart TB\n")); + assert!(mermaid.contains("subgraph E185[\"GUZ-185: Title GUZ-185\"]\n")); + assert!(mermaid.contains("GUZ_191[\"GUZ-191: Data #quot;model#quot; v2\"]\n")); + assert!(mermaid.contains("GUZ_192[\"GUZ-192: Title GUZ-192\"]\n")); + assert!(mermaid.contains("GUZ_200[\"GUZ-200: Title GUZ-200\"]\n")); + assert!(mermaid.contains(" GUZ_191 --> GUZ_200\n")); + assert!(!mermaid.contains("GUZ_200 --> GUZ_191")); + assert!(!mermaid.contains("GUZ_185 -->")); + assert!(mermaid.contains("subgraph Legend[\"State legend\"]\n")); + assert!(mermaid.contains( + "classDef complete fill:#dcfce7,stroke:#15803d,color:#14532d,stroke-width:2px" + )); + assert!(mermaid.contains( + "classDef inprogress fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:4px" + )); + assert!(mermaid.contains( + "classDef frontier fill:#fef3c7,stroke:#b45309,color:#78350f,stroke-width:3px" + )); + assert!(mermaid.contains( + "classDef fog fill:#f1f5f9,stroke:#64748b,color:#334155,stroke-dasharray:5 5" + )); + assert!(!mermaid.contains("class LComplete")); + assert!(mermaid.contains(" class LProgress,GUZ_191 inprogress\n")); + assert!(mermaid.contains(" class LFrontier,GUZ_192 frontier\n")); + assert!(mermaid.contains(" class LFog,GUZ_200 fog\n")); + assert!(mermaid.contains(" style E185 fill:#12141d,stroke:#8b7bb8,stroke-width:1.5px\n")); + assert!(mermaid.contains(" style Legend fill:#12141d,stroke:#f59e0b,stroke-width:1.5px\n")); + let frontier = mermaid.find("LFrontier[\"").unwrap(); + let complete = mermaid.find("LComplete[\"").unwrap(); + let progress = mermaid.find("LProgress[\"").unwrap(); + let fog = mermaid.find("LFog[\"").unwrap(); + assert!(frontier < complete && complete < progress && progress < fog); + + assert_eq!(mermaid, build_mermaid(&input)); + let mut reversed = input.clone(); + reversed.reverse(); + assert_eq!(mermaid, build_mermaid(&reversed)); + } + + #[test] + fn subgraph_hues_cycle_by_sorted_epic_number() { + let nodes = vec![ + node("GUZ-300", "started", None, &[]), + node("GUZ-301", "started", Some("GUZ-300"), &[]), + node("GUZ-302", "backlog", Some("GUZ-301"), &[]), + node("GUZ-100", "started", None, &[]), + node("GUZ-101", "started", Some("GUZ-100"), &[]), + ]; + let mermaid = build_mermaid(&nodes); + assert!(mermaid.contains("style E100 fill:#12141d,stroke:#8b7bb8,stroke-width:1.5px")); + assert!(mermaid.contains("style E300 fill:#12141d,stroke:#2dd4bf,stroke-width:1.5px")); + assert!(mermaid.contains("style E301 fill:#12141d,stroke:#f59e0b,stroke-width:1.5px")); + } +} diff --git a/crates/core/src/linear/mod.rs b/crates/core/src/linear/mod.rs index 71ebc16..1b9c0fb 100644 --- a/crates/core/src/linear/mod.rs +++ b/crates/core/src/linear/mod.rs @@ -1,6 +1,8 @@ +pub mod chart; pub mod resolve; pub mod retry; pub mod types; +pub use chart::*; pub use resolve::*; pub use retry::*; pub use types::*; diff --git a/crates/core/src/projection.rs b/crates/core/src/projection.rs new file mode 100644 index 0000000..abfb1e9 --- /dev/null +++ b/crates/core/src/projection.rs @@ -0,0 +1,182 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionError { + pub path: String, +} + +pub fn project( + value: serde_json::Value, + paths: &[String], +) -> Result { + if paths.is_empty() { + return Ok(value); + } + let mut out = serde_json::Value::Null; + for path in paths { + let segments: Vec<&str> = path.split('.').collect(); + let mut leaves = 0usize; + insert(&mut out, &value, &segments, &mut leaves); + if leaves == 0 { + return Err(ProjectionError { path: path.clone() }); + } + } + Ok(out) +} + +fn resolves(value: &serde_json::Value, segments: &[&str]) -> bool { + match (value, segments.split_first()) { + (_, None) => true, + (serde_json::Value::Object(map), Some((head, rest))) => { + map.get(*head).is_some_and(|child| resolves(child, rest)) + } + (serde_json::Value::Array(items), Some(_)) => { + items.iter().any(|item| resolves(item, segments)) + } + _ => false, + } +} + +fn insert( + out: &mut serde_json::Value, + value: &serde_json::Value, + segments: &[&str], + leaves: &mut usize, +) { + let Some((head, rest)) = segments.split_first() else { + *out = value.clone(); + *leaves += 1; + return; + }; + match value { + serde_json::Value::Object(map) => { + let Some(child) = map.get(*head) else { + return; + }; + if !resolves(child, rest) { + return; + } + if !out.is_object() { + *out = serde_json::Value::Object(serde_json::Map::new()); + } + let Some(sinks) = out.as_object_mut() else { + return; + }; + let sink = sinks + .entry((*head).to_string()) + .or_insert(serde_json::Value::Null); + insert(sink, child, rest, leaves); + } + serde_json::Value::Array(items) => { + if !out.is_array() { + *out = serde_json::Value::Array(Vec::new()); + } + let Some(sinks) = out.as_array_mut() else { + return; + }; + sinks.resize( + items.len(), + serde_json::Value::Object(serde_json::Map::new()), + ); + for (sink, item) in sinks.iter_mut().zip(items) { + insert(sink, item, segments, leaves); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn projected( + value: serde_json::Value, + paths: &[&str], + ) -> Result { + let paths: Vec = paths.iter().map(|p| p.to_string()).collect(); + project(value, &paths) + } + + #[test] + fn flat_select_keeps_only_named_keys() { + assert_eq!( + projected(json!({"a": 1, "b": 2}), &["a"]), + Ok(json!({"a": 1})) + ); + } + + #[test] + fn union_keeps_every_named_key() { + assert_eq!( + projected(json!({"a": 1, "b": 2}), &["a", "b"]), + Ok(json!({"a": 1, "b": 2})) + ); + } + + #[test] + fn ancestors_kept_and_siblings_dropped() { + let value = json!({ + "nodes": [{"title": "t", "body": "x"}], + "pageInfo": {"endCursor": "e", "hasNextPage": false} + }); + assert_eq!( + projected(value, &["nodes.title"]), + Ok(json!({"nodes": [{"title": "t"}]})) + ); + } + + #[test] + fn array_element_missing_the_key_contributes_empty_object() { + let value = json!({"nodes": [{"title": "t"}, {"body": "x"}]}); + assert_eq!( + projected(value, &["nodes.title"]), + Ok(json!({"nodes": [{"title": "t"}, {}]})) + ); + } + + #[test] + fn wider_path_wins() { + let value = json!({"nodes": [{"title": "t", "body": "x"}]}); + assert_eq!( + projected(value, &["nodes", "nodes.title"]), + Ok(json!({"nodes": [{"title": "t", "body": "x"}]})) + ); + } + + #[test] + fn top_level_path_resolving_nowhere_errors() { + assert_eq!( + projected(json!({"a": 1}), &["bogus"]), + Err(ProjectionError { + path: "bogus".to_string() + }) + ); + } + + #[test] + fn path_under_array_resolving_nowhere_errors() { + let value = json!({"nodes": [{"title": "t"}]}); + assert_eq!( + projected(value, &["nodes.nope"]), + Err(ProjectionError { + path: "nodes.nope".to_string() + }) + ); + } + + #[test] + fn empty_segment_errors() { + assert_eq!( + projected(json!({"a": {"b": 1}}), &["a..b"]), + Err(ProjectionError { + path: "a..b".to_string() + }) + ); + } + + #[test] + fn no_paths_returns_value_unchanged() { + let value = json!({"a": 1, "nested": {"b": [1, 2]}}); + assert_eq!(project(value.clone(), &[]), Ok(value)); + } +} diff --git a/crates/core/src/sandbox.rs b/crates/core/src/sandbox.rs new file mode 100644 index 0000000..ce46858 --- /dev/null +++ b/crates/core/src/sandbox.rs @@ -0,0 +1,261 @@ +use std::time::Duration; + +pub struct Limits { + pub timeout: Duration, + pub memory_bytes: usize, + pub output_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + timeout: Duration::from_secs(30), + memory_bytes: 64 * 1024 * 1024, + output_bytes: 256 * 1024, + } + } +} + +#[derive(Debug)] +pub struct LogBuffer { + lines: Vec, + bytes: usize, + cap: usize, +} + +impl LogBuffer { + pub fn new(cap: usize) -> Self { + Self { + lines: Vec::new(), + bytes: 0, + cap, + } + } + + pub fn push(&mut self, line: String) -> Result<(), OutputLimit> { + if self.bytes + line.len() > self.cap { + return Err(OutputLimit(self.cap)); + } + self.bytes += line.len(); + self.lines.push(line); + Ok(()) + } + + pub fn into_lines(self) -> Vec { + self.lines + } +} + +#[derive(Debug, PartialEq)] +pub struct OutputLimit(pub usize); + +pub struct Run { + pub logs: Vec, + pub outcome: Result, +} + +#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct ExecuteOutput { + pub logs: Vec, + pub result: serde_json::Value, + pub error: Option, +} + +#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct ExecuteError { + pub message: String, + pub name: String, +} + +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum SandboxError { + #[error("execution timed out after {0:?}")] + Timeout(Duration), + #[error("output limit exceeded: {0} bytes")] + OutputLimit(usize), + #[error("{name}: {message}")] + Js { name: String, message: String }, +} + +impl From for SandboxError { + fn from(OutputLimit(cap): OutputLimit) -> Self { + Self::OutputLimit(cap) + } +} + +pub fn check_size( + logs: &[String], + result: &serde_json::Value, + cap: usize, +) -> Result<(), OutputLimit> { + let log_bytes: usize = logs.iter().map(String::len).sum(); + if log_bytes + result.to_string().len() > cap { + Err(OutputLimit(cap)) + } else { + Ok(()) + } +} + +pub fn execute_output(run: Run) -> ExecuteOutput { + let Run { logs, outcome } = run; + match outcome { + Ok(result) => ExecuteOutput { + logs, + result, + error: None, + }, + Err(err) => { + let error = match &err { + SandboxError::Timeout(_) => ExecuteError { + name: "TimeoutError".to_string(), + message: err.to_string(), + }, + SandboxError::OutputLimit(_) => ExecuteError { + name: "OutputLimitError".to_string(), + message: err.to_string(), + }, + SandboxError::Js { name, message } => ExecuteError { + name: name.clone(), + message: message.clone(), + }, + }; + ExecuteOutput { + logs, + result: serde_json::Value::Null, + error: Some(error), + } + } + } +} + +pub fn tool_result(envelope: serde_json::Value) -> serde_json::Value { + envelope + .get("structuredContent") + .cloned() + .unwrap_or(envelope) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_at_cap_is_accepted() { + let mut buffer = LogBuffer::new(4); + assert!(buffer.push("abcd".to_string()).is_ok()); + } + + #[test] + fn push_over_cap_is_rejected() { + let mut buffer = LogBuffer::new(4); + assert_eq!(buffer.push("abcde".to_string()), Err(OutputLimit(4))); + } + + #[test] + fn check_size_boundary() { + let at_logs = vec!["xx".to_string()]; + assert_eq!(check_size(&at_logs, &serde_json::json!(""), 4), Ok(())); + let over_logs = vec!["xxx".to_string()]; + assert_eq!( + check_size(&over_logs, &serde_json::json!(""), 4), + Err(OutputLimit(4)) + ); + } + + #[test] + fn execute_output_ok_passes_logs_through() { + let output = execute_output(Run { + logs: vec!["a".to_string()], + outcome: Ok(serde_json::json!(2)), + }); + assert_eq!( + output, + ExecuteOutput { + logs: vec!["a".to_string()], + result: serde_json::json!(2), + error: None, + } + ); + } + + #[test] + fn execute_output_timeout_names_timeout_error() { + let err = SandboxError::Timeout(Duration::from_secs(1)); + let message = err.to_string(); + let output = execute_output(Run { + logs: vec!["a".to_string()], + outcome: Err(err), + }); + assert_eq!( + output, + ExecuteOutput { + logs: vec!["a".to_string()], + result: serde_json::Value::Null, + error: Some(ExecuteError { + name: "TimeoutError".to_string(), + message, + }), + } + ); + } + + #[test] + fn execute_output_limit_names_output_limit_error() { + let err = SandboxError::OutputLimit(4); + let message = err.to_string(); + let output = execute_output(Run { + logs: vec!["a".to_string()], + outcome: Err(err), + }); + assert_eq!( + output, + ExecuteOutput { + logs: vec!["a".to_string()], + result: serde_json::Value::Null, + error: Some(ExecuteError { + name: "OutputLimitError".to_string(), + message, + }), + } + ); + } + + #[test] + fn execute_output_js_passes_name_and_message_through() { + let output = execute_output(Run { + logs: vec!["a".to_string()], + outcome: Err(SandboxError::Js { + name: "TypeError".to_string(), + message: "x".to_string(), + }), + }); + assert_eq!( + output, + ExecuteOutput { + logs: vec!["a".to_string()], + result: serde_json::Value::Null, + error: Some(ExecuteError { + name: "TypeError".to_string(), + message: "x".to_string(), + }), + } + ); + } + + #[test] + fn tool_result_prefers_structured_content() { + let envelope = serde_json::json!({ + "content": [{"type": "text", "text": "{}"}], + "structuredContent": {"tools": []}, + }); + assert_eq!(tool_result(envelope), serde_json::json!({"tools": []})); + } + + #[test] + fn tool_result_falls_back_to_envelope() { + let envelope = serde_json::json!({ + "content": [{"type": "text", "text": "{}"}], + }); + assert_eq!(tool_result(envelope.clone()), envelope); + } +} diff --git a/crates/core/src/ts_decl.rs b/crates/core/src/ts_decl.rs new file mode 100644 index 0000000..d482f4a --- /dev/null +++ b/crates/core/src/ts_decl.rs @@ -0,0 +1,524 @@ +use serde_json::Value; + +pub fn tool_declaration( + name: &str, + description: &str, + input_schema: &Value, + output_schema: &Value, +) -> String { + let prefix = pascal(name); + let mut blocks = vec![render_interface( + &format!("{prefix}Input"), + input_schema, + &prefix, + true, + )]; + for (def_name, def_schema) in def_entries(output_schema) { + blocks.push(render_definition(&prefix, def_name, def_schema)); + } + for (def_name, def_schema) in def_entries(input_schema) { + blocks.push(render_definition(&prefix, def_name, def_schema)); + } + blocks.push(render_interface( + &format!("{prefix}Output"), + output_schema, + &prefix, + false, + )); + let mut text = blocks.join("\n\n"); + text.push_str("\n\n"); + if let Some(doc) = jsdoc(unless_empty(description), None, 0) { + text.push_str(&doc); + text.push('\n'); + } + text.push_str(&format!( + "declare function {name}(input: {prefix}Input): Promise<{prefix}Output>;\n" + )); + text +} + +fn unless_empty(text: &str) -> Option<&str> { + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn pascal(name: &str) -> String { + name.split('_') + .map(|piece| { + let mut chars = piece.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect() +} + +fn def_entries(schema: &Value) -> Vec<(&str, &Value)> { + schema + .get("$defs") + .and_then(Value::as_object) + .map(|defs| { + defs.iter() + .map(|(name, def)| (name.as_str(), def)) + .collect() + }) + .unwrap_or_default() +} + +fn render_definition(prefix: &str, name: &str, schema: &Value) -> String { + let title = format!("{prefix}{name}"); + if is_object(schema) { + render_interface(&title, schema, prefix, false) + } else { + format!("type {title} = {};", ts_type(schema, prefix)) + } +} + +fn is_object(schema: &Value) -> bool { + schema.get("type").and_then(Value::as_str) == Some("object") + || schema + .get("properties") + .and_then(Value::as_object) + .is_some() +} + +fn render_interface(title: &str, schema: &Value, prefix: &str, input: bool) -> String { + let mut lines = vec![format!("interface {title} {{")]; + lines.extend(member_lines(schema, prefix, 1, input)); + lines.push("}".to_string()); + lines.join("\n") +} + +fn member_lines(schema: &Value, prefix: &str, level: usize, input: bool) -> Vec { + let pad = " ".repeat(level); + let required = required_of(schema); + match schema.get("properties").and_then(Value::as_object) { + None => vec![], + Some(props) => props + .iter() + .flat_map(|(name, prop)| { + member_block(name, prop, prefix, &pad, level, input, &required) + }) + .collect(), + } +} + +fn member_block( + name: &str, + prop: &Value, + prefix: &str, + pad: &str, + level: usize, + input: bool, + required: &[&str], +) -> Vec { + let mut lines = vec![]; + let (text, default) = if input { + input_doc(prop) + } else { + (prop_description(prop), None) + }; + if let Some(doc) = jsdoc(text.as_deref(), default.as_ref(), pad.len()) { + lines.extend(doc.split('\n').map(str::to_string)); + } + let marker = if required.contains(&name) { "" } else { "?" }; + let rendered = ts_type_at(prop, prefix, level); + let mut chunks = rendered.split('\n'); + let first = chunks.next().unwrap_or("unknown"); + lines.push(format!("{pad}{name}{marker}: {first}")); + let rest: Vec<&str> = chunks.collect(); + if let Some((last, middle)) = rest.split_last() { + for chunk in middle { + lines.push(chunk.to_string()); + } + lines.push(format!("{last};")); + } else { + let tail = lines.len() - 1; + lines[tail].push(';'); + } + lines +} + +fn required_of(schema: &Value) -> Vec<&str> { + schema + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default() +} + +fn prop_description(prop: &Value) -> Option { + prop.get("description") + .and_then(Value::as_str) + .map(str::to_string) +} + +fn input_doc(prop: &Value) -> (Option, Option) { + let mut text = prop_description(prop); + let mut default = prop.get("default").cloned(); + if default.is_none() { + if let Some(current) = text.clone() { + let (head, suffix) = split_default_suffix(¤t); + if let Some(value) = suffix { + text = Some(head.to_string()); + default = Some(value); + } + } + } + (text, default) +} + +fn split_default_suffix(text: &str) -> (&str, Option) { + let Some(rest) = text.strip_suffix(')') else { + return (text, None); + }; + let Some(start) = rest.rfind("(default:") else { + return (text, None); + }; + let head = &rest[..start]; + if !head.is_empty() && !head.ends_with(' ') { + return (text, None); + } + let raw = rest[start + "(default:".len()..].trim(); + if raw.is_empty() || raw.contains('(') || raw.contains(')') { + return (text, None); + } + let value = serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())); + (head.trim_end(), Some(value)) +} + +fn jsdoc(text: Option<&str>, default: Option<&Value>, indent: usize) -> Option { + let pad = " ".repeat(indent); + let mut lines: Vec = vec![]; + if let Some(current) = text { + for line in current.split('\n') { + let clean = line.replace("*/", "*\\/"); + if !clean.trim().is_empty() { + lines.push(clean); + } + } + } + match (lines.len(), default) { + (0, None) => None, + (0, Some(value)) => Some(format!("{pad}/** @default {} */", compact(value))), + (1, None) => Some(format!("{pad}/** {} */", lines[0])), + (1, Some(value)) => Some(format!( + "{pad}/** {} @default {} */", + lines[0], + compact(value) + )), + (_, fallback) => { + let mut out = vec![format!("{pad}/**")]; + for line in &lines { + out.push(format!("{pad} * {line}")); + } + if let Some(value) = fallback { + out.push(format!("{pad} * @default {}", compact(value))); + } + out.push(format!("{pad} */")); + Some(out.join("\n")) + } + } +} + +fn compact(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) +} + +fn ts_type(schema: &Value, prefix: &str) -> String { + ts_type_at(schema, prefix, 0) +} + +fn ts_type_at(schema: &Value, prefix: &str, level: usize) -> String { + let Some(obj) = schema.as_object() else { + return "unknown".to_string(); + }; + if obj.contains_key("$ref") { + return match obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|target| target.strip_prefix("#/$defs/")) + { + Some(name) if !name.is_empty() && !name.contains('/') => { + format!("{prefix}{name}") + } + _ => "unknown".to_string(), + }; + } + if let Some(variants) = obj.get("enum").and_then(Value::as_array) { + if variants.is_empty() { + return "unknown".to_string(); + } + return variants.iter().map(compact).collect::>().join(" | "); + } + if let Some(members) = obj.get("anyOf").and_then(Value::as_array) { + if members.is_empty() { + return "unknown".to_string(); + } + return members + .iter() + .map(|member| ts_type_at(member, prefix, level)) + .collect::>() + .join(" | "); + } + if let Some(items) = obj.get("prefixItems").and_then(Value::as_array) { + let parts = items + .iter() + .map(|item| ts_type_at(item, prefix, level)) + .collect::>(); + return format!("[{}]", parts.join(", ")); + } + match obj.get("type") { + Some(Value::String(name)) => match name.as_str() { + "string" => "string".to_string(), + "boolean" => "boolean".to_string(), + "integer" | "number" => "number".to_string(), + "null" => "null".to_string(), + "array" => array_type(obj, prefix, level), + "object" => match obj.get("properties").and_then(Value::as_object) { + Some(_) => inline_object(schema, prefix, level), + None => "unknown".to_string(), + }, + _ => "unknown".to_string(), + }, + Some(Value::Array(names)) => { + if names.is_empty() { + return "unknown".to_string(); + } + names + .iter() + .map(|name| match name.as_str().and_then(scalar) { + Some(found) => found.to_string(), + None => "unknown".to_string(), + }) + .collect::>() + .join(" | ") + } + _ => match obj.get("properties").and_then(Value::as_object) { + Some(_) => inline_object(schema, prefix, level), + None => "unknown".to_string(), + }, + } +} + +fn scalar(name: &str) -> Option<&str> { + match name { + "string" => Some("string"), + "boolean" => Some("boolean"), + "integer" | "number" => Some("number"), + "null" => Some("null"), + _ => None, + } +} + +fn array_type(obj: &serde_json::Map, prefix: &str, level: usize) -> String { + let inner = obj + .get("items") + .map(|items| ts_type_at(items, prefix, level)) + .unwrap_or_else(|| "unknown".to_string()); + if !inner.contains('\n') && inner.contains(" | ") { + format!("({inner})[]") + } else { + format!("{inner}[]") + } +} + +fn inline_object(schema: &Value, prefix: &str, level: usize) -> String { + let mut lines = vec!["{".to_string()]; + lines.extend(member_lines(schema, prefix, level + 1, false)); + lines.push(format!("{}}}", " ".repeat(level))); + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn ty(schema: Value) -> String { + ts_type(&schema, "Pfx") + } + + #[test] + fn scalar_types_map() { + assert_eq!(ty(json!({"type": "string"})), "string"); + assert_eq!(ty(json!({"type": "boolean"})), "boolean"); + assert_eq!(ty(json!({"type": "integer"})), "number"); + assert_eq!(ty(json!({"type": "number"})), "number"); + assert_eq!(ty(json!({"type": "null"})), "null"); + } + + #[test] + fn type_array_becomes_union() { + assert_eq!(ty(json!({"type": ["string", "null"]})), "string | null"); + } + + #[test] + fn enum_becomes_string_literal_union() { + assert_eq!( + ty(json!({"type": "string", "enum": ["Jpeg", "Png"]})), + "\"Jpeg\" | \"Png\"" + ); + } + + #[test] + fn ref_uses_prefix() { + assert_eq!(ty(json!({"$ref": "#/$defs/Issue"})), "PfxIssue"); + } + + #[test] + fn items_become_array() { + assert_eq!( + ty(json!({"type": "array", "items": {"$ref": "#/$defs/Issue"}})), + "PfxIssue[]" + ); + } + + #[test] + fn union_items_get_parentheses() { + assert_eq!( + ty(json!({"type": "array", "items": {"type": ["string", "null"]}})), + "(string | null)[]" + ); + } + + #[test] + fn inline_properties_become_object() { + assert_eq!( + ty(json!({"type": "object", "properties": {"a": {"type": "string"}}})), + "{\n a?: string;\n}" + ); + } + + #[test] + fn anyof_becomes_union() { + assert_eq!( + ty(json!({"anyOf": [{"$ref": "#/$defs/Usage"}, {"type": "null"}]})), + "PfxUsage | null" + ); + assert_eq!( + ty(json!({"anyOf": [{"type": "string"}, {"type": "integer"}]})), + "string | number" + ); + } + + #[test] + fn prefix_items_become_tuple() { + assert_eq!( + ty(json!({"type": "array", "prefixItems": [{"type": "integer"}, {"type": "integer"}]})), + "[number, number]" + ); + } + + #[test] + fn boolean_schema_stays_unknown() { + assert_eq!(ty(json!(true)), "unknown"); + assert_eq!(ty(json!(false)), "unknown"); + } + + #[test] + fn self_referential_def_renders() { + let text = tool_declaration( + "demo_tool", + "Does things.", + &json!({"type": "object", "properties": {}}), + &json!({ + "type": "object", + "properties": {"sections": {"type": "array", "items": {"$ref": "#/$defs/Section"}}}, + "required": ["sections"], + "$defs": {"Section": { + "type": "object", + "properties": {"children": {"type": "array", "items": {"$ref": "#/$defs/Section"}}}, + "required": ["children"] + }} + }), + ); + assert!(text.contains("interface DemoToolSection {")); + assert!(text.contains("children: DemoToolSection[];")); + } + + #[test] + fn unknown_fallback() { + assert_eq!(ty(json!({"type": "object"})), "unknown"); + assert_eq!(ty(json!({"$ref": "#/other/X"})), "unknown"); + assert_eq!(ty(json!({})), "unknown"); + } + + #[test] + fn jsdoc_forms() { + assert_eq!(jsdoc(Some("hi"), None, 2).as_deref(), Some(" /** hi */")); + assert_eq!( + jsdoc(Some("Max items per page"), Some(&json!(25)), 2).as_deref(), + Some(" /** Max items per page @default 25 */") + ); + assert_eq!( + jsdoc(None, Some(&json!(25)), 0).as_deref(), + Some("/** @default 25 */") + ); + assert_eq!( + jsdoc(Some("a\nb"), None, 0).as_deref(), + Some("/**\n * a\n * b\n */") + ); + assert_eq!( + jsdoc(Some("a */ b"), None, 0).as_deref(), + Some("/** a *\\/ b */") + ); + assert_eq!(jsdoc(None, None, 0), None); + } + + #[test] + fn optional_marker_follows_required() { + let text = tool_declaration( + "demo_tool", + "Does things.", + &json!({"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}), + &json!({"type": "object", "properties": {"n": {"type": "integer"}}}), + ); + assert!(text.contains(" id: string;\n")); + assert!(text.contains(" n?: number;\n")); + } + + #[test] + fn defs_get_tool_prefix() { + let text = tool_declaration( + "demo_tool", + "Does things.", + &json!({"type": "object", "properties": {}}), + &json!({ + "type": "object", + "properties": {"item": {"$ref": "#/$defs/Item"}}, + "required": ["item"], + "$defs": {"Item": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}} + }), + ); + assert!(text.contains("interface DemoToolItem {")); + assert!(text.contains(" item: DemoToolItem;")); + } + + #[test] + fn default_suffix_moves_to_at_default() { + let (text, default) = input_doc( + &json!({"type": "integer", "description": "Max items per page (default: 25)"}), + ); + assert_eq!(text.as_deref(), Some("Max items per page")); + assert_eq!(default, Some(json!(25))); + } + + #[test] + fn empty_input_interface_still_emitted() { + let text = tool_declaration( + "demo_tool", + "Does things.", + &json!({"type": "object"}), + &json!({"type": "object", "properties": {}}), + ); + assert!(text.contains("interface DemoToolInput {\n}\n")); + assert!(text.contains( + "declare function demo_tool(input: DemoToolInput): Promise;\n" + )); + } +} diff --git a/crates/mcptools/Cargo.toml b/crates/mcptools/Cargo.toml index 0dbac47..f14c8ef 100644 --- a/crates/mcptools/Cargo.toml +++ b/crates/mcptools/Cargo.toml @@ -37,6 +37,7 @@ base64 = { workspace = true } urlencoding = { workspace = true } indicatif = { workspace = true } rand = { workspace = true } +rquickjs = { workspace = true } rusqlite = { workspace = true } tree-sitter = { workspace = true } @@ -50,5 +51,9 @@ ignore = { workspace = true } terminal_size = { workspace = true } tempfile = "3.8" +[dev-dependencies] +jsonschema = { workspace = true } +wiremock = "0.6" + [package.metadata.cargo-machete] ignored = [] diff --git a/crates/mcptools/src/atlassian/jira/mod.rs b/crates/mcptools/src/atlassian/jira/mod.rs index c9e5da6..7655a67 100644 --- a/crates/mcptools/src/atlassian/jira/mod.rs +++ b/crates/mcptools/src/atlassian/jira/mod.rs @@ -213,47 +213,15 @@ fn display_ticket(ticket: &TicketOutput) { if !ticket.comments.is_empty() { std::println!("\n{}", "Comments:".bold().cyan()); for (index, comment) in ticket.comments.iter().enumerate() { - let content = match &comment.body { - serde_json::Value::Object(map) => { - let mut full_text = String::new(); - - if let Some(content_arr) = map.get("content").and_then(|c| c.as_array()) { - for content_item in content_arr { - if let Some(paragraph_content) = - content_item.get("content").and_then(|c| c.as_array()) - { - for text_item in paragraph_content { - if let Some(text) = - text_item.get("text").and_then(|t| t.as_str()) - { - full_text.push_str(text); - full_text.push(' '); - } - - if let Some(mention_text) = text_item - .get("attrs") - .and_then(|attrs| attrs.get("text")) - .and_then(|t| t.as_str()) - { - full_text.push_str(&format!("@{mention_text} ")); - } - } - } - } - } - - full_text.trim().to_string() - } - serde_json::Value::String(s) => s.to_string(), - _ => "(Unable to parse comment)".to_string(), - }; - + let content = comment + .body + .clone() + .unwrap_or_else(|| "(Unable to parse comment)".to_string()); let index_str = format!("{}.", index + 1).green().to_string(); let timestamp_str = format!("[{}]", comment.created_at).blue().to_string(); let author_str = comment .author - .as_ref() - .and_then(|a| a.display_name.clone()) + .clone() .unwrap_or_else(|| "Unknown".to_string()) .magenta() .to_string(); diff --git a/crates/mcptools/src/find_tools.rs b/crates/mcptools/src/find_tools.rs new file mode 100644 index 0000000..8042d96 --- /dev/null +++ b/crates/mcptools/src/find_tools.rs @@ -0,0 +1,19 @@ +use crate::prelude::{println, *}; + +#[derive(Debug, clap::Parser)] +#[command(name = "find-tools")] +#[command(about = "Find the best-fitting tools for a task")] +pub struct App { + #[arg(help = "Natural-language description of the task")] + pub task: String, + #[arg(short = 'k', long, default_value_t = mcptools_core::find_tools::DEFAULT_K, help = "Maximum number of tools to return")] + pub k: usize, +} + +pub async fn run(app: App, _global: crate::Global) -> Result<()> { + let result = crate::mcp::find_tools(&app.task, app.k) + .await + .map_err(|e| eyre!(e.to_string()))?; + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(()) +} diff --git a/crates/mcptools/src/images/mod.rs b/crates/mcptools/src/images/mod.rs index 127efff..e060f86 100644 --- a/crates/mcptools/src/images/mod.rs +++ b/crates/mcptools/src/images/mod.rs @@ -1,14 +1,15 @@ pub mod auth; -use crate::prelude::{eprintln, println, *}; +use crate::open::maybe_open; +use crate::prelude::{println, *}; use mcptools_core::images::{ self, EditParams, GenerateParams, ImagesResponse, CHAT_MAINLINE_DEFAULT, CODEX_RESPONSES_URL, DEFAULT_BASE_URL, DEFAULT_MODEL, MAX_INPUT_IMAGES, VARY_PROMPT, }; use schemars::JsonSchema; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, JsonSchema)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct SavedOutput { pub files: Vec, pub model: String, @@ -188,24 +189,6 @@ pub async fn run(app: App, _global: crate::Global) -> Result<()> { } } -fn opener() -> &'static str { - if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - } -} - -fn maybe_open(files: &[String], open: bool) { - if !open { - return; - } - match std::process::Command::new(opener()).args(files).status() { - Ok(_) => {} - Err(e) => eprintln!("warning: failed to open image: {e}"), - } -} - fn print_saved(saved: &SavedOutput, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(saved)?); diff --git a/crates/mcptools/src/jev.rs b/crates/mcptools/src/jev.rs new file mode 100644 index 0000000..eb0acf5 --- /dev/null +++ b/crates/mcptools/src/jev.rs @@ -0,0 +1,30 @@ +pub const JEV_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + +#[derive(Debug, thiserror::Error)] +pub enum JevError { + #[error("gateway unreachable")] + Unreachable, + #[error("gateway returned an error status")] + HttpStatus, +} + +pub async fn classify( + cfg: &mcptools_core::jev::GatewayConfig, + body: &serde_json::Value, +) -> Result { + let client = reqwest::Client::builder() + .timeout(JEV_TIMEOUT) + .build() + .map_err(|_| JevError::Unreachable)?; + let resp = client + .post(&cfg.endpoint) + .bearer_auth(cfg.api_key.expose()) + .json(body) + .send() + .await + .map_err(|_| JevError::Unreachable)?; + if !resp.status().is_success() { + return Err(JevError::HttpStatus); + } + resp.text().await.map_err(|_| JevError::Unreachable) +} diff --git a/crates/mcptools/src/linear/args.rs b/crates/mcptools/src/linear/args.rs index a312083..b4beb50 100644 --- a/crates/mcptools/src/linear/args.rs +++ b/crates/mcptools/src/linear/args.rs @@ -14,6 +14,7 @@ pub struct IssueGetArgs { #[serde(rename_all = "camelCase")] #[schemars(rename_all = "camelCase")] pub struct IssueListArgs { + pub fields: Option>, #[schemars(description = "Team id, key, or name")] pub team: Option, #[schemars(description = "Project id or name (names need team)")] diff --git a/crates/mcptools/src/linear/chart.rs b/crates/mcptools/src/linear/chart.rs new file mode 100644 index 0000000..849f2fd --- /dev/null +++ b/crates/mcptools/src/linear/chart.rs @@ -0,0 +1,306 @@ +use crate::linear::client::execute; +use crate::prelude::*; +use mcptools_core::linear::ChartNode; +use std::collections::{BTreeMap, HashSet, VecDeque}; + +pub const ISSUE_CAP: usize = 300; + +const CHART_ISSUE_QUERY: &str = "query ($id: String!, $after: String) { issue(id: $id) { id identifier title url state { name type } parent { identifier } inverseRelations(first: 25) { nodes { type issue { identifier } } } children(first: 50, after: $after) { nodes { identifier } pageInfo { hasNextPage endCursor } } } }"; + +#[derive(Debug)] +pub struct ChartClosure { + pub nodes: Vec, + pub cap_hit: bool, + pub missing_blockers: Vec<(String, String)>, +} + +pub fn validate_issue_ids(seeds: &[String]) -> Result> { + if seeds.is_empty() { + return Err(eyre!("Linear chart needs at least one issue id")); + } + let mut validated = Vec::with_capacity(seeds.len()); + for seed in seeds { + let trimmed = seed.trim(); + if trimmed.is_empty() { + return Err(eyre!("Linear chart issue id must not be empty")); + } + validated.push(trimmed.to_string()); + } + Ok(validated) +} + +pub async fn chart_data(client: &reqwest::Client, seeds: &[String]) -> Result { + let seeds = validate_issue_ids(seeds)?; + let mut fetched: BTreeMap = BTreeMap::new(); + let mut queue: VecDeque = seeds.iter().cloned().collect(); + let mut seen: HashSet = seeds.iter().cloned().collect(); + let mut cap_hit = false; + while let Some(selector) = queue.pop_front() { + if fetched.contains_key(&selector) { + continue; + } + if fetched.len() >= ISSUE_CAP { + cap_hit = true; + break; + } + let issue = fetch_issue(client, &selector).await?; + for id in issue.children.iter().chain(issue.blocked_by.iter()) { + if seen.insert(id.clone()) { + queue.push_back(id.clone()); + } + } + fetched.insert(issue.identifier.clone(), issue); + } + let mut missing_blockers = Vec::new(); + for issue in fetched.values() { + for blocker in &issue.blocked_by { + if !fetched.contains_key(blocker) { + missing_blockers.push((issue.identifier.clone(), blocker.clone())); + } + } + } + let nodes = fetched + .values() + .map(|issue| ChartNode { + identifier: issue.identifier.clone(), + title: issue.title.clone(), + state_type: issue.state_type.clone(), + parent: issue.parent.clone(), + blocked_by: issue.blocked_by.clone(), + }) + .collect(); + Ok(ChartClosure { + nodes, + cap_hit, + missing_blockers, + }) +} + +pub fn default_out_path() -> std::path::PathBuf { + std::env::temp_dir().join("mcptools-linear-chart.html") +} + +pub fn render_html(title: &str, chart: &str) -> String { + let heading = escape_html(&format!("{title} — Linear chart")); + let chart = escape_html(chart); + format!( + r#" + + + + +{heading} + + + +
+{chart}
+
+ + + +"# + ) +} + +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +struct FetchedIssue { + identifier: String, + title: String, + url: String, + state_type: String, + parent: Option, + blocked_by: Vec, + children: Vec, +} + +async fn fetch_issue(client: &reqwest::Client, selector: &str) -> Result { + let mut root: Option = None; + let mut children: Vec = Vec::new(); + let mut after: Option = None; + let mut last_cursor: Option = None; + loop { + let data = execute( + client, + CHART_ISSUE_QUERY, + serde_json::json!({"id": selector, "after": after}), + ) + .await?; + let issue = parse_issue(data, selector)?; + let page = issue.children.as_ref().map(|kids| kids.page_info.clone()); + children.extend( + issue + .children + .iter() + .flat_map(|kids| kids.nodes.iter().map(|node| node.identifier.clone())), + ); + if root.is_none() { + root = Some(issue); + } + let (has_next, cursor) = match page { + Some(info) => (info.has_next, info.end_cursor), + None => (false, None), + }; + if !has_next { + break; + } + let cursor = match cursor { + Some(cursor) if last_cursor.as_deref() != Some(cursor.as_str()) => cursor, + _ => break, + }; + last_cursor = Some(cursor.clone()); + after = Some(cursor); + } + let issue = root.ok_or_else(|| eyre!("Linear issue not found: {}", selector))?; + Ok(FetchedIssue { + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state_type: issue.state.state_type, + parent: issue.parent.map(|parent| parent.identifier), + blocked_by: issue + .inverse_relations + .unwrap_or_default() + .nodes + .into_iter() + .filter(|node| node.rel_type == "blocks") + .filter_map(|node| node.issue.map(|issue| issue.identifier)) + .collect(), + children, + }) +} + +fn parse_issue(data: serde_json::Value, selector: &str) -> Result { + #[derive(serde::Deserialize)] + struct Root { + issue: Option, + } + let root: Root = serde_json::from_value(data) + .map_err(|e| eyre!("Failed to parse Linear issue {}: {}", selector, e))?; + root.issue + .ok_or_else(|| eyre!("Linear issue not found: {}", selector)) +} + +#[derive(Clone, serde::Deserialize)] +struct RawIssue { + identifier: String, + title: String, + url: String, + state: RawState, + parent: Option, + #[serde(rename = "inverseRelations")] + inverse_relations: Option, + children: Option, +} + +#[derive(Clone, serde::Deserialize)] +struct RawState { + #[serde(rename = "type")] + state_type: String, +} + +#[derive(Clone, serde::Deserialize)] +struct RawParent { + identifier: String, +} + +#[derive(Clone, Default, serde::Deserialize)] +struct RawRelations { + #[serde(default)] + nodes: Vec, +} + +#[derive(Clone, serde::Deserialize)] +struct RawRelationNode { + #[serde(rename = "type")] + rel_type: String, + issue: Option, +} + +#[derive(Clone, serde::Deserialize)] +struct RawIdentifier { + identifier: String, +} + +#[derive(Clone, serde::Deserialize)] +struct RawChildren { + #[serde(default)] + nodes: Vec, + #[serde(rename = "pageInfo")] + page_info: RawPageInfo, +} + +#[derive(Clone, serde::Deserialize)] +struct RawPageInfo { + #[serde(rename = "hasNextPage")] + has_next: bool, + #[serde(rename = "endCursor")] + end_cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::linear::config::LinearConfig; + + #[tokio::test] + async fn rejects_empty_issue_ids_before_io() { + let cfg = LinearConfig { + api_key: "test-key".to_string(), + }; + let client = crate::linear::client::build_client(&cfg).unwrap(); + for seed in ["", " "] { + let err = chart_data(&client, &[seed.to_string()]).await.unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + } + let err = chart_data(&client, &[]).await.unwrap_err(); + assert!(err.to_string().contains("at least one issue id")); + } +} diff --git a/crates/mcptools/src/linear/mod.rs b/crates/mcptools/src/linear/mod.rs index 7996675..df599c4 100644 --- a/crates/mcptools/src/linear/mod.rs +++ b/crates/mcptools/src/linear/mod.rs @@ -1,5 +1,6 @@ pub mod args; pub mod auth; +pub mod chart; pub mod client; pub mod comments; pub mod config; @@ -7,7 +8,7 @@ pub mod discover; pub mod issue; pub mod relations; -use crate::prelude::{println, *}; +use crate::prelude::{eprintln, println, *}; #[derive(Debug, clap::Parser)] #[command(name = "linear")] @@ -43,6 +44,8 @@ pub enum Commands { /// Cycle operations #[command(subcommand)] Cycles(CyclesCommands), + #[command(about = "Render an HTML Mermaid chart of issues, sub-issues, and blockers")] + Chart(ChartOptions), } #[derive(Debug, clap::Subcommand)] @@ -391,6 +394,23 @@ pub struct TeamScopedListOptions { pub json: bool, } +#[derive(Debug, clap::Args, Clone)] +pub struct ChartOptions { + #[arg( + required = true, + help = "Issue identifiers (e.g. GUZ-185 GUZ-186 GUZ-188)" + )] + pub issues: Vec, + #[arg( + long, + value_name = "PATH", + help = "Output HTML path (default: /mcptools-linear-chart.html)" + )] + pub out: Option, + #[arg(long, help = "Do not open the chart in the default browser")] + pub no_open: bool, +} + pub async fn run(app: App, main_global: crate::Global) -> Result<()> { if main_global.verbose { println!("Running Linear command..."); @@ -435,9 +455,40 @@ pub async fn run(app: App, main_global: crate::Global) -> Result<()> { Commands::Cycles(cmd) => match cmd { CyclesCommands::List(options) => cycles_list_handler(options).await, }, + Commands::Chart(options) => chart_handler(options).await, } } +async fn chart_handler(options: ChartOptions) -> Result<()> { + let seeds = chart::validate_issue_ids(&options.issues)?; + let cfg = config::LinearConfig::from_env()?; + let client = client::build_client(&cfg)?; + let closure = chart::chart_data(&client, &seeds).await?; + if closure.cap_hit { + eprintln!( + "warning: linear chart capped at {} issues; expansion stopped early", + chart::ISSUE_CAP + ); + } + for (issue, blocker) in &closure.missing_blockers { + eprintln!( + "warning: {} is blocked by {}, which is outside the fetched set; rendering {} as fog", + issue, blocker, issue + ); + } + let stats = mcptools_core::linear::chart_stats(&closure.nodes); + let mermaid = mcptools_core::linear::build_mermaid(&closure.nodes); + let stats_line = mcptools_core::linear::stats_line(&stats); + let html = chart::render_html(&seeds[0], &mermaid); + let out = options.out.unwrap_or_else(chart::default_out_path); + std::fs::write(&out, html) + .map_err(|e| eyre!("Failed to write chart HTML to '{}': {}", out.display(), e))?; + println!("{}", out.display()); + println!("{}", stats_line); + crate::open::maybe_open(&[out.to_string_lossy().into_owned()], !options.no_open); + Ok(()) +} + async fn issue_get_handler(options: IssueGetOptions) -> Result<()> { let cfg = config::LinearConfig::from_env()?; let client = client::build_client(&cfg)?; diff --git a/crates/mcptools/src/main.rs b/crates/mcptools/src/main.rs index 0dc21bc..aad1b36 100644 --- a/crates/mcptools/src/main.rs +++ b/crates/mcptools/src/main.rs @@ -2,19 +2,25 @@ use crate::prelude::*; use clap::Parser; +use mcptools_core::sandbox::Limits; +use std::time::Duration; mod agent; mod atlas; mod atlassian; mod error; +mod find_tools; mod hn; mod images; +mod jev; mod linear; mod llm_stream; mod mcp; mod md; +mod open; mod pdf; mod prelude; +mod sandbox; mod upgrade; #[derive(Debug, clap::Parser)] @@ -32,11 +38,51 @@ pub struct App { global: Global, } -#[derive(Debug, Clone, clap::Args)] +#[derive(Debug, Clone, clap::Parser)] pub struct Global { /// Whether to display additional information. #[clap(long, env = "MCPTOOLS_VERBOSE", global = true, default_value = "false")] pub verbose: bool, + + #[clap( + long, + env = "MCPTOOLS_EXECUTE_TIMEOUT_SECS", + global = true, + default_value_t = 30, + value_name = "SECS", + value_parser = clap::value_parser!(u64).range(1..) + )] + pub execute_timeout_secs: u64, + + #[clap( + long, + env = "MCPTOOLS_EXECUTE_MEMORY_MB", + global = true, + default_value_t = 64, + value_name = "MB", + value_parser = clap::value_parser!(u64).range(1..) + )] + pub execute_memory_mb: u64, + + #[clap( + long, + env = "MCPTOOLS_EXECUTE_OUTPUT_KB", + global = true, + default_value_t = 256, + value_name = "KB", + value_parser = clap::value_parser!(u64).range(1..) + )] + pub execute_output_kb: u64, +} + +impl Global { + pub fn sandbox_limits(&self) -> Limits { + Limits { + timeout: Duration::from_secs(self.execute_timeout_secs), + memory_bytes: (self.execute_memory_mb * 1024 * 1024) as usize, + output_bytes: (self.execute_output_kb * 1024) as usize, + } + } } #[derive(Debug, clap::Parser)] @@ -50,6 +96,9 @@ pub enum SubCommands { /// Atlassian (Jira, Confluence) operations Atlassian(Box), + #[command(about = "Find the best-fitting tools for a task")] + FindTools(crate::find_tools::App), + /// HackerNews (news.ycombinator.com) operations HN(crate::hn::App), @@ -82,6 +131,7 @@ async fn main() -> Result<()> { SubCommands::Agent(sub_app) => crate::agent::run(sub_app, app.global).await, SubCommands::Atlas(sub_app) => crate::atlas::run(sub_app, app.global).await, SubCommands::Atlassian(sub_app) => crate::atlassian::run(*sub_app, app.global).await, + SubCommands::FindTools(sub_app) => crate::find_tools::run(sub_app, app.global).await, SubCommands::HN(sub_app) => crate::hn::run(sub_app, app.global).await, SubCommands::Images(sub_app) => crate::images::run(sub_app, app.global).await, SubCommands::MCP(sub_app) => crate::mcp::run(sub_app, app.global).await, @@ -92,3 +142,43 @@ async fn main() -> Result<()> { } .map_err(|err: color_eyre::eyre::Report| eyre!(err)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_limits_from_flags() { + let global = Global::try_parse_from([ + "mcptools", + "--execute-timeout-secs", + "5", + "--execute-memory-mb", + "2", + "--execute-output-kb", + "3", + ]) + .unwrap(); + + let limits = global.sandbox_limits(); + assert_eq!(limits.timeout, Duration::from_secs(5)); + assert_eq!(limits.memory_bytes, 2 * 1024 * 1024); + assert_eq!(limits.output_bytes, 3 * 1024); + } + + #[test] + fn sandbox_limits_without_flags_are_default() { + let limits = Global::try_parse_from(["mcptools"]) + .unwrap() + .sandbox_limits(); + let default = Limits::default(); + assert_eq!(limits.timeout, default.timeout); + assert_eq!(limits.memory_bytes, default.memory_bytes); + assert_eq!(limits.output_bytes, default.output_bytes); + } + + #[test] + fn zero_execute_memory_is_rejected() { + assert!(Global::try_parse_from(["mcptools", "--execute-memory-mb", "0"]).is_err()); + } +} diff --git a/crates/mcptools/src/mcp/cli.rs b/crates/mcptools/src/mcp/cli.rs index 9027e8e..2e03572 100644 --- a/crates/mcptools/src/mcp/cli.rs +++ b/crates/mcptools/src/mcp/cli.rs @@ -1,7 +1,31 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::Args)] +pub struct ServeFlags { + #[clap( + long, + env = "MCPTOOLS_DISCOVERY", + global = true, + default_value = "false", + value_parser = clap::builder::BoolishValueParser::new() + )] + pub discovery: bool, + + #[clap( + long, + env = "MCPTOOLS_CODE_MODE", + global = true, + default_value = "false", + value_parser = clap::builder::BoolishValueParser::new() + )] + pub code_mode: bool, +} + #[derive(Debug, clap::Parser)] #[command(name = "mcp")] #[command(about = "Model Context Protocol server")] pub struct App { + #[clap(flatten)] + pub flags: ServeFlags, + #[command(subcommand)] pub command: Commands, } @@ -15,6 +39,18 @@ pub enum Commands { /// Start MCP server with SSE transport (HTTP) #[clap(name = "sse")] Sse(SseOptions), + + #[command( + name = "catalog", + about = "Print the compact tool catalog: domain, name, summary" + )] + Catalog, + + #[command( + name = "declarations", + about = "Print TypeScript declarations for tools" + )] + Declarations { names: Vec }, } #[derive(Debug, clap::Args)] diff --git a/crates/mcptools/src/mcp/mod.rs b/crates/mcptools/src/mcp/mod.rs index f93fb69..9d0671f 100644 --- a/crates/mcptools/src/mcp/mod.rs +++ b/crates/mcptools/src/mcp/mod.rs @@ -2,9 +2,10 @@ mod cli; mod resources; mod sse; mod stdio; -mod tools; +pub(crate) mod tools; -pub use cli::App; +pub use cli::{App, ServeFlags}; +pub use tools::find_tools; use crate::prelude::*; use serde::{Deserialize, Serialize}; @@ -36,7 +37,13 @@ pub struct JsonRpcError { pub data: Option, } -// MCP Protocol types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolKind { + Read, + Write, + Spend, +} + #[derive(Debug, Serialize)] pub struct Tool { pub name: String, @@ -45,16 +52,36 @@ pub struct Tool { pub input_schema: serde_json::Value, #[serde(rename = "outputSchema")] pub output_schema: serde_json::Value, + #[serde(skip)] + pub summary: &'static str, + #[serde(skip)] + pub kind: ToolKind, } pub async fn run(app: App, global: crate::Global) -> Result<()> { + let flags = app.flags; match app.command { - cli::Commands::Stdio => stdio::run_stdio(global).await, - cli::Commands::Sse(options) => sse::run_sse(options, global).await, + cli::Commands::Stdio => stdio::run_stdio(global, flags).await, + cli::Commands::Sse(options) => sse::run_sse(options, global, flags).await, + cli::Commands::Catalog => { + print!( + "{}", + mcptools_core::catalog::render_catalog(&tools::tool_catalog()) + ); + Ok(()) + } + cli::Commands::Declarations { names } => { + print!("{}", tools::declarations(&names)?); + Ok(()) + } } } -pub async fn handle_request(request_str: &str, global: &crate::Global) -> JsonRpcResponse { +pub async fn handle_request( + request_str: &str, + global: &crate::Global, + flags: ServeFlags, +) -> JsonRpcResponse { let request: JsonRpcRequest = match serde_json::from_str(request_str) { Ok(req) => req, Err(e) => { @@ -73,8 +100,8 @@ pub async fn handle_request(request_str: &str, global: &crate::Global) -> JsonRp let result = match request.method.as_str() { "initialize" => tools::handle_initialize(), - "tools/list" => tools::handle_tools_list(), - "tools/call" => tools::handle_tools_call(request.params, global).await, + "tools/list" => tools::handle_tools_list(flags), + "tools/call" => tools::handle_tools_call(request.params, global, flags).await, "resources/list" => resources::handle_resources_list(), "resources/read" => resources::handle_resources_read(request.params), method => Err(JsonRpcError { diff --git a/crates/mcptools/src/mcp/sse.rs b/crates/mcptools/src/mcp/sse.rs index 4e04256..1414712 100644 --- a/crates/mcptools/src/mcp/sse.rs +++ b/crates/mcptools/src/mcp/sse.rs @@ -10,7 +10,11 @@ use std::convert::Infallible; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; -pub async fn run_sse(options: super::cli::SseOptions, global: crate::Global) -> Result<()> { +pub async fn run_sse( + options: super::cli::SseOptions, + global: crate::Global, + flags: super::cli::ServeFlags, +) -> Result<()> { if global.verbose { eprintln!( "Starting MCP server with SSE transport on {}:{}...", @@ -31,7 +35,7 @@ pub async fn run_sse(options: super::cli::SseOptions, global: crate::Global) -> .route("/sse", get(sse_handler)) .route("/message", post(message_handler)) .layer(cors) - .with_state(shared_global); + .with_state((shared_global, flags)); if global.verbose { eprintln!("MCP server listening on http://{}", addr); @@ -51,17 +55,17 @@ pub async fn run_sse(options: super::cli::SseOptions, global: crate::Global) -> } async fn sse_handler( - State(_global): State>, + State((_global, _flags)): State<(Arc, super::cli::ServeFlags)>, ) -> Sse>> { let stream = stream::once(async { Ok(Event::default().data("MCP SSE endpoint ready")) }); Sse::new(stream) } async fn message_handler( - State(global): State>, + State((global, flags)): State<(Arc, super::cli::ServeFlags)>, Json(request): Json, ) -> Json { let request_str = serde_json::to_string(&request).unwrap_or_default(); - let response = super::handle_request(&request_str, &global).await; + let response = super::handle_request(&request_str, &global, flags).await; Json(serde_json::to_value(response).unwrap_or(serde_json::Value::Null)) } diff --git a/crates/mcptools/src/mcp/stdio.rs b/crates/mcptools/src/mcp/stdio.rs index 84e52b5..61640c9 100644 --- a/crates/mcptools/src/mcp/stdio.rs +++ b/crates/mcptools/src/mcp/stdio.rs @@ -1,7 +1,7 @@ use crate::prelude::{eprintln, *}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -pub async fn run_stdio(global: crate::Global) -> Result<()> { +pub async fn run_stdio(global: crate::Global, flags: crate::mcp::ServeFlags) -> Result<()> { if global.verbose { eprintln!("Starting MCP server with stdio transport..."); eprintln!(); @@ -29,7 +29,7 @@ pub async fn run_stdio(global: crate::Global) -> Result<()> { eprintln!("Received: {trimmed}"); } - let response = super::handle_request(trimmed, &global).await; + let response = super::handle_request(trimmed, &global, flags).await; let response_json = serde_json::to_string(&response)?; if global.verbose { diff --git a/crates/mcptools/src/mcp/tools/annotations.rs b/crates/mcptools/src/mcp/tools/annotations.rs index 6deeea7..a7e8a0e 100644 --- a/crates/mcptools/src/mcp/tools/annotations.rs +++ b/crates/mcptools/src/mcp/tools/annotations.rs @@ -26,13 +26,13 @@ pub struct AnnotationsClearArgs { pub url: Option, } -#[derive(Serialize, JsonSchema)] +#[derive(Deserialize, Serialize, JsonSchema)] pub struct AnnotationResolveOutput { pub id: String, pub resolved: bool, } -#[derive(Serialize, JsonSchema)] +#[derive(Deserialize, Serialize, JsonSchema)] pub struct AnnotationClearOutput { pub cleared: u64, } diff --git a/crates/mcptools/src/mcp/tools/atlassian.rs b/crates/mcptools/src/mcp/tools/atlassian.rs index f615638..adf4f2e 100644 --- a/crates/mcptools/src/mcp/tools/atlassian.rs +++ b/crates/mcptools/src/mcp/tools/atlassian.rs @@ -6,6 +6,7 @@ use super::JsonRpcError; #[derive(Deserialize, JsonSchema)] pub(crate) struct JiraSearchArgs { + fields: Option>, query: Option, #[serde(rename = "queryName")] query_name: Option, @@ -80,6 +81,7 @@ pub(crate) struct BitbucketPRListArgs { } #[derive(Deserialize, JsonSchema)] pub(crate) struct BitbucketPRReadArgs { + fields: Option>, repo: String, #[serde(rename = "prNumber")] pr_number: u64, @@ -190,6 +192,7 @@ pub async fn handle_jira_search( message: format!("Invalid arguments: {e}"), data: None, })?; + let fields = args.fields; let resolved_query = if let Some(query_name) = args.query_name { let home = env::var("HOME") @@ -245,7 +248,7 @@ pub async fn handle_jira_search( data: None, })?; - super::to_dual_result(search_data) + super::to_dual_result_projected(search_data, fields.as_deref()) } pub async fn handle_confluence_search( @@ -708,6 +711,8 @@ pub async fn handle_bitbucket_pr_read( } })?; + let fields = args.fields; + if global.verbose { eprintln!( "Calling bitbucket_pr_read: repo={}, prNumber={}, limit={:?}, diffLimit={:?}, lineLimit={:?}, noDiff={:?}", @@ -755,7 +760,7 @@ pub async fn handle_bitbucket_pr_read( } } - super::to_dual_result(pr_data) + super::to_dual_result_projected(pr_data, fields.as_deref()) } pub async fn handle_bitbucket_pr_create( diff --git a/crates/mcptools/src/mcp/tools/conformance_tests.rs b/crates/mcptools/src/mcp/tools/conformance_tests.rs new file mode 100644 index 0000000..4e76908 --- /dev/null +++ b/crates/mcptools/src/mcp/tools/conformance_tests.rs @@ -0,0 +1,673 @@ +use std::collections::HashSet; + +fn sample(tool: &str) -> serde_json::Value { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("contract_samples") + .join(format!("{tool}.json")); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("missing recorded sample for {tool}")); + serde_json::from_str(&text).unwrap_or_else(|_| panic!("bad JSON sample for {tool}")) +} + +fn roundtrip( + value: &serde_json::Value, +) -> serde_json::Value { + let t: T = serde_json::from_value(value.clone()).expect("roundtrip deserialize"); + serde_json::to_value(&t).expect("roundtrip serialize") +} + +fn roundtrip_by_tool(tool: &str, value: &serde_json::Value) -> serde_json::Value { + match tool { + "jira_search" => roundtrip::(value), + "confluence_search" => { + roundtrip::(value) + } + "hn_read_item" => roundtrip::(value), + "hn_list_items" => roundtrip::(value), + "md_fetch" => roundtrip::(value), + "md_toc" => roundtrip::(value), + "jira_create" => roundtrip::(value), + "jira_get" => roundtrip::(value), + "jira_update" => roundtrip::(value), + "jira_comment_add" => roundtrip::(value), + "jira_comment_list" => { + roundtrip::(value) + } + "jira_comment_update" => roundtrip::(value), + "jira_comment_delete" => { + roundtrip::(value) + } + "jira_sprint_list" => roundtrip::(value), + "jira_attachment_list" => { + roundtrip::(value) + } + "jira_attachment_download" => { + roundtrip::(value) + } + "jira_attachment_upload" => { + roundtrip::(value) + } + "jira_query_list" => roundtrip::(value), + "jira_query_save" => roundtrip::(value), + "jira_query_delete" => { + roundtrip::(value) + } + "jira_query_load" => roundtrip::(value), + "bitbucket_pr_list" => { + roundtrip::(value) + } + "bitbucket_pr_read" => roundtrip::(value), + "bitbucket_pr_create" => { + roundtrip::(value) + } + "bitbucket_workspace_list" => { + roundtrip::(value) + } + "bitbucket_repo_list" => { + roundtrip::(value) + } + "bitbucket_repo_branches" => { + roundtrip::(value) + } + "ui_annotations_list" => { + roundtrip::(value) + } + "ui_annotations_get" => roundtrip::(value), + "ui_annotations_resolve" => roundtrip::(value), + "ui_annotations_clear" => roundtrip::(value), + "pdf_toc" => roundtrip::<::pdf::DocumentTree>(value), + "pdf_read" => roundtrip::<::pdf::SectionContent>(value), + "pdf_peek" => roundtrip::<::pdf::PeekContent>(value), + "pdf_images" => roundtrip::(value), + "pdf_image" => roundtrip::(value), + "pdf_info" => roundtrip::<::pdf::DocumentMetadata>(value), + "images_generate" => roundtrip::(value), + "images_edit" => roundtrip::(value), + "images_vary" => roundtrip::(value), + "atlas_tree_view" => roundtrip::(value), + "atlas_peek" => roundtrip::(value), + "atlas_status" => roundtrip::(value), + "linear_auth_status" => roundtrip::(value), + "linear_issue_get" => roundtrip::(value), + "linear_issue_list" => roundtrip::(value), + "linear_comment_list" => roundtrip::(value), + "linear_relation_list" => roundtrip::(value), + "linear_team_list" => roundtrip::(value), + "linear_team_get" => roundtrip::(value), + "linear_project_list" => roundtrip::(value), + "linear_project_get" => roundtrip::(value), + "linear_user_list" => roundtrip::(value), + "linear_state_list" => roundtrip::(value), + "linear_label_list" => roundtrip::(value), + "linear_cycle_list" => roundtrip::(value), + "linear_issue_create" => roundtrip::(value), + "linear_issue_update" => roundtrip::(value), + "linear_comment_create" => roundtrip::(value), + "linear_relation_add" => roundtrip::(value), + "linear_relation_remove" => roundtrip::(value), + "find_tools" => roundtrip::(value), + "execute" => roundtrip::(value), + _ => panic!("unknown tool in roundtrip_by_tool: {tool}"), + } +} + +fn strip_optional( + schema: &serde_json::Value, + root: &serde_json::Value, + value: &serde_json::Value, +) -> serde_json::Value { + strip_with_visited(schema, root, value, &mut HashSet::new()) +} + +fn strip_with_visited( + schema: &serde_json::Value, + root: &serde_json::Value, + value: &serde_json::Value, + visited: &mut HashSet, +) -> serde_json::Value { + let ref_str = schema + .get("$ref") + .and_then(|x| x.as_str()) + .map(str::to_string); + let schema = if let Some(r) = &ref_str { + if visited.contains(r) { + return value.clone(); + } + visited.insert(r.clone()); + resolve_ref(root, r) + } else { + schema + }; + let result = if let Some(map) = value.as_object() { + let empty = serde_json::Map::new(); + let sobj = schema.as_object().unwrap_or(&empty); + let required: HashSet = sobj + .get("required") + .and_then(|r| r.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let props = sobj.get("properties").and_then(|p| p.as_object()); + let mut out = serde_json::Map::new(); + for (k, v) in map { + if required.contains(k) { + let ps = props + .and_then(|p| p.get(k)) + .unwrap_or(&serde_json::Value::Null); + let sv = strip_with_visited(ps, root, v, visited); + out.insert(k.clone(), sv); + } + } + serde_json::Value::Object(out) + } else if let Some(arr) = value.as_array() { + let it = schema.get("items").unwrap_or(&serde_json::Value::Null); + let sarr = arr + .iter() + .map(|v| strip_with_visited(it, root, v, visited)) + .collect(); + serde_json::Value::Array(sarr) + } else if let Some(any) = schema.get("anyOf").and_then(|a| a.as_array()) { + for br in any { + let b = resolve_schema(br, root); + if !is_nullish(b) { + let res = strip_with_visited(b, root, value, visited); + if let Some(r) = &ref_str { + visited.remove(r); + } + return res; + } + } + value.clone() + } else { + value.clone() + }; + if let Some(r) = &ref_str { + visited.remove(r); + } + result +} + +fn resolve_schema<'a>( + s: &'a serde_json::Value, + root: &'a serde_json::Value, +) -> &'a serde_json::Value { + if let Some(r) = s.get("$ref").and_then(|x| x.as_str()) { + resolve_ref(root, r) + } else { + s + } +} + +fn resolve_ref<'a>(root: &'a serde_json::Value, r: &str) -> &'a serde_json::Value { + let mut cur = root; + let p = r.strip_prefix("#/").unwrap_or(r); + for part in p.split('/') { + if part.is_empty() { + continue; + } + if let Some(o) = cur.as_object() { + if let Some(next) = o.get(part) { + cur = next; + continue; + } + if let Some(d) = o.get("$defs").or_else(|| o.get("definitions")) { + if let Some(next) = d.get(part) { + cur = next; + continue; + } + } + } + break; + } + cur +} + +fn is_nullish(s: &serde_json::Value) -> bool { + match s.get("type") { + Some(serde_json::Value::String(t)) if t == "null" => true, + Some(serde_json::Value::Array(ts)) => ts.iter().any(|t| t.as_str() == Some("null")), + _ => false, + } +} + +fn loose_nodes(schema: &serde_json::Value) -> Vec { + let mut paths = vec![]; + loose_collect(schema, schema, "", &mut HashSet::new(), &mut paths); + paths +} + +fn loose_collect( + node: &serde_json::Value, + root: &serde_json::Value, + path: &str, + visited: &mut HashSet, + paths: &mut Vec, +) { + let ref_str = node + .get("$ref") + .and_then(|x| x.as_str()) + .map(str::to_string); + if let Some(r) = &ref_str { + if visited.contains(r) { + return; + } + visited.insert(r.clone()); + let res = resolve_ref(root, r); + if is_loose(res) { + let p = if path.is_empty() { + "/".to_string() + } else { + path.to_string() + }; + paths.push(p); + } + visited.remove(r); + return; + } + if is_loose(node) { + let p = if path.is_empty() { + "/".to_string() + } else { + path.to_string() + }; + paths.push(p); + } + if let Some(obj) = node.as_object() { + if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) { + for (k, v) in props { + let child = if path.is_empty() { + format!("/properties/{}", k) + } else { + format!("{}/properties/{}", path, k) + }; + loose_collect(v, root, &child, visited, paths); + } + } + if let Some(items) = obj.get("items") { + let child = if path.is_empty() { + "/items".to_string() + } else { + format!("{}/items", path) + }; + loose_collect(items, root, &child, visited, paths); + } + if let Some(pis) = obj.get("prefixItems").and_then(|p| p.as_array()) { + for (i, pi) in pis.iter().enumerate() { + let child = if path.is_empty() { + format!("/prefixItems/{}", i) + } else { + format!("{}/prefixItems/{}", path, i) + }; + loose_collect(pi, root, &child, visited, paths); + } + } + if let Some(aos) = obj.get("anyOf").and_then(|a| a.as_array()) { + for (i, ao) in aos.iter().enumerate() { + let child = if path.is_empty() { + format!("/anyOf/{}", i) + } else { + format!("{}/anyOf/{}", path, i) + }; + loose_collect(ao, root, &child, visited, paths); + } + } + if let Some(defs) = obj + .get("$defs") + .or_else(|| obj.get("definitions")) + .and_then(|d| d.as_object()) + { + for (k, dv) in defs { + let child = format!("/$defs/{}", k); + let dref = format!("#/$defs/{}", k); + if visited.contains(&dref) { + continue; + } + visited.insert(dref.clone()); + loose_collect(dv, root, &child, visited, paths); + visited.remove(&dref); + } + } + } +} + +fn is_loose(s: &serde_json::Value) -> bool { + if s.as_bool() == Some(true) { + return true; + } + if let Some(o) = s.as_object() { + if o.is_empty() { + return true; + } + !o.contains_key("type") + && !o.contains_key("$ref") + && !o.contains_key("anyOf") + && !o.contains_key("enum") + } else { + false + } +} + +fn unreached(schema: &serde_json::Value, sample: &serde_json::Value) -> Vec { + let mut paths = vec![]; + unreached_collect(schema, schema, sample, "", &mut HashSet::new(), &mut paths); + paths +} + +fn unreached_collect( + node: &serde_json::Value, + root: &serde_json::Value, + value: &serde_json::Value, + path: &str, + visited: &mut HashSet, + paths: &mut Vec, +) { + let ref_str = node + .get("$ref") + .and_then(|x| x.as_str()) + .map(str::to_string); + if let Some(r) = &ref_str { + if visited.contains(r) { + return; + } + visited.insert(r.clone()); + let res = resolve_ref(root, r); + unreached_collect(res, root, value, path, visited, paths); + visited.remove(r); + return; + } + if let Some(aos) = node.get("anyOf").and_then(|a| a.as_array()) { + if value.is_null() { + return; + } + for ao in aos { + let b = resolve_schema(ao, root); + if !is_nullish(b) { + unreached_collect(b, root, value, path, visited, paths); + break; + } + } + return; + } + if let Some(obj) = node.as_object() { + if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) { + let empty = serde_json::Map::new(); + let map = value.as_object().unwrap_or(&empty); + for (k, ps) in props { + let child = if path.is_empty() { + format!("/properties/{}", k) + } else { + format!("{}/properties/{}", path, k) + }; + if let Some(v) = map.get(k) { + if v.is_null() { + paths.push(child); + } else { + unreached_collect(ps, root, v, &child, visited, paths); + } + } else { + paths.push(child); + } + } + } + if let Some(it) = obj.get("items") { + let child = if path.is_empty() { + "/items".to_string() + } else { + format!("{}/items", path) + }; + if let Some(arr) = value.as_array() { + if arr.is_empty() { + paths.push(child); + } else { + unreached_collect(it, root, &arr[0], &child, visited, paths); + } + } else { + paths.push(child); + } + } + if let Some(pis) = obj.get("prefixItems").and_then(|p| p.as_array()) { + let empty: Vec = vec![]; + let arr = value.as_array().unwrap_or(&empty); + for (i, pi) in pis.iter().enumerate() { + let child = if path.is_empty() { + format!("/prefixItems/{}", i) + } else { + format!("{}/prefixItems/{}", path, i) + }; + if i < arr.len() && !arr[i].is_null() { + unreached_collect(pi, root, &arr[i], &child, visited, paths); + } else { + paths.push(child); + } + } + } + } +} + +#[test] +fn samples_roundtrip_through_output_types() { + for tool in super::registered_tools() { + let name = &tool.name; + let s = sample(name); + let rt = roundtrip_by_tool(name, &s); + assert_eq!(rt, s, "roundtrip mismatch for {name}"); + } +} + +const PROJECTED_TOOLS: [&str; 5] = [ + "pdf_read", + "md_fetch", + "bitbucket_pr_read", + "jira_search", + "linear_issue_list", +]; + +#[test] +fn sparse_outputs_validate() { + for tool in super::registered_tools() { + let name = &tool.name; + let s = sample(name); + let schema = &tool.output_schema; + let sparse = strip_optional(schema, schema, &s); + let checked = if PROJECTED_TOOLS.contains(&name.as_str()) { + sparse + } else { + roundtrip_by_tool(name, &sparse) + }; + let validator = jsonschema::draft202012::new(schema) + .unwrap_or_else(|e| panic!("invalid schema for {name}: {e}")); + let mut errs = vec![]; + for e in validator.iter_errors(&checked) { + let p = e.instance_path().as_str(); + let p = if p.is_empty() { "/" } else { p }; + errs.push(format!("{name}: {p}: {e}")); + } + assert!(errs.is_empty(), "sparse failed for {name}: {errs:?}"); + } +} + +#[test] +fn output_schemas_have_no_any_nodes() { + let mut bad = vec![]; + for tool in super::registered_tools() { + let name = &tool.name; + let schema = &tool.output_schema; + let mut loose = loose_nodes(schema); + if name.as_str() == "execute" { + loose.retain(|path| path != "/properties/result"); + } + if !loose.is_empty() { + bad.push((name.to_string(), loose)); + } + } + assert!(bad.is_empty(), "loose nodes: {bad:?}"); +} + +#[test] +fn samples_cover_their_schemas() { + let mut bad = vec![]; + for tool in super::registered_tools() { + let name = &tool.name; + let s = sample(name); + let schema = &tool.output_schema; + let unre = unreached(schema, &s); + if !unre.is_empty() { + bad.push((name.to_string(), unre)); + } + } + assert!(bad.is_empty(), "unreached paths: {bad:?}"); +} + +fn paths(list: &[&str]) -> Vec { + list.iter().map(|p| p.to_string()).collect() +} + +fn text_matches_structured(result: &serde_json::Value) { + let text = result["content"][0]["text"].as_str().expect("text body"); + let parsed: serde_json::Value = serde_json::from_str(text).expect("text parses as JSON"); + assert_eq!(parsed, result["structuredContent"]); +} + +fn object_keys(value: &serde_json::Value) -> Vec<&str> { + let mut keys: Vec<&str> = value + .as_object() + .expect("object value") + .keys() + .map(String::as_str) + .collect(); + keys.sort(); + keys +} + +#[test] +fn fields_projection_linear_issue_list() { + let projected = super::to_dual_result_projected( + sample("linear_issue_list"), + Some(&paths(&["nodes.identifier", "nodes.title", "pageInfo"])), + ) + .expect("known paths project"); + let structured = &projected["structuredContent"]; + let nodes = structured["nodes"].as_array().expect("nodes array"); + assert!(!nodes.is_empty()); + for node in nodes { + assert_eq!(object_keys(node), ["identifier", "title"]); + } + assert!(structured.get("pageInfo").is_some()); + text_matches_structured(&projected); + + let err = + super::to_dual_result_projected(sample("linear_issue_list"), Some(&paths(&["nodes.nope"]))) + .expect_err("unknown path fails"); + assert_eq!(err.code, -32602); + assert_eq!(err.message, "Unknown field path: \"nodes.nope\""); +} + +#[test] +fn fields_projection_jira_search() { + let projected = super::to_dual_result_projected( + sample("jira_search"), + Some(&paths(&["issues.key", "total"])), + ) + .expect("known paths project"); + let structured = &projected["structuredContent"]; + let issues = structured["issues"].as_array().expect("issues array"); + assert!(!issues.is_empty()); + assert_eq!(object_keys(&issues[0]), ["key"]); + assert_eq!(issues[0]["key"], serde_json::json!("PROJ-1")); + assert_eq!(structured["total"], serde_json::json!(1)); + assert!(structured.get("next_page_token").is_none()); + text_matches_structured(&projected); + + let err = + super::to_dual_result_projected(sample("jira_search"), Some(&paths(&["issues.nope"]))) + .expect_err("unknown path fails"); + assert_eq!(err.code, -32602); + assert_eq!(err.message, "Unknown field path: \"issues.nope\""); +} + +#[test] +fn fields_projection_md_fetch() { + let projected = + super::to_dual_result_projected(sample("md_fetch"), Some(&paths(&["title", "content"]))) + .expect("known paths project"); + let structured = &projected["structuredContent"]; + assert_eq!(object_keys(structured), ["content", "title"]); + assert!(structured.get("url").is_none()); + assert!(structured.get("html_length").is_none()); + assert!(structured.get("fetch_time_ms").is_none()); + assert!(structured.get("pagination").is_none()); + text_matches_structured(&projected); +} + +#[test] +fn fields_projection_bitbucket_pr_read() { + let projected = super::to_dual_result_projected( + sample("bitbucket_pr_read"), + Some(&paths(&["id", "title", "state", "diff_content"])), + ) + .expect("known paths project"); + let structured = &projected["structuredContent"]; + assert_eq!( + object_keys(structured), + ["diff_content", "id", "state", "title"] + ); + assert!(structured.get("comments").is_none()); + assert!(structured.get("diffstat").is_none()); + text_matches_structured(&projected); +} + +fn contains_required_key(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(map) => map + .iter() + .any(|(k, v)| k == "required" || contains_required_key(v)), + serde_json::Value::Array(items) => items.iter().any(contains_required_key), + _ => false, + } +} + +#[test] +fn fields_projection_covers_five_registered_tools() { + let tools = super::registered_tools(); + for name in PROJECTED_TOOLS { + let tool = tools + .iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("missing tool {name}")); + let fields = tool + .input_schema + .get("properties") + .and_then(|p| p.get("fields")) + .unwrap_or_else(|| panic!("{name}: missing fields property")); + assert_eq!(fields.get("type").and_then(|t| t.as_str()), Some("array")); + assert_eq!( + fields + .get("items") + .and_then(|i| i.get("type")) + .and_then(|t| t.as_str()), + Some("string") + ); + let required = tool + .input_schema + .get("required") + .and_then(|r| r.as_array()) + .cloned() + .unwrap_or_default(); + assert!( + !required.iter().any(|r| r.as_str() == Some("fields")), + "{name}: fields is required" + ); + assert!( + tool.description.contains("fields"), + "{name}: description lacks fields" + ); + assert!( + !contains_required_key(&tool.output_schema), + "{name}: output schema keeps required" + ); + } +} diff --git a/crates/mcptools/src/mcp/tools/execute.rs b/crates/mcptools/src/mcp/tools/execute.rs new file mode 100644 index 0000000..72ecd55 --- /dev/null +++ b/crates/mcptools/src/mcp/tools/execute.rs @@ -0,0 +1,418 @@ +use super::{registered_tools, to_dual_result, JsonRpcError, Tool, ToolKind}; +use crate::sandbox; +use mcptools_core::sandbox::execute_output; + +pub const DESCRIPTION: &str = r#"Runs JavaScript in a sandbox. Call find_tools first: each declaration it returns is an async global named after the tool, e.g. `const r = await jira_search({ jql: "..." })`. console.log lines return as logs, and the final expression or top-level return value as result. Only read tools are bound unless allowWrites or allowSpend is true."#; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ExecuteArgs { + #[schemars(description = "JavaScript source to run in the sandbox.")] + pub code: String, + #[serde(rename = "allowWrites", default)] + #[schemars(description = "Binds write tools in addition to read tools.")] + pub allow_writes: bool, + #[serde(rename = "allowSpend", default)] + #[schemars(description = "Binds spend tools in addition to read tools.")] + pub allow_spend: bool, +} + +pub async fn handle_execute( + arguments: Option, + global: &crate::Global, + flags: super::ServeFlags, +) -> Result { + let args: ExecuteArgs = serde_json::from_value(arguments.unwrap_or(serde_json::Value::Null)) + .map_err(|e| JsonRpcError { + code: -32602, + message: format!("Invalid params: {e}"), + data: None, + })?; + let mut kinds = vec![ToolKind::Read]; + if args.allow_writes { + kinds.push(ToolKind::Write); + } + if args.allow_spend { + kinds.push(ToolKind::Spend); + } + let tools = registered_tools(); + let names = sandbox::bindings::bound_names(&tools, &kinds); + let run = sandbox::run( + &args.code, + global.sandbox_limits(), + sandbox::Bindings { + names, + global: global.clone(), + flags, + }, + ) + .await; + let mut output = execute_output(run); + if let Some(error) = output + .error + .as_mut() + .filter(|error| error.name == "ReferenceError") + { + if let Some(message) = gated_reference_message(&error.message, &tools, &kinds) { + error.message = message; + } + } + let failed = output.error.is_some(); + let mut envelope = to_dual_result(&output)?; + if failed { + envelope["isError"] = serde_json::json!(true); + } + Ok(envelope) +} + +fn gated_reference_message(message: &str, tools: &[Tool], allowed: &[ToolKind]) -> Option { + let name = message.strip_suffix(" is not defined")?; + if name == "execute" { + return None; + } + let kind = tools.iter().find(|tool| tool.name == name)?.kind; + if allowed.contains(&kind) { + return None; + } + match kind { + ToolKind::Write => Some(format!( + "{name} is a write tool; pass allowWrites: true to execute" + )), + ToolKind::Spend => Some(format!( + "{name} is a spend tool; pass allowSpend: true to execute" + )), + ToolKind::Read => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::tools::{handle_tools_call, listed_tools}; + use serde_json::json; + + fn global_with(extra: &[&str]) -> crate::Global { + use clap::Parser; + #[derive(clap::Parser)] + struct Args { + #[command(flatten)] + global: crate::Global, + } + let mut argv = vec!["mcptools"]; + argv.extend_from_slice(extra); + Args::parse_from(argv).global + } + + fn clear_offline_env() { + let keys: Vec = std::env::vars() + .map(|(key, _)| key) + .filter(|key| key.starts_with("ATLASSIAN_") || key.starts_with("JIRA_")) + .collect(); + for key in keys { + std::env::remove_var(key); + } + std::env::remove_var("JEV_PROVIDER"); + } + + async fn call( + name: &str, + arguments: serde_json::Value, + global: &crate::Global, + ) -> Result { + handle_tools_call( + Some(json!({"name": name, "arguments": arguments})), + global, + serve_flags(false), + ) + .await + } + + fn serve_flags(discovery: bool) -> crate::mcp::ServeFlags { + crate::mcp::ServeFlags { + discovery, + code_mode: false, + } + } + + const KIND_PROBE: &str = + "return [typeof jira_search, typeof jira_create, typeof images_generate, typeof execute]"; + + #[tokio::test(flavor = "multi_thread")] + async fn find_tools_then_execute_reaches_unlisted_tool() { + clear_offline_env(); + let global = global_with(&[]); + let found = call("find_tools", json!({"task": "search jira issues"}), &global) + .await + .unwrap(); + let names: Vec<&str> = found["structuredContent"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"jira_search")); + assert!(!listed_tools(registered_tools(), serve_flags(true)) + .iter() + .any(|tool| tool.name == "jira_search")); + let executed = call( + "execute", + json!({"code": "try { await jira_search({ jql: \"x\" }); return null } catch (e) { return e.message }"}), + &global, + ) + .await + .unwrap(); + let result = executed["structuredContent"]["result"].clone(); + let err = handle_tools_call( + Some(json!({"name": "jira_search", "arguments": {"jql": "x"}})), + &global, + serve_flags(false), + ) + .await + .unwrap_err(); + assert_eq!(result, json!(err.message)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn default_binds_read_only() { + let global = global_with(&[]); + let executed = call("execute", json!({"code": KIND_PROBE}), &global) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["result"], + json!(["function", "undefined", "undefined", "undefined"]) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn allow_writes_binds_write_not_spend() { + let global = global_with(&[]); + let executed = call( + "execute", + json!({"code": KIND_PROBE, "allowWrites": true}), + &global, + ) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["result"], + json!(["function", "function", "undefined", "undefined"]) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn allow_spend_binds_spend() { + let global = global_with(&[]); + let executed = call( + "execute", + json!({"code": KIND_PROBE, "allowSpend": true}), + &global, + ) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["result"], + json!(["function", "undefined", "function", "undefined"]) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn success_returns_logs_and_result() { + let global = global_with(&[]); + let executed = call( + "execute", + json!({"code": "console.log(1); return {n: 2}"}), + &global, + ) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"], + json!({"logs": ["1"], "result": {"n": 2}, "error": null}) + ); + assert!(executed.get("isError").is_none()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn throw_returns_error_and_prior_logs() { + let global = global_with(&[]); + let executed = call( + "execute", + json!({"code": "console.log('a'); throw new TypeError('b')"}), + &global, + ) + .await + .unwrap(); + assert_eq!(executed["structuredContent"]["logs"], json!(["a"])); + assert_eq!(executed["structuredContent"]["result"], json!(null)); + assert_eq!( + executed["structuredContent"]["error"], + json!({"message": "b", "name": "TypeError"}) + ); + assert_eq!(executed["isError"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn timeout_returns_timeout_error_and_prior_logs() { + let global = global_with(&["--execute-timeout-secs", "1"]); + let executed = call( + "execute", + json!({"code": "console.log('a'); while (true) {}"}), + &global, + ) + .await + .unwrap(); + assert_eq!(executed["structuredContent"]["logs"], json!(["a"])); + assert_eq!( + executed["structuredContent"]["error"]["name"], + json!("TimeoutError") + ); + assert_eq!(executed["isError"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn output_cap_keeps_lines_that_fit() { + let global = global_with(&["--execute-output-kb", "1"]); + let executed = call( + "execute", + json!({"code": "console.log('a'); console.log('x'.repeat(2000))"}), + &global, + ) + .await + .unwrap(); + assert_eq!(executed["structuredContent"]["logs"], json!(["a"])); + assert_eq!( + executed["structuredContent"]["error"]["name"], + json!("OutputLimitError") + ); + assert_eq!(executed["isError"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn memory_exhaustion_returns_internal_error() { + let global = global_with(&["--execute-memory-mb", "1"]); + let executed = call( + "execute", + json!({"code": "const a=[]; while(true) a.push('x'.repeat(1<<20))"}), + &global, + ) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["error"]["name"], + json!("InternalError") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn missing_code_is_invalid_params() { + let global = global_with(&[]); + let err = handle_tools_call( + Some(json!({"name": "execute", "arguments": {}})), + &global, + serve_flags(false), + ) + .await + .unwrap_err(); + assert_eq!(err.code, -32602); + } + + #[tokio::test(flavor = "multi_thread")] + async fn write_tool_names_allow_writes_flag() { + clear_offline_env(); + let global = global_with(&[]); + let executed = call( + "execute", + json!({"code": "console.log('a'); jira_update({})"}), + &global, + ) + .await + .unwrap(); + assert_eq!(executed["structuredContent"]["logs"], json!(["a"])); + assert_eq!( + executed["structuredContent"]["error"], + json!({ + "message": "jira_update is a write tool; pass allowWrites: true to execute", + "name": "ReferenceError" + }) + ); + assert_eq!(executed["structuredContent"]["result"], json!(null)); + assert_eq!(executed["isError"], json!(true)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn spend_tool_names_allow_spend_flag() { + clear_offline_env(); + let global = global_with(&[]); + let executed = call("execute", json!({"code": "images_generate({})"}), &global) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["error"], + json!({ + "message": "images_generate is a spend tool; pass allowSpend: true to execute", + "name": "ReferenceError" + }) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn allow_writes_does_not_cover_spend() { + clear_offline_env(); + let global = global_with(&[]); + let executed = call( + "execute", + json!({"code": "images_generate({})", "allowWrites": true}), + &global, + ) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["error"]["message"], + json!("images_generate is a spend tool; pass allowSpend: true to execute") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn execute_reference_stays_plain() { + clear_offline_env(); + let global = global_with(&[]); + let executed = call("execute", json!({"code": "execute({})"}), &global) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["error"]["message"], + json!("execute is not defined") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn unknown_global_stays_plain() { + clear_offline_env(); + let global = global_with(&[]); + let executed = call("execute", json!({"code": "no_such_tool_175({})"}), &global) + .await + .unwrap(); + assert_eq!( + executed["structuredContent"]["error"]["message"], + json!("no_such_tool_175 is not defined") + ); + } + + #[test] + fn gated_reference_message_none_branches() { + let tools = registered_tools(); + let writes = [ToolKind::Read, ToolKind::Write]; + assert_eq!( + gated_reference_message("jira_update is not defined", &tools, &writes), + None + ); + assert_eq!( + gated_reference_message("jira_update could not run", &tools, &writes), + None + ); + assert_eq!( + gated_reference_message("execute is not defined", &tools, &writes), + None + ); + } +} diff --git a/crates/mcptools/src/mcp/tools/find_tools.rs b/crates/mcptools/src/mcp/tools/find_tools.rs new file mode 100644 index 0000000..83793c0 --- /dev/null +++ b/crates/mcptools/src/mcp/tools/find_tools.rs @@ -0,0 +1,599 @@ +use super::JsonRpcError; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] +pub struct FoundTool { + pub name: String, + pub domain: String, + pub score: f64, + pub declaration: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Jev, + Local, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FallbackReason { + Unreachable, + HttpStatus, + InvalidResponse, + InvalidConfig, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, schemars::JsonSchema)] +pub struct FoundTools { + pub none: f64, + pub tools: Vec, + pub backend: Backend, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback: Option, + pub usage: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct FindToolsArgs { + #[schemars(description = "Natural-language description of the task")] + pub task: String, + #[schemars(description = "Maximum number of tools to return (default 5)")] + pub k: Option, +} + +fn attach_schemas( + ranking: mcptools_core::find_tools::Ranking, + backend: Backend, + fallback: Option, +) -> FoundTools { + let registered = super::registered_tools(); + let tools: Vec = ranking + .tools + .into_iter() + .filter_map(|r| { + registered + .iter() + .find(|t| t.name == r.name) + .map(|t| FoundTool { + name: r.name, + domain: r.domain, + score: r.score, + declaration: super::declaration(t), + }) + }) + .collect(); + FoundTools { + none: ranking.none, + tools, + backend, + fallback, + usage: mcptools_core::find_tools::USAGE.to_string(), + } +} + +pub async fn find_tools_with( + config: Result, mcptools_core::jev::ConfigError>, + task: &str, + k: usize, +) -> Result { + let catalog = super::tool_catalog(); + let local = mcptools_core::find_tools::rank_tools(task, &catalog, k)?; + match config { + Ok(Some(cfg)) => { + let body = mcptools_core::jev::build_request(task, &catalog, &cfg.model); + match crate::jev::classify(&cfg, &body).await { + Ok(text) => match mcptools_core::jev::parse_ranking(&text, &catalog) { + Ok(r) => { + let sel = mcptools_core::jev::select(r, k); + Ok(attach_schemas(sel, Backend::Jev, None)) + } + Err(mcptools_core::jev::ClassifyError::MalformedBody) + | Err(mcptools_core::jev::ClassifyError::MissingAnswer) => Ok(attach_schemas( + local, + Backend::Local, + Some(FallbackReason::InvalidResponse), + )), + }, + Err(crate::jev::JevError::Unreachable) => Ok(attach_schemas( + local, + Backend::Local, + Some(FallbackReason::Unreachable), + )), + Err(crate::jev::JevError::HttpStatus) => Ok(attach_schemas( + local, + Backend::Local, + Some(FallbackReason::HttpStatus), + )), + } + } + Ok(None) => Ok(attach_schemas(local, Backend::Local, None)), + Err(mcptools_core::jev::ConfigError::UnknownProvider) + | Err(mcptools_core::jev::ConfigError::MissingKey) => Ok(attach_schemas( + local, + Backend::Local, + Some(FallbackReason::InvalidConfig), + )), + } +} + +pub async fn find_tools( + task: &str, + k: usize, +) -> Result { + find_tools_with( + mcptools_core::jev::gateway_config(|key| std::env::var(key).ok()), + task, + k, + ) + .await +} + +pub async fn handle_find_tools( + arguments: Option, + _global: &crate::Global, + flags: super::ServeFlags, +) -> Result { + let args: FindToolsArgs = serde_json::from_value(arguments.unwrap_or(serde_json::Value::Null)) + .map_err(|e| JsonRpcError { + code: -32602, + message: format!("Invalid arguments: {e}"), + data: None, + })?; + let mut result = find_tools( + &args.task, + args.k.unwrap_or(mcptools_core::find_tools::DEFAULT_K), + ) + .await + .map_err(|e| JsonRpcError { + code: -32602, + message: e.to_string(), + data: None, + })?; + result.usage = if flags.code_mode { + mcptools_core::find_tools::CODE_MODE_USAGE.to_string() + } else { + mcptools_core::find_tools::USAGE.to_string() + }; + super::to_dual_result(result) +} + +#[cfg(test)] +mod find_tools_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn parse_golden() -> Vec<(String, Option>, String)> { + let content = include_str!("fixtures/golden-queries.tsv"); + let mut rows = vec![]; + for (i, line) in content.lines().enumerate() { + if i == 0 || line.trim().is_empty() { + continue; + } + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() != 3 { + continue; + } + let task = parts[0].to_string(); + let expected = if parts[1] == "NONE" { + None + } else { + Some(parts[1].split(',').map(|s| s.trim().to_string()).collect()) + }; + let kind = parts[2].to_string(); + rows.push((task, expected, kind)); + } + rows + } + + #[tokio::test] + async fn find_tools_attaches_declaration() { + let result = find_tools("close GUZ-22", 5).await.unwrap(); + let registered = super::super::registered_tools(); + for found in &result.tools { + let reg = registered.iter().find(|t| t.name == found.name).unwrap(); + assert_eq!(found.declaration, super::super::declaration(reg)); + } + assert_eq!(result.usage, mcptools_core::find_tools::USAGE); + } + + #[tokio::test] + async fn golden_recall_at_5_meets_threshold() { + let rows = parse_golden(); + let in_scope: Vec<_> = rows.iter().filter(|(_, _, k)| k == "in_scope").collect(); + let mut hits = 0; + for (task, expected, _) in &in_scope { + if let Some(exps) = expected { + let res = find_tools(task, 5).await.unwrap(); + let names: Vec<_> = res.tools.iter().map(|t| t.name.as_str()).collect(); + if exps.iter().any(|e| names.contains(&e.as_str())) { + hits += 1; + } + } + } + let recall = hits as f64 / in_scope.len() as f64; + assert!(recall >= 0.85, "recall@5 = {}", recall); + } + + #[tokio::test] + async fn required_rows_each_pass() { + let rows = parse_golden(); + let required: Vec<_> = rows.iter().filter(|(_, _, k)| k == "required").collect(); + let mut none_scores = vec![]; + let mut failing = vec![]; + for (task, expected, _) in &required { + let res = find_tools(task, 5).await.unwrap(); + let names: Vec<_> = res.tools.iter().map(|t| t.name.as_str()).collect(); + if let Some(exps) = expected { + let top1_ok = names.first().is_some_and(|t| exps.iter().any(|e| e == t)); + let all_in = exps.iter().all(|e| names.contains(&e.as_str())); + none_scores.push(res.none); + if !(top1_ok && all_in) { + failing.push(task.clone()); + } + } + } + for (task, expected, _) in &required { + if expected.is_none() { + let res = find_tools(task, 5).await.unwrap(); + if !none_scores.iter().all(|&n| n < res.none) { + failing.push(task.clone()); + } + } + } + assert!(failing.is_empty(), "required rows failed: {failing:?}"); + } + + #[tokio::test] + async fn close_guz_22_ranks_linear_issue_update_in_top_5() { + let res = find_tools("close GUZ-22", 5).await.unwrap(); + assert!(res.tools.iter().any(|t| t.name == "linear_issue_update")); + } + + #[tokio::test] + async fn unrelated_rows_score_higher_none_than_close_guz_22() { + let rows = parse_golden(); + let unrelated: Vec<_> = rows.iter().filter(|(_, _, k)| k == "unrelated").collect(); + let close_none = find_tools("close GUZ-22", 5).await.unwrap().none; + for (task, _, _) in &unrelated { + let n = find_tools(task, 5).await.unwrap().none; + assert!( + n > close_none, + "task={} none={} close_none={}", + task, + n, + close_none + ); + } + } + + #[tokio::test] + async fn mcp_call_matches_shell_result() { + let task = "close GUZ-22"; + let k = 5; + let global = crate::Global { + verbose: false, + execute_timeout_secs: 30, + execute_memory_mb: 64, + execute_output_kb: 256, + }; + let flags = super::super::ServeFlags { + discovery: false, + code_mode: false, + }; + let mcp_result = super::super::handle_tools_call( + Some(serde_json::json!({"name":"find_tools","arguments":{"task":task,"k":k}})), + &global, + flags, + ) + .await + .unwrap(); + let shell_value = serde_json::to_value(find_tools(task, k).await.unwrap()).unwrap(); + assert_eq!(mcp_result["structuredContent"], shell_value); + } + + fn test_flags(code_mode: bool) -> super::super::ServeFlags { + super::super::ServeFlags { + discovery: false, + code_mode, + } + } + + fn test_global() -> crate::Global { + crate::Global { + verbose: false, + execute_timeout_secs: 30, + execute_memory_mb: 64, + execute_output_kb: 256, + } + } + + #[tokio::test] + async fn usage_switches_by_code_mode() { + let global = test_global(); + let args = Some(serde_json::json!({ + "name": "find_tools", + "arguments": {"task": "search jira issues"} + })); + let plain = super::super::handle_tools_call(args.clone(), &global, test_flags(false)) + .await + .unwrap(); + assert_eq!( + plain["structuredContent"]["usage"], + serde_json::json!(mcptools_core::find_tools::USAGE) + ); + let code = super::super::handle_tools_call(args, &global, test_flags(true)) + .await + .unwrap(); + assert_eq!( + code["structuredContent"]["usage"], + serde_json::json!(mcptools_core::find_tools::CODE_MODE_USAGE) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn nested_find_tools_usage_follows_flags_inside_execute() { + let global = test_global(); + let args = Some(serde_json::json!({ + "name": "execute", + "arguments": { + "code": "return (await find_tools({task: \"search jira issues\"})).usage" + } + })); + let plain = super::super::handle_tools_call(args.clone(), &global, test_flags(false)) + .await + .unwrap(); + assert_eq!( + plain["structuredContent"]["result"], + serde_json::json!(mcptools_core::find_tools::USAGE) + ); + let code = super::super::handle_tools_call(args, &global, test_flags(true)) + .await + .unwrap(); + assert_eq!( + code["structuredContent"]["result"], + serde_json::json!(mcptools_core::find_tools::CODE_MODE_USAGE) + ); + } + + struct Stub { + status: u16, + headers: Vec<(&'static str, &'static str)>, + body: &'static str, + delay_ms: u64, + } + + impl Stub { + fn ok(body: &'static str) -> Self { + Self { + status: 200, + headers: vec![], + body, + delay_ms: 0, + } + } + } + + async fn spawn_stub(stubs: Vec, hits: Arc) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + loop { + let (mut stream, _) = match listener.accept().await { + Ok(pair) => pair, + Err(_) => return, + }; + let index = hits.fetch_add(1, Ordering::SeqCst); + let stub = &stubs[index.min(stubs.len() - 1)]; + let mut raw = vec![0u8; 65536]; + let mut read = 0usize; + while !raw[..read].windows(4).any(|w| w == b"\r\n\r\n") && read < raw.len() { + match stream.read(&mut raw[read..]).await { + Ok(0) | Err(_) => break, + Ok(n) => read += n, + } + } + let header_text = String::from_utf8_lossy(&raw[..read]).to_string(); + let content_len = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + match name.trim().eq_ignore_ascii_case("content-length") { + true => value.trim().parse::().ok(), + false => None, + } + }) + .unwrap_or(0); + let body_start = raw[..read] + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|i| i + 4) + .unwrap_or(read); + let mut buffered = read.saturating_sub(body_start); + while buffered < content_len { + match stream.read(&mut raw[..]).await { + Ok(0) | Err(_) => break, + Ok(n) => buffered += n, + } + } + if stub.delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(stub.delay_ms)).await; + } + let mut extra = String::new(); + for (name, value) in &stub.headers { + extra.push_str(&format!("{name}: {value}\r\n")); + } + let response = format!( + "HTTP/1.1 {} x\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n{}\r\n{}", + stub.status, + stub.body.len(), + extra, + stub.body + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + url + } + + #[tokio::test] + async fn find_tools_with_uses_jev_on_split_probabilities() { + let hits = Arc::new(AtomicUsize::new(0)); + let body = r#"{"answers":{"tool":{"probabilities":{"jira_update":0.71,"linear_issue_update":0.17,"none":0.10}}}}"#; + let url = spawn_stub(vec![Stub::ok(body)], hits).await; + let cfg = mcptools_core::jev::GatewayConfig { + endpoint: url, + model: "test".into(), + api_key: mcptools_core::jev::Secret::new("k"), + }; + let res = find_tools_with(Ok(Some(cfg)), "mark the ticket done", 5) + .await + .unwrap(); + assert_eq!(res.backend, Backend::Jev); + let names: Vec<_> = res.tools.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"jira_update")); + assert!(names.contains(&"linear_issue_update")); + } + + #[tokio::test] + async fn find_tools_with_falls_back_to_local_on_529() { + let hits = Arc::new(AtomicUsize::new(0)); + let url = spawn_stub( + vec![Stub { + status: 529, + headers: vec![], + body: "", + delay_ms: 0, + }], + hits, + ) + .await; + let cfg = mcptools_core::jev::GatewayConfig { + endpoint: url, + model: "test".into(), + api_key: mcptools_core::jev::Secret::new("k"), + }; + let jev_res = find_tools_with(Ok(Some(cfg)), "close GUZ-22", 5) + .await + .unwrap(); + assert_eq!(jev_res.backend, Backend::Local); + assert_eq!(jev_res.fallback, Some(FallbackReason::HttpStatus)); + let local_res = find_tools_with(Ok(None), "close GUZ-22", 5).await.unwrap(); + assert_eq!(jev_res.tools, local_res.tools); + assert_eq!(local_res.fallback, None); + } + + #[tokio::test] + async fn fallback_on_garbage_body_is_invalid_response() { + let hits = Arc::new(AtomicUsize::new(0)); + let url = spawn_stub(vec![Stub::ok("not json")], hits).await; + let cfg = mcptools_core::jev::GatewayConfig { + endpoint: url, + model: "test".into(), + api_key: mcptools_core::jev::Secret::new("k"), + }; + let res = find_tools_with(Ok(Some(cfg)), "close GUZ-22", 5) + .await + .unwrap(); + assert_eq!(res.backend, Backend::Local); + assert_eq!(res.fallback, Some(FallbackReason::InvalidResponse)); + } + + #[tokio::test] + async fn fallback_on_closed_port_is_unreachable() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let url = format!("http://{}", addr); + let cfg = mcptools_core::jev::GatewayConfig { + endpoint: url, + model: "test".into(), + api_key: mcptools_core::jev::Secret::new("k"), + }; + let res = find_tools_with(Ok(Some(cfg)), "close GUZ-22", 5) + .await + .unwrap(); + assert_eq!(res.backend, Backend::Local); + assert_eq!(res.fallback, Some(FallbackReason::Unreachable)); + } + + #[tokio::test] + async fn fallback_on_401_is_http_status() { + let hits = Arc::new(AtomicUsize::new(0)); + let url = spawn_stub( + vec![Stub { + status: 401, + headers: vec![], + body: "", + delay_ms: 0, + }], + hits, + ) + .await; + let cfg = mcptools_core::jev::GatewayConfig { + endpoint: url, + model: "test".into(), + api_key: mcptools_core::jev::Secret::new("k"), + }; + let res = find_tools_with(Ok(Some(cfg)), "close GUZ-22", 5) + .await + .unwrap(); + assert_eq!(res.backend, Backend::Local); + assert_eq!(res.fallback, Some(FallbackReason::HttpStatus)); + } + + #[tokio::test] + async fn fallback_on_unknown_provider_is_invalid_config() { + let err = mcptools_core::jev::gateway_config(|k| { + (k == "JEV_PROVIDER").then(|| "bogus".to_string()) + }); + let res = find_tools_with(err, "close GUZ-22", 5).await.unwrap(); + assert_eq!(res.backend, Backend::Local); + assert_eq!(res.fallback, Some(FallbackReason::InvalidConfig)); + } + + #[tokio::test] + async fn fallback_on_missing_key_is_invalid_config() { + let err = mcptools_core::jev::gateway_config(|k| { + (k == "JEV_PROVIDER").then(|| "opencode".to_string()) + }); + let res = find_tools_with(err, "close GUZ-22", 5).await.unwrap(); + assert_eq!(res.backend, Backend::Local); + assert_eq!(res.fallback, Some(FallbackReason::InvalidConfig)); + } + + #[tokio::test] + async fn secret_never_leaks() { + let hits = Arc::new(AtomicUsize::new(0)); + let url = spawn_stub( + vec![Stub { + status: 401, + headers: vec![], + body: "", + delay_ms: 0, + }], + hits, + ) + .await; + let sentinel = "sentinel-key-zz99"; + let cfg = mcptools_core::jev::GatewayConfig { + endpoint: url, + model: "test".into(), + api_key: mcptools_core::jev::Secret::new(sentinel), + }; + assert!(!format!("{:?}", cfg).contains(sentinel)); + let res = find_tools_with(Ok(Some(cfg)), "close GUZ-22", 5) + .await + .unwrap(); + let json = serde_json::to_string(&res).unwrap(); + assert!(!json.contains(sentinel)); + assert!(!format!("{:?}", mcptools_core::jev::ConfigError::MissingKey).contains(sentinel)); + assert!(!format!("{:?}", crate::jev::JevError::HttpStatus).contains(sentinel)); + assert!( + !format!("{:?}", mcptools_core::jev::ClassifyError::MalformedBody).contains(sentinel) + ); + } +} diff --git a/crates/mcptools/src/mcp/tools/fixtures/golden-queries.tsv b/crates/mcptools/src/mcp/tools/fixtures/golden-queries.tsv new file mode 100644 index 0000000..0b5ec2f --- /dev/null +++ b/crates/mcptools/src/mcp/tools/fixtures/golden-queries.tsv @@ -0,0 +1,50 @@ +task expected kind +close GUZ-22 linear_issue_update,jira_update in_scope +reopen PROJ-123 jira_update,linear_issue_update in_scope +mark ABC-789 done jira_update,linear_issue_update in_scope +who's assigned to XYZ-456 jira_get,linear_issue_get in_scope +add a note to DEV-101 jira_comment_add,linear_comment_create in_scope +what are people discussing about rust on the news site hn_list_items in_scope +grab that third diagram from the report pdf_image in_scope +sketch out the project directory structure atlas_tree_view in_scope +convert this website to markdown format md_fetch in_scope +which pull requests are waiting for review in bitbucket bitbucket_pr_list in_scope +show the UI overlays from the dev screen ui_annotations_list in_scope +find wiki pages mentioning deployment confluence_search in_scope +who commented and what did they say jira_comment_list in_scope +manufacture a picture of a starry night images_generate in_scope +enhance this image with better color balance images_edit in_scope +what's the sprint calendar jira_sprint_list in_scope +what's blocking this card linear_relation_list in_scope +show me the page structure from this URL md_toc in_scope +get the list of teams linear_team_list in_scope +what issue categories are available linear_state_list in_scope +read hacker news stories hn_read_item in_scope +list sections in the PDF pdf_toc in_scope +fetch webpage content md_fetch in_scope +search jira for open bugs jira_search in_scope +file a new jira ticket jira_create in_scope +change the ticket status to done jira_update in_scope +save this jql query for later jira_query_save in_scope +create a pull request in bitbucket bitbucket_pr_create in_scope +verify the atlas index health atlas_status in_scope +find linear users by name linear_user_list in_scope +bake a chocolate cake NONE unrelated +what is the weather in Montevideo NONE unrelated +teach me how to play the violin NONE unrelated +how do I cook risotto NONE unrelated +tell me about the history of rome NONE unrelated +recommend a good chinese restaurant NONE unrelated +close the window blinds NONE adversarial +list my spotify playlists NONE adversarial +update my macOS version NONE adversarial +read my email inbox NONE adversarial +create a docker container NONE adversarial +search flights to madrid NONE adversarial +mark the ticket done linear_issue_update,jira_update required +table of contents of this pdf pdf_toc required +table of contents of this web page md_toc required +update the jira comment I just listed jira_comment_update required +jira_comment_update jira_comment_update required +pdf_toc pdf_toc required +book a flight to Paris NONE required diff --git a/crates/mcptools/src/mcp/tools/linear.rs b/crates/mcptools/src/mcp/tools/linear.rs index bd3d33d..ef776e3 100644 --- a/crates/mcptools/src/mcp/tools/linear.rs +++ b/crates/mcptools/src/mcp/tools/linear.rs @@ -80,6 +80,7 @@ pub async fn handle_linear_issue_list( global: &crate::Global, ) -> Result { let args: crate::linear::args::IssueListArgs = parse_args(arguments)?; + let fields = args.fields; if global.verbose { eprintln!( "Calling linear_issue_list: team={:?}, project={:?}, limit={:?}", @@ -120,7 +121,10 @@ pub async fn handle_linear_issue_list( cursor = page_info.end_cursor.clone(); } nodes.truncate(50); - super::to_dual_result(IssueListOutput::from(Paginated { nodes, page_info })) + super::to_dual_result_projected( + IssueListOutput::from(Paginated { nodes, page_info }), + fields.as_deref(), + ) } pub async fn handle_linear_comment_list( diff --git a/crates/mcptools/src/mcp/tools/md.rs b/crates/mcptools/src/mcp/tools/md.rs index af18768..88becf7 100644 --- a/crates/mcptools/src/mcp/tools/md.rs +++ b/crates/mcptools/src/mcp/tools/md.rs @@ -4,6 +4,8 @@ use serde::Deserialize; #[derive(Deserialize, JsonSchema)] pub struct MdFetchArgs { + #[serde(default)] + pub fields: Option>, pub url: String, #[serde(default)] pub timeout: Option, @@ -75,6 +77,7 @@ pub async fn handle_md_fetch( if matches!(strategy, crate::md::SelectionStrategy::N) && args.index.is_none() { return Err(invalid_strategy_index()); } + let fields = args.fields; let fetch_data = tokio::task::spawn_blocking(move || { crate::md::fetch_and_convert_data(crate::md::FetchConfig { @@ -102,7 +105,7 @@ pub async fn handle_md_fetch( data: None, })?; - super::to_dual_result(fetch_data) + super::to_dual_result_projected(fetch_data, fields.as_deref()) } pub async fn handle_md_toc( diff --git a/crates/mcptools/src/mcp/tools/mod.rs b/crates/mcptools/src/mcp/tools/mod.rs index a8958e9..040d57c 100644 --- a/crates/mcptools/src/mcp/tools/mod.rs +++ b/crates/mcptools/src/mcp/tools/mod.rs @@ -1,6 +1,8 @@ mod annotations; mod atlas; mod atlassian; +mod execute; +mod find_tools; mod hn; mod images; mod linear; @@ -8,10 +10,14 @@ mod md; mod pdf; pub mod schema; +#[cfg(test)] +mod conformance_tests; + use serde::{Deserialize, Serialize}; // Re-export types needed by tool handlers -pub use super::{JsonRpcError, Tool}; +pub use super::{JsonRpcError, ServeFlags, Tool, ToolKind}; +pub use find_tools::find_tools; // MCP Protocol types for tools #[derive(Debug, Serialize)] @@ -46,6 +52,27 @@ pub struct ToolsList { pub tools: Vec, } +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct ToolAnnotations { + #[serde(rename = "readOnlyHint")] + pub read_only: bool, + #[serde(rename = "destructiveHint")] + pub destructive: bool, +} + +pub fn annotations_for(kind: ToolKind) -> ToolAnnotations { + match kind { + ToolKind::Read => ToolAnnotations { + read_only: true, + destructive: false, + }, + ToolKind::Write | ToolKind::Spend => ToolAnnotations { + read_only: false, + destructive: true, + }, + } +} + #[derive(Debug, Deserialize)] pub struct CallToolParams { pub name: String, @@ -91,6 +118,27 @@ pub fn to_dual_result(value: impl Serialize) -> Result, +) -> Result { + let Some(fields) = fields.filter(|fields| !fields.is_empty()) else { + return to_dual_result(value); + }; + let structured = serde_json::to_value(&value).map_err(|e| JsonRpcError { + code: -32603, + message: format!("Serialization error: {e}"), + data: None, + })?; + let projected = + mcptools_core::projection::project(structured, fields).map_err(|e| JsonRpcError { + code: -32602, + message: format!("Unknown field path: \"{}\"", e.path), + data: None, + })?; + to_dual_result(projected) +} + pub fn handle_initialize() -> Result { let result = InitializeResult { protocol_version: "2025-06-18".to_string(), @@ -111,271 +159,361 @@ pub fn handle_initialize() -> Result { }) } -pub fn handle_tools_list() -> Result { - let tools = vec![ +pub fn registered_tools() -> Vec { + vec![ Tool { - output_schema: schema::output_schema_for::(), + output_schema: schema::projected_output_schema_for::(), name: "jira_search".to_string(), - description: "Search Jira issues using JQL (Jira Query Language) or a saved query. Returns a list of issues matching the query with details like key, summary, status, and assignee. Supports token-based pagination using nextPageToken. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + description: "Search Jira issues using JQL (Jira Query Language) or a saved query. Returns a list of issues matching the query with details like key, summary, status, and assignee. Supports token-based pagination using nextPageToken. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback). Optional fields (array of dotted paths, e.g. [\"issues.key\",\"total\"]) returns only the named fields; omit it for the full output. Keep nextPageToken if you page further.".to_string(), + input_schema: schema::input_schema_for::(), + summary: "Search Jira issues with JQL or a saved query", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "confluence_search".to_string(), description: "Search Confluence pages using CQL (Confluence Query Language). Returns a list of pages matching the query with title, type, URL, and optionally the plain text content. Requires CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, and CONFLUENCE_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Search Confluence pages with CQL", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "hn_read_item".to_string(), description: "Read a HackerNews post and its comments. Accepts HackerNews item ID (e.g., '8863') or full URL (e.g., 'https://news.ycombinator.com/item?id=8863'). Returns post details with paginated comments.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Read a HackerNews post and its comments", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "hn_list_items".to_string(), description: "List HackerNews stories with pagination. Supports different story types: top, new, best, ask, show, job. Returns a paginated list of stories with their details.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List HackerNews stories: top, new, best, ask, show, job", + kind: ToolKind::Read, }, Tool { - output_schema: schema::output_schema_for::(), + output_schema: schema::projected_output_schema_for::(), name: "md_fetch".to_string(), - description: "Fetch a web page using headless Chrome, wait for all XHR requests to complete (network idle), and convert the HTML to Markdown. Supports CSS selector filtering to extract specific page elements. Returns the page title, markdown content, selector metadata, and fetch statistics.".to_string(), - input_schema: schema::input_schema_for::() + description: "Fetch a web page using headless Chrome, wait for all XHR requests to complete (network idle), and convert the HTML to Markdown. Supports CSS selector filtering to extract specific page elements. Returns the page title, markdown content, selector metadata, and fetch statistics. Optional fields (array of dotted paths, e.g. [\"title\",\"content\"]) returns only the named fields; omit it for the full output. Keep the pagination fields if you page further.".to_string(), + input_schema: schema::input_schema_for::(), + summary: "Fetch a web page as Markdown", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "md_toc".to_string(), description: "Extract table of contents from a web page by parsing markdown headings (H1-H6). Fetches the page using headless Chrome, converts to markdown, and extracts all heading levels with their character offsets and limits. Each TOC entry includes char_offset and char_limit values that can be used with md_fetch to extract specific sections. Sections are defined as heading + content until the next same-or-higher-level heading. Supports CSS selector filtering to extract TOC from specific page elements.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List the headings of a web page as a table of contents", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_create".to_string(), description: "Create a new Jira ticket with required summary. Supports optional fields like description, issue type, priority, assignee, and sprint assignment. Returns the created ticket key. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Create a Jira ticket", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_get".to_string(), description: "Get detailed information about a Jira ticket. Returns comprehensive information about a specific issue using its issue key. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Get one Jira ticket by key", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_update".to_string(), description: "Update Jira ticket fields. Supports updating Status, Priority, Type, Assignee, Description (markdown), and Sprint assignment. Can update multiple fields in a single call. Handles status transitions automatically and supports assignee lookup by email, display name, or account ID. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Update a Jira ticket: status (close, reopen), priority, assignee, sprint", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_comment_add".to_string(), description: "Post a comment on a Jira ticket. Supports markdown in the comment body (bold, italic, headings, lists, code blocks, links) which is automatically converted to Atlassian Document Format. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Add a comment to a Jira ticket", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_comment_list".to_string(), description: "List all comments on a Jira ticket. Returns comment details including ID, author, body text, and creation date. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List comments on a Jira ticket", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_comment_update".to_string(), description: "Update an existing comment on a Jira ticket. Supports markdown in the comment body. Use jira_comment_list first to get comment IDs. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Edit a comment on a Jira ticket", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_comment_delete".to_string(), description: "Delete a comment from a Jira ticket by comment ID. Use jira_comment_list first to get comment IDs. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Delete a comment from a Jira ticket", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_sprint_list".to_string(), description: "List sprints for a Jira board. Returns sprint metadata including ID, name, state, and dates. Use this to discover sprint IDs and names before assigning issues to sprints via jira_update or jira_create. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List sprints of a Jira board", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_attachment_list".to_string(), description: "List all attachments on a Jira ticket. Returns attachment metadata including ID, filename, size, MIME type, and creation date. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List attachments on a Jira ticket", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_attachment_download".to_string(), description: "Download a specific attachment from a Jira ticket by attachment ID. Use jira_attachment_list first to get attachment IDs. Saves to a temp file by default, or to a specified output path. Returns the saved file path.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Download one attachment from a Jira ticket to a file", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_attachment_upload".to_string(), description: "Upload one or more files as attachments to a Jira ticket. Accepts an array of local file paths. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback).".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Upload files as attachments to a Jira ticket", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_query_list".to_string(), description: "List all saved Jira queries. Returns a list of query names stored in ~/.config/mcptools/queries/".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List saved Jira JQL queries", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_query_save".to_string(), description: "Save a Jira JQL query with a name for later reuse. Queries are stored in ~/.config/mcptools/queries/ as .jql files.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Save a named Jira JQL query", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_query_delete".to_string(), description: "Delete a saved Jira query by name. Removes the query from ~/.config/mcptools/queries/".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Delete a saved Jira JQL query", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "jira_query_load".to_string(), description: "Load and display the contents of a saved Jira query. Returns the query name and the JQL query text.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Show the JQL text of a saved Jira query", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "bitbucket_pr_list".to_string(), description: "List pull requests for a Bitbucket repository. Returns PR details including ID, title, author, state, and branches. Supports filtering by state and pagination. Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List pull requests of a Bitbucket repository", + kind: ToolKind::Read, }, Tool { - output_schema: schema::output_schema_for::(), + output_schema: schema::projected_output_schema_for::< + mcptools_core::atlassian::bitbucket::PROutput, + >(), name: "bitbucket_pr_read".to_string(), - description: "Read details of a specific Bitbucket pull request including diff, diffstat, and comments. Use lineLimit to control diff output size (default: 500 lines, use -1 for unlimited). Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables.".to_string(), - input_schema: schema::input_schema_for::() + description: "Read details of a specific Bitbucket pull request including diff, diffstat, and comments. Use lineLimit to control diff output size (default: 500 lines, use -1 for unlimited). Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables. Optional fields (array of dotted paths, e.g. [\"id\",\"title\",\"diff_content\"]) returns only the named fields; omit it for the full output.".to_string(), + input_schema: schema::input_schema_for::(), + summary: "Read one Bitbucket pull request with diff and comments", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "bitbucket_pr_create".to_string(), description: "Create a new pull request in a Bitbucket repository. Requires repo, title, and source branch. Optionally specify destination branch (defaults to repo's main branch), description, and whether to close the source branch after merge. Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Create a pull request in a Bitbucket repository", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "bitbucket_workspace_list".to_string(), description: "List Bitbucket workspaces accessible to the authenticated user. Returns workspace slugs and names. Supports pagination. Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List Bitbucket workspaces", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "bitbucket_repo_list".to_string(), description: "List repositories in a Bitbucket workspace. Returns repository names, full names, and clone URLs (SSH and HTTPS). Supports pagination. Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List repositories in a Bitbucket workspace", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "bitbucket_repo_branches".to_string(), description: "List branches in a Bitbucket repository. Returns branch names, latest commit hash, date, message, and author. Supports filtering and sorting. Requires BITBUCKET_USERNAME and BITBUCKET_APP_PASSWORD environment variables.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List branches of a Bitbucket repository", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "ui_annotations_list".to_string(), description: "List all UI annotations from the calendsync dev server. Returns selector, component name, note, and resolution status for each annotation.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List UI annotations from the calendsync dev overlay", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "ui_annotations_get".to_string(), description: "Get a single UI annotation by ID with full details including computed styles, bounding box, and optional screenshot.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Get one UI annotation with styles, bounding box, and screenshot", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "ui_annotations_resolve".to_string(), description: "Mark a UI annotation as resolved with a summary of the changes made.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Mark a UI annotation as resolved with a change summary", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "ui_annotations_clear".to_string(), description: "Clear all UI annotations from the dev server.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Delete all UI annotations", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::<::pdf::DocumentTree>(), name: "pdf_toc".to_string(), description: "Parse a PDF file and return its document tree (table of contents) with section IDs, headings, content previews, and image counts. Use the section IDs with pdf_read to read specific sections.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List the sections of a PDF as a table of contents", + kind: ToolKind::Read, }, Tool { - output_schema: schema::output_schema_for::<::pdf::SectionContent>(), + output_schema: schema::projected_output_schema_for::<::pdf::SectionContent>(), name: "pdf_read".to_string(), - description: "Read a section of a PDF document as Markdown, or the entire document if no section specified. Returns the section title, rendered Markdown text, and image references.".to_string(), - input_schema: schema::input_schema_for::() + description: "Read a section of a PDF document as Markdown, or the entire document if no section specified. Returns the section title, rendered Markdown text, and image references. Optional fields (array of dotted paths, e.g. [\"title\",\"text\"]) returns only the named fields; omit it for the full output.".to_string(), + input_schema: schema::input_schema_for::(), + summary: "Read a PDF section, or the whole PDF, as Markdown", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::<::pdf::PeekContent>(), name: "pdf_peek".to_string(), description: "Sample a text snippet from a PDF section at a given position (beginning, middle, ending, random) without reading the full content. Returns the snippet with total character count so you know how much content remains. Defaults to the whole document if no section specified.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Sample a short text snippet from a PDF section", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "pdf_images".to_string(), description: "List all images in a PDF section or the whole document. Returns image IDs, formats, section IDs, section titles, and page numbers. Use with pdf_image to extract specific images. NOTE: PDFs often reuse decorative images (logos, backgrounds, headers) across many pages — the same image ID will appear on multiple pages. To find meaningful content images (screenshots, diagrams, photos), filter out IDs that repeat across many pages and focus on IDs that appear only within the target section.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "List the images in a PDF or in one section", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "pdf_image".to_string(), description: "Extract a specific image from a PDF document by ID, or pick a random image. Returns the image as base64-encoded data with format information. Optionally scope to a section.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Extract one image from a PDF by id or at random", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::<::pdf::DocumentMetadata>(), name: "pdf_info".to_string(), description: "Get metadata about a PDF document including title, author, page count, and creator.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Get PDF metadata: title, author, page count", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "images_generate".to_string(), description: "Generate images with ChatGPT Images 2.5 (gpt-image-2.5-flare default, gpt-image-2.5-sunburst for premium precision). Text-to-image via POST /v1/images/generations. Saves PNG/JPEG/WebP files and returns paths plus usage. Defaults to the ChatGPT subscription (llm-stream auth.json); pass api=openai with OPENAI_API_KEY for the metered API.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Generate an image from a text prompt", + kind: ToolKind::Spend, }, Tool { output_schema: schema::output_schema_for::(), name: "images_edit".to_string(), description: "Edit ChatGPT Images 2.5 images with a prompt plus 1-16 reference images and optional mask via POST /v1/images/edits. Preserves subject/composition outside the edit. Saves files and returns paths plus usage. Defaults to the ChatGPT subscription (llm-stream auth.json); pass api=openai with OPENAI_API_KEY for the metered API.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Edit an image with a prompt, reference images, and an optional mask", + kind: ToolKind::Spend, }, Tool { output_schema: schema::output_schema_for::(), name: "images_vary".to_string(), description: "Create variations of ChatGPT Images 2.5 images anchored to 1-16 reference images. Same as images_edit with a default variation prompt when prompt is omitted. Defaults to the ChatGPT subscription (llm-stream auth.json); pass api=openai with OPENAI_API_KEY for the metered API.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Create variations of reference images", + kind: ToolKind::Spend, }, Tool { output_schema: schema::output_schema_for::(), name: "atlas_tree_view".to_string(), description: "Browse an annotated directory tree of the codebase. Each entry includes a short description of what the file or directory contains. Use this to navigate unfamiliar codebases — start at the root, then drill into directories of interest.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Browse the annotated directory tree of the codebase", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "atlas_peek".to_string(), description: "Get a detailed summary of a file or directory. For files: long description, extracted symbols with signatures. For directories: long description, children with descriptions, aggregated symbols. Use this after tree_view to understand a specific file before reading it.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Summarize one file or directory of the codebase with its symbols", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "atlas_status".to_string(), description: "Check the health of the Atlas codebase index. Shows when it was last updated, how many files are tracked, and whether descriptions are available.".to_string(), - input_schema: schema::input_schema_for::() + input_schema: schema::input_schema_for::(), + summary: "Check the health of the Atlas codebase index", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_auth_status".to_string(), description: "Show the Linear viewer identity for LINEAR_API_KEY. Returns id, name, and email. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Show the authenticated Linear user", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::< @@ -384,117 +522,232 @@ pub fn handle_tools_list() -> Result { name: "linear_issue_get".to_string(), description: "Get one Linear issue by id or identifier (e.g. GUZ-85). Returns id, identifier, title, URL, state, parent, and blocked-by relations. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Get one Linear issue with its comments and activity", + kind: ToolKind::Read, }, Tool { - output_schema: schema::output_schema_for::(), + output_schema: schema::projected_output_schema_for::< + mcptools_core::linear::IssueListOutput, + >(), name: "linear_issue_list".to_string(), - description: "List Linear issues with filters. Returns nodes with id, identifier, title, state, parent, blocked-by plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), + description: "List Linear issues with filters. Returns nodes with id, identifier, title, state, parent, blocked-by plus pageInfo. Requires LINEAR_API_KEY environment variable. Optional fields (array of dotted paths, e.g. [\"nodes.identifier\",\"nodes.title\",\"pageInfo\"]) returns only the named fields; omit it for the full output. Keep pageInfo if you page further.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List Linear issues by team, state, assignee, project, label, or cycle", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_comment_list".to_string(), description: "List comments on one Linear issue. Returns nodes with id, author, body, createdAt plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List comments on a Linear issue", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_relation_list".to_string(), description: "List relations on one Linear issue. Returns nodes with id, type, issue, related issue, direction plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List relations of a Linear issue", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_team_list".to_string(), description: "List Linear teams. Returns nodes with id, key, name plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List Linear teams", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_team_get".to_string(), description: "Get one Linear team by id, key, or name. Returns id, key, name. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Get one Linear team by id, key, or name", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_project_list".to_string(), description: "List Linear projects in a team. Returns nodes with id, name plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List projects in a Linear team", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_project_get".to_string(), description: "Get one Linear project by id or name within a team. Returns id, name. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Get one Linear project by id or name", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_user_list".to_string(), description: "List Linear users matching a name query. Returns nodes with id, name, email plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Find Linear users by name", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_state_list".to_string(), description: "List workflow states in a Linear team. Returns nodes with id, name, type plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List workflow states of a Linear team", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_label_list".to_string(), description: "List labels in a Linear team. Returns nodes with id, name plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List labels of a Linear team", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_cycle_list".to_string(), description: "List cycles in a Linear team. Returns nodes with id, number, name plus pageInfo. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "List cycles of a Linear team", + kind: ToolKind::Read, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_issue_create".to_string(), description: "Create a Linear issue in a team. Returns id, identifier, title, URL, state, parent. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Create a Linear issue in a team", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_issue_update".to_string(), description: "Update a Linear issue by id or identifier. Needs at least one of title, description, state, assignee, parent, clearParent. Returns the updated issue. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Update a Linear issue: state (close, reopen), title, assignee, parent", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_comment_create".to_string(), description: "Create a comment on a Linear issue. Returns id, body, URL, author, createdAt. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Add a comment to a Linear issue", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_relation_add".to_string(), description: "Add a relation between two Linear issues. Type is 'blocks' or 'related'. Returns status created or already_exists plus the relation. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Add a blocks or related relation between two Linear issues", + kind: ToolKind::Write, }, Tool { output_schema: schema::output_schema_for::(), name: "linear_relation_remove".to_string(), description: "Remove a relation between two Linear issues matched by source, related, type triple. Returns the deleted relation id. Requires LINEAR_API_KEY environment variable.".to_string(), input_schema: schema::input_schema_for::(), + summary: "Remove a relation between two Linear issues", + kind: ToolKind::Write, + }, + Tool { + output_schema: schema::output_schema_for::(), + name: "find_tools".to_string(), + description: "Ranks the tool catalog against a task and returns a TypeScript declaration per tool, and never calls another tool. Every tool it returns is callable by name through tools/call, even when tools/list does not list it.".to_string(), + input_schema: schema::input_schema_for::(), + summary: "Find the best-fitting tools for a task", + kind: ToolKind::Read, }, - ]; + Tool { + output_schema: schema::output_schema_for::(), + name: "execute".to_string(), + description: execute::DESCRIPTION.to_string(), + input_schema: schema::input_schema_for::(), + summary: "Run JavaScript that calls the tools find_tools returns", + kind: ToolKind::Write, + }, + ] +} - let result = ToolsList { tools }; +pub fn listed_tools(tools: Vec, flags: ServeFlags) -> Vec { + if flags.code_mode { + tools + .into_iter() + .filter(|tool| tool.name == "find_tools" || tool.name == "execute") + .collect() + } else if flags.discovery { + tools + .into_iter() + .filter(|tool| tool.name == "find_tools") + .collect() + } else { + tools + } +} - serde_json::to_value(result).map_err(|e| JsonRpcError { +pub fn handle_tools_list(flags: ServeFlags) -> Result { + let tools = listed_tools(registered_tools(), flags); + let kinds: Vec = tools.iter().map(|tool| tool.kind).collect(); + let mut list = serde_json::to_value(ToolsList { tools }).map_err(|e| JsonRpcError { code: -32603, message: format!("Internal error: {e}"), data: None, - }) + })?; + if let Some(entries) = list.get_mut("tools").and_then(|tools| tools.as_array_mut()) { + for (entry, kind) in entries.iter_mut().zip(kinds) { + entry["annotations"] = + serde_json::to_value(annotations_for(kind)).map_err(|e| JsonRpcError { + code: -32603, + message: format!("Internal error: {e}"), + data: None, + })?; + } + } + Ok(list) +} + +pub fn tool_catalog() -> Vec { + mcptools_core::catalog::build_catalog( + registered_tools() + .iter() + .filter(|tool| tool.name != "find_tools" && tool.name != "execute") + .map(|tool| (tool.name.as_str(), tool.summary)), + ) +} + +pub fn declaration(tool: &Tool) -> String { + mcptools_core::ts_decl::tool_declaration( + &tool.name, + &tool.description, + &tool.input_schema, + &tool.output_schema, + ) +} + +pub fn declarations(names: &[String]) -> crate::prelude::Result { + let tools = registered_tools(); + if names.is_empty() { + return Ok(tools.iter().map(declaration).collect::>().join("\n")); + } + let mut blocks = Vec::with_capacity(names.len()); + for name in names { + match tools.iter().find(|tool| &tool.name == name) { + Some(tool) => blocks.push(declaration(tool)), + None => return Err(crate::prelude::eyre!("unknown tool: {name}")), + } + } + Ok(blocks.join("\n")) } pub async fn handle_tools_call( params: Option, global: &crate::Global, + flags: ServeFlags, ) -> Result { let params: CallToolParams = serde_json::from_value(params.unwrap_or(serde_json::Value::Null)) .map_err(|e| JsonRpcError { @@ -597,6 +850,8 @@ pub async fn handle_tools_call( "linear_relation_remove" => { linear::handle_linear_relation_remove(params.arguments, global).await } + "find_tools" => find_tools::handle_find_tools(params.arguments, global, flags).await, + "execute" => execute::handle_execute(params.arguments, global, flags).await, _ => Err(JsonRpcError { code: -32602, message: format!("Unknown tool: {}", params.name), @@ -628,3 +883,536 @@ mod dual_tests { assert!(value.get("isError").is_none()); } } + +#[cfg(test)] +mod catalog_tests { + use super::ServeFlags; + use std::collections::BTreeSet; + + fn serve_flags(discovery: bool) -> ServeFlags { + ServeFlags { + discovery, + code_mode: false, + } + } + + #[test] + fn catalog_names_equal_registered_names_minus_find_tools_and_execute() { + let catalog = super::tool_catalog(); + let registered = super::registered_tools(); + assert_eq!(catalog.len() + 2, registered.len()); + let catalog_names: BTreeSet = catalog.into_iter().map(|entry| entry.name).collect(); + let registry_names: BTreeSet = registered + .into_iter() + .map(|tool| tool.name) + .filter(|n| n != "find_tools" && n != "execute") + .collect(); + assert_eq!(catalog_names, registry_names); + } + + #[test] + fn summaries_fit_the_cap() { + for entry in super::tool_catalog() { + assert!(!entry.summary.is_empty(), "{}", entry.name); + assert!( + entry.summary.chars().count() <= mcptools_core::catalog::SUMMARY_MAX_CHARS, + "{}", + entry.name + ); + } + } + + #[test] + fn domains_equal_the_known_prefixes() { + let catalog = super::tool_catalog(); + let domains: BTreeSet<&str> = catalog.iter().map(|entry| entry.domain.as_str()).collect(); + assert_eq!( + domains, + BTreeSet::from([ + "atlas", + "bitbucket", + "confluence", + "hn", + "images", + "jira", + "linear", + "md", + "pdf", + "ui" + ]) + ); + } + + #[test] + fn tools_list_json_omits_summary() { + let list = super::handle_tools_list(serve_flags(false)).unwrap(); + for tool in list["tools"].as_array().unwrap() { + assert!(tool.get("summary").is_none()); + assert!(tool.get("name").is_some()); + assert!(tool.get("description").is_some()); + assert!(tool.get("inputSchema").is_some()); + assert!(tool.get("outputSchema").is_some()); + } + } + + #[test] + fn mcp_tools_list_includes_find_tools() { + let list = super::handle_tools_list(serve_flags(false)).unwrap(); + let tools = list["tools"].as_array().unwrap(); + assert!(tools + .iter() + .any(|t| t.get("name") == Some(&serde_json::json!("find_tools")))); + } + #[test] + fn registry_holds_sixty_three_tools() { + assert_eq!(super::registered_tools().len(), 63); + } + + const FIND_TOOLS_BUDGET_CHARS: usize = 1300; + const JOINT_BUDGET_TOKENS: usize = 1000; + + #[test] + fn joint_definition_fits_budget() { + let list = super::handle_tools_list(ServeFlags { + discovery: false, + code_mode: true, + }) + .unwrap(); + let tools = list["tools"].as_array().unwrap(); + let mut total = 0; + for name in ["find_tools", "execute"] { + let entry = tools + .iter() + .find(|tool| tool.get("name") == Some(&serde_json::json!(name))) + .unwrap_or_else(|| panic!("{name} missing from code mode tools/list")); + total += mcptools_core::atlas::prompts::estimate_tokens( + &serde_json::to_string(entry).unwrap(), + ); + } + println!("joint tokens = {total} (budget {JOINT_BUDGET_TOKENS})"); + assert!( + total <= JOINT_BUDGET_TOKENS, + "joint tokens = {total} (budget {JOINT_BUDGET_TOKENS})" + ); + } + + #[test] + fn find_tools_definition_fits_budget() { + let list = super::handle_tools_list(serve_flags(true)).unwrap(); + let tools = list["tools"].as_array().unwrap(); + let entry = tools + .iter() + .find(|tool| tool.get("name") == Some(&serde_json::json!("find_tools"))) + .expect("find_tools missing from discovery tools/list"); + let length = serde_json::to_string(entry).unwrap().len(); + assert!( + length <= FIND_TOOLS_BUDGET_CHARS, + "find_tools tools/list entry is {length} chars, budget is {FIND_TOOLS_BUDGET_CHARS}" + ); + } + + #[test] + fn listed_tools_discovery_keeps_only_find_tools() { + let tools = super::listed_tools(super::registered_tools(), serve_flags(true)); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "find_tools"); + } + + #[test] + fn listed_tools_full_mode_is_unchanged() { + let full = super::listed_tools(super::registered_tools(), serve_flags(false)); + let registered = super::registered_tools(); + assert_eq!(full.len(), registered.len()); + assert_eq!( + full.iter().map(|t| &t.name).collect::>(), + registered.iter().map(|t| &t.name).collect::>() + ); + } + + #[test] + fn gate_truth_table_for_flag_combinations() { + let names = |flags: ServeFlags| -> Vec { + let list = super::handle_tools_list(flags).unwrap(); + list["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_string()) + .collect() + }; + assert_eq!(names(serve_flags(false)).len(), 63); + assert_eq!(names(serve_flags(true)), ["find_tools"]); + assert_eq!( + names(ServeFlags { + discovery: false, + code_mode: true + }), + ["find_tools", "execute"] + ); + assert_eq!( + names(ServeFlags { + discovery: true, + code_mode: true + }), + ["find_tools", "execute"] + ); + } + + #[test] + fn annotations_follow_kind() { + assert_eq!( + super::annotations_for(super::ToolKind::Read), + super::ToolAnnotations { + read_only: true, + destructive: false, + } + ); + assert_eq!( + super::annotations_for(super::ToolKind::Write), + super::ToolAnnotations { + read_only: false, + destructive: true, + } + ); + assert_eq!( + super::annotations_for(super::ToolKind::Spend), + super::ToolAnnotations { + read_only: false, + destructive: true, + } + ); + } + + #[test] + fn execute_entry_is_destructive() { + let list = super::handle_tools_list(serve_flags(false)).unwrap(); + let tools = list["tools"].as_array().unwrap(); + let entry = tools + .iter() + .find(|tool| tool.get("name") == Some(&serde_json::json!("execute"))) + .expect("execute missing from tools/list"); + assert_eq!( + entry["annotations"]["destructiveHint"], + serde_json::json!(true) + ); + } + + #[test] + fn full_table_matches_oracle_annotations() { + let write = [ + "jira_create", + "jira_update", + "jira_comment_add", + "jira_comment_update", + "jira_comment_delete", + "jira_attachment_upload", + "jira_query_save", + "jira_query_delete", + "bitbucket_pr_create", + "linear_issue_create", + "linear_issue_update", + "linear_comment_create", + "linear_relation_add", + "linear_relation_remove", + "ui_annotations_resolve", + "ui_annotations_clear", + "execute", + ]; + let spend = ["images_generate", "images_edit", "images_vary"]; + let list = super::handle_tools_list(serve_flags(false)).unwrap(); + let tools = list["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 63); + let entry = |name: &str| { + tools + .iter() + .find(|t| t.get("name") == Some(&serde_json::json!(name))) + .unwrap_or_else(|| panic!("missing tool {name}")) + }; + let mutable = serde_json::json!({"readOnlyHint": false, "destructiveHint": true}); + let readonly = serde_json::json!({"readOnlyHint": true, "destructiveHint": false}); + for name in write.into_iter().chain(spend) { + assert_eq!(entry(name)["annotations"], mutable, "{name}"); + } + let mutable_names: std::collections::BTreeSet<&str> = + write.into_iter().chain(spend).collect(); + let read_count = tools + .iter() + .filter(|t| t["annotations"] == readonly) + .count(); + assert_eq!(read_count, 63 - mutable_names.len()); + } + + #[test] + fn registered_names_match_oracle_sets() { + let write: std::collections::BTreeSet<&str> = [ + "jira_create", + "jira_update", + "jira_comment_add", + "jira_comment_update", + "jira_comment_delete", + "jira_attachment_upload", + "jira_query_save", + "jira_query_delete", + "bitbucket_pr_create", + "linear_issue_create", + "linear_issue_update", + "linear_comment_create", + "linear_relation_add", + "linear_relation_remove", + "ui_annotations_resolve", + "ui_annotations_clear", + "execute", + ] + .into_iter() + .collect(); + let spend: std::collections::BTreeSet<&str> = + ["images_generate", "images_edit", "images_vary"] + .into_iter() + .collect(); + let read: std::collections::BTreeSet<&str> = [ + "jira_search", + "confluence_search", + "hn_read_item", + "hn_list_items", + "md_fetch", + "md_toc", + "jira_get", + "jira_comment_list", + "jira_sprint_list", + "jira_attachment_list", + "jira_attachment_download", + "jira_query_list", + "jira_query_load", + "bitbucket_pr_list", + "bitbucket_pr_read", + "bitbucket_workspace_list", + "bitbucket_repo_list", + "bitbucket_repo_branches", + "ui_annotations_list", + "ui_annotations_get", + "pdf_toc", + "pdf_read", + "pdf_peek", + "pdf_images", + "pdf_image", + "pdf_info", + "atlas_tree_view", + "atlas_peek", + "atlas_status", + "linear_auth_status", + "linear_issue_get", + "linear_issue_list", + "linear_comment_list", + "linear_relation_list", + "linear_team_list", + "linear_team_get", + "linear_project_list", + "linear_project_get", + "linear_user_list", + "linear_state_list", + "linear_label_list", + "linear_cycle_list", + "find_tools", + ] + .into_iter() + .collect(); + let union: std::collections::BTreeSet<&str> = + write.union(&spend).chain(read.iter()).copied().collect(); + assert_eq!(union.len(), 63); + let mut actual_write = BTreeSet::new(); + let mut actual_spend = BTreeSet::new(); + let mut actual_read = BTreeSet::new(); + for tool in super::registered_tools() { + match tool.kind { + super::ToolKind::Write => actual_write.insert(tool.name), + super::ToolKind::Spend => actual_spend.insert(tool.name), + super::ToolKind::Read => actual_read.insert(tool.name), + }; + } + let names = |set: std::collections::BTreeSet<&str>| { + set.into_iter().map(str::to_string).collect::>() + }; + assert_eq!(actual_write, names(write)); + assert_eq!(actual_spend, names(spend)); + assert_eq!(actual_read, names(read)); + } + + #[test] + fn pilot_entries_carry_kind_annotations() { + let list = super::handle_tools_list(serve_flags(false)).unwrap(); + let tools = list["tools"].as_array().unwrap(); + let entry = |name: &str| { + tools + .iter() + .find(|t| t.get("name") == Some(&serde_json::json!(name))) + .unwrap_or_else(|| panic!("missing tool {name}")) + }; + assert_eq!( + entry("jira_search")["annotations"], + serde_json::json!({"readOnlyHint": true, "destructiveHint": false}) + ); + assert_eq!( + entry("jira_create")["annotations"], + serde_json::json!({"readOnlyHint": false, "destructiveHint": true}) + ); + assert_eq!( + entry("images_generate")["annotations"], + serde_json::json!({"readOnlyHint": false, "destructiveHint": true}) + ); + for tool in tools { + let annotations = tool.get("annotations").unwrap(); + assert!(annotations.get("idempotentHint").is_none()); + assert!(annotations.get("openWorldHint").is_none()); + } + } + + #[test] + fn catalog_never_contains_a_tool_named_none() { + let catalog = super::tool_catalog(); + assert!(!catalog.iter().any(|e| e.name == "none")); + } +} + +#[cfg(test)] +mod declaration_tests { + use super::*; + + fn named(name: &str) -> Tool { + super::registered_tools() + .into_iter() + .find(|tool| tool.name == name) + .unwrap() + } + + #[test] + fn jira_search_declaration() { + let tool = named("jira_search"); + let head = "interface JiraSearchInput {\n fields?: string[];\n limit?: number;\n nextPageToken?: string;\n query?: string;\n queryName?: string;\n}\n\ninterface JiraSearchIssueOutput {\n assignee?: string | null;\n description?: string | null;\n key?: string;\n status?: string;\n summary?: string;\n}\n\ninterface JiraSearchOutput {\n issues?: JiraSearchIssueOutput[];\n next_page_token?: string | null;\n total?: number;\n}\n\n"; + let want = + format!("{head}/** {} */\ndeclare function jira_search(input: JiraSearchInput): Promise;\n", tool.description); + assert_eq!(super::declaration(&tool), want); + } + + #[test] + fn pdf_read_declaration() { + let tool = named("pdf_read"); + let head = "interface PdfReadInput {\n fields?: string[];\n path: string;\n sectionId?: string;\n}\n\ntype PdfReadImageFormat = \"Jpeg\" | \"Png\" | \"Jpeg2000\" | \"Gif\" | \"Tiff\" | \"Bmp\" | \"WebP\" | \"Unknown\";\n\ntype PdfReadImageId = string;\n\ninterface PdfReadImageRef {\n format?: PdfReadImageFormat;\n id?: PdfReadImageId;\n}\n\ntype PdfReadSectionId = string;\n\ninterface PdfReadOutput {\n id?: PdfReadSectionId;\n images?: PdfReadImageRef[];\n text?: string;\n title?: string;\n}\n\n"; + let want = + format!("{head}/** {} */\ndeclare function pdf_read(input: PdfReadInput): Promise;\n", tool.description); + assert_eq!(super::declaration(&tool), want); + } + + #[test] + fn linear_issue_list_declaration() { + let tool = named("linear_issue_list"); + let head = "interface LinearIssueListInput {\n /** Fetch all pages (up to 50 items) */\n all?: boolean;\n /** Assignee user UUID or 'me' */\n assignee?: string;\n /** Page cursor for pagination */\n cursor?: string;\n /** Cycle number or id */\n cycle?: string;\n fields?: string[];\n /** Label name */\n label?: string;\n /** Max items per page @default 25 */\n limit?: number;\n /** Project id or name (names need team) */\n project?: string;\n /** Title substring to search */\n query?: string;\n /** Workflow state name (e.g. Todo) */\n state?: string;\n /** Team id, key, or name */\n team?: string;\n /** Only issues updated at or after RFC3339 time (e.g. 2026-01-01T00:00:00Z) */\n updatedAfter?: string;\n}\n\ninterface LinearIssueListIssueMini {\n blocked_by?: string[];\n description?: string | null;\n id?: string;\n identifier?: string;\n parent?: string | null;\n state?: string;\n title?: string;\n url?: string;\n}\n\ninterface LinearIssueListPageInfo {\n endCursor?: string | null;\n hasNextPage?: boolean;\n}\n\ninterface LinearIssueListOutput {\n nodes?: LinearIssueListIssueMini[];\n pageInfo?: LinearIssueListPageInfo;\n}\n\n"; + let want = + format!("{head}/** {} */\ndeclare function linear_issue_list(input: LinearIssueListInput): Promise;\n", tool.description); + assert_eq!(super::declaration(&tool), want); + } + + #[test] + fn md_fetch_declaration() { + let tool = named("md_fetch"); + let head = "interface MdFetchInput {\n /** @default null */\n fields?: string[];\n /** @default null */\n index?: number;\n /** @default null */\n limit?: number;\n /** @default null */\n offset?: number;\n /** @default null */\n page?: number;\n /** @default null */\n raw_html?: boolean;\n /** @default null */\n selector?: string;\n /** @default null */\n strategy?: string;\n /** @default null */\n timeout?: number;\n url: string;\n}\n\ninterface MdFetchMdPaginationInfo {\n current_page?: number;\n has_more?: boolean;\n limit?: number;\n total_characters?: number;\n total_pages?: number;\n}\n\ninterface MdFetchOutput {\n content?: string;\n elements_found?: number | null;\n fetch_time_ms?: number;\n html_length?: number;\n pagination?: MdFetchMdPaginationInfo;\n selector_used?: string | null;\n strategy_applied?: string | null;\n title?: string | null;\n url?: string;\n}\n\n"; + let want = + format!("{head}/** {} */\ndeclare function md_fetch(input: MdFetchInput): Promise;\n", tool.description); + assert_eq!(super::declaration(&tool), want); + } + + #[test] + fn bitbucket_pr_read_declaration() { + let tool = named("bitbucket_pr_read"); + let head = "interface BitbucketPrReadInput {\n diffLimit?: number;\n fields?: string[];\n limit?: number;\n lineLimit?: number;\n noDiff?: boolean;\n prNumber: number;\n repo: string;\n}\n\ninterface BitbucketPrReadCommentOutput {\n author?: string;\n content?: string;\n created_on?: string;\n id?: number;\n inline_line?: number | null;\n inline_path?: string | null;\n is_inline?: boolean;\n}\n\ninterface BitbucketPrReadDiffstatOutput {\n files?: BitbucketPrReadFileStatOutput[];\n total_deletions?: number;\n total_files?: number;\n total_insertions?: number;\n}\n\ninterface BitbucketPrReadFileStatOutput {\n lines_added?: number;\n lines_removed?: number;\n old_path?: string | null;\n path?: string;\n status?: string;\n}\n\ninterface BitbucketPrReadOutput {\n approvals?: string[];\n author?: string;\n comments?: BitbucketPrReadCommentOutput[];\n created_on?: string;\n description?: string | null;\n destination_branch?: string;\n destination_commit?: string | null;\n destination_repo?: string;\n diff_content?: string | null;\n diffstat?: BitbucketPrReadDiffstatOutput;\n html_link?: string;\n id?: number;\n reviewers?: string[];\n source_branch?: string;\n source_commit?: string | null;\n source_repo?: string;\n state?: string;\n title?: string;\n updated_on?: string;\n}\n\n"; + let want = + format!("{head}/** {} */\ndeclare function bitbucket_pr_read(input: BitbucketPrReadInput): Promise;\n", tool.description); + assert_eq!(super::declaration(&tool), want); + } + + #[test] + fn declarations_keep_given_order_and_reject_unknown() { + let names = vec!["pdf_read".to_string(), "jira_search".to_string()]; + let text = super::declarations(&names).unwrap(); + assert!( + text.find("declare function pdf_read").unwrap() + < text.find("declare function jira_search").unwrap() + ); + assert!(text.ends_with(";\n") && !text.ends_with("\n\n")); + + let all = super::declarations(&[]).unwrap(); + assert_eq!( + all.matches("declare function ").count(), + super::registered_tools().len() + ); + + let err = super::declarations(&["nope_tool".to_string()]).unwrap_err(); + assert!(err.to_string().contains("nope_tool")); + } + + fn pascal_case(name: &str) -> String { + name.split('_') + .map(|piece| { + let mut chars = piece.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect() + } + + fn ref_targets(schema: &serde_json::Value, out: &mut Vec) { + match schema { + serde_json::Value::Object(map) => { + if let Some(name) = map + .get("$ref") + .and_then(|link| link.as_str()) + .and_then(|link| link.strip_prefix("#/$defs/")) + { + out.push(name.to_string()); + } + for value in map.values() { + ref_targets(value, out); + } + } + serde_json::Value::Array(items) => { + for item in items { + ref_targets(item, out); + } + } + _ => {} + } + } + + #[test] + fn sweep_covers_every_registered_tool() { + let tools = super::registered_tools(); + let mut unknown_tools = std::collections::BTreeSet::new(); + for tool in &tools { + let text = super::declaration(tool); + let mut targets = vec![]; + ref_targets(&tool.input_schema, &mut targets); + ref_targets(&tool.output_schema, &mut targets); + let prefix = pascal_case(&tool.name); + for target in targets { + let named = format!("{prefix}{target}"); + assert!( + text.contains(&format!("interface {named} ")) + || text.contains(&format!("type {named} =")), + "{}: missing definition for {target}", + tool.name + ); + } + assert!(!text.contains("$ref"), "{}: raw $ref leaked", tool.name); + assert!( + !text.contains("#/$defs"), + "{}: raw #/$defs leaked", + tool.name + ); + if text.contains("unknown") && tool.name != "execute" { + unknown_tools.insert(tool.name.clone()); + } + } + assert_eq!(unknown_tools, std::collections::BTreeSet::new()); + } +} diff --git a/crates/mcptools/src/mcp/tools/pdf.rs b/crates/mcptools/src/mcp/tools/pdf.rs index 0d58738..d7b8532 100644 --- a/crates/mcptools/src/mcp/tools/pdf.rs +++ b/crates/mcptools/src/mcp/tools/pdf.rs @@ -11,6 +11,7 @@ pub struct PdfTocArgs { #[derive(Deserialize, JsonSchema)] pub struct PdfReadArgs { + pub fields: Option>, pub path: String, #[serde(rename = "sectionId")] pub section_id: Option, @@ -104,6 +105,7 @@ pub async fn handle_pdf_read( _global: &crate::Global, ) -> Result { let args: PdfReadArgs = parse_args(arguments)?; + let fields = args.fields; let content = run_blocking(move || { let bytes = std::fs::read(&args.path).map_err(|e| format!("Failed to read file: {e}"))?; @@ -112,7 +114,7 @@ pub async fn handle_pdf_read( }) .await?; - super::to_dual_result(content) + super::to_dual_result_projected(content, fields.as_deref()) } pub async fn handle_pdf_peek( diff --git a/crates/mcptools/src/mcp/tools/schema.rs b/crates/mcptools/src/mcp/tools/schema.rs index 2d30bbf..33031a0 100644 --- a/crates/mcptools/src/mcp/tools/schema.rs +++ b/crates/mcptools/src/mcp/tools/schema.rs @@ -1,21 +1,61 @@ pub fn input_schema_for() -> serde_json::Value { - schema_value::() + schema_value::(true) } pub fn output_schema_for() -> serde_json::Value { - schema_value::() + schema_value::(false) } -fn schema_value() -> serde_json::Value { +pub fn projected_output_schema_for() -> serde_json::Value { + let mut value = output_schema_for::(); + strip_required(&mut value); + value +} + +fn strip_required(value: &mut serde_json::Value) { + let Some(map) = value.as_object_mut() else { + if let Some(items) = value.as_array_mut() { + for item in items { + strip_required(item); + } + } + return; + }; + map.remove("required"); + if let Some(props) = map.get_mut("properties").and_then(|v| v.as_object_mut()) { + for child in props.values_mut() { + strip_required(child); + } + } + if let Some(items) = map.get_mut("items") { + strip_required(items); + } + for key in ["anyOf", "oneOf", "allOf", "prefixItems"] { + if let Some(variants) = map.get_mut(key).and_then(|v| v.as_array_mut()) { + for variant in variants { + strip_required(variant); + } + } + } + for key in ["$defs", "definitions"] { + if let Some(defs) = map.get_mut(key).and_then(|v| v.as_object_mut()) { + for child in defs.values_mut() { + strip_required(child); + } + } + } +} + +fn schema_value(normalize: bool) -> serde_json::Value { let schema = schemars::schema_for!(T); let mut value = serde_json::to_value(&schema).unwrap_or(serde_json::json!({ "type": "object", })); - strip_envelope(&mut value); + strip_envelope(&mut value, normalize); value } -fn strip_envelope(value: &mut serde_json::Value) { +fn strip_envelope(value: &mut serde_json::Value, normalize: bool) { let Some(root) = value.as_object_mut() else { return; }; @@ -24,8 +64,10 @@ fn strip_envelope(value: &mut serde_json::Value) { let Some(props) = root.get_mut("properties").and_then(|v| v.as_object_mut()) else { return; }; - for (_, prop) in props.iter_mut() { - normalize_prop(prop); + if normalize { + for (_, prop) in props.iter_mut() { + normalize_prop(prop); + } } } @@ -153,6 +195,7 @@ mod tests { "assignee", "cursor", "cycle", + "fields", "label", "limit", "project", diff --git a/crates/mcptools/src/open.rs b/crates/mcptools/src/open.rs new file mode 100644 index 0000000..bbc8186 --- /dev/null +++ b/crates/mcptools/src/open.rs @@ -0,0 +1,19 @@ +use crate::prelude::eprintln; + +pub(crate) fn opener() -> &'static str { + if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + } +} + +pub(crate) fn maybe_open(files: &[String], open: bool) { + if !open { + return; + } + match std::process::Command::new(opener()).args(files).status() { + Ok(_) => {} + Err(e) => eprintln!("warning: failed to open: {e}"), + } +} diff --git a/crates/mcptools/src/sandbox.rs b/crates/mcptools/src/sandbox.rs new file mode 100644 index 0000000..36f5883 --- /dev/null +++ b/crates/mcptools/src/sandbox.rs @@ -0,0 +1,499 @@ +use mcptools_core::sandbox::{check_size, Limits, LogBuffer, Run, SandboxError}; +use rquickjs::context::EvalOptions; +use rquickjs::function::Func; +use rquickjs::{AsyncContext, AsyncRuntime, Coerced, Ctx, Exception, FromJs, Promise, Value}; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Instant; + +pub mod bindings; + +pub struct Bindings { + pub names: Vec, + pub global: crate::Global, + pub flags: crate::mcp::ServeFlags, +} + +pub const PRELUDE: &str = r#" +globalThis.console = { + log: (...args) => __log(__fmt(args)), + info: (...args) => __log(__fmt(args)), + warn: (...args) => __log(__fmt(args)), + error: (...args) => __log(__fmt(args)), + debug: (...args) => __log(__fmt(args)), +}; +function __fmt(args) { + return args.map(a => { + if (typeof a === 'string') return a; + if (a instanceof Error) return String(a); + return JSON.stringify(a); + }).join(' '); +} +"#; + +pub async fn run(code: &str, limits: Limits, bindings: Bindings) -> Run { + let code = code.to_string(); + let outcome = tokio::task::spawn_blocking(move || { + tokio::runtime::Handle::current().block_on(run_inner(code, limits, bindings)) + }) + .await; + match outcome { + Ok(run) => run, + Err(err) => Run { + logs: Vec::new(), + outcome: Err(message_error(err.to_string())), + }, + } +} + +async fn run_inner(code: String, limits: Limits, bindings: Bindings) -> Run { + let logs = Rc::new(RefCell::new(LogBuffer::new(limits.output_bytes))); + let stop: Rc>> = Rc::new(Cell::new(None)); + let outcome: Result = { + match AsyncRuntime::new().map_err(js_error) { + Err(err) => Err(err), + Ok(runtime) => { + runtime.set_memory_limit(limits.memory_bytes).await; + let deadline = Instant::now() + limits.timeout; + let handler_stop = stop.clone(); + runtime + .set_interrupt_handler(Some(Box::new(move || { + if handler_stop.get().is_some() { + return true; + } + if Instant::now() >= deadline { + handler_stop.set(Some(StopReason::Timeout)); + return true; + } + false + }))) + .await; + let drive = eval(&runtime, &logs, &stop, code, bindings); + let timed = tokio::time::timeout(limits.timeout, drive).await; + if let Some(reason) = stop.get() { + Err(reason.to_error(&limits)) + } else { + match timed { + Ok(Ok(value)) => Ok(value), + Ok(Err(err)) => Err(err), + Err(_) => Err(SandboxError::Timeout(limits.timeout)), + } + } + } + } + }; + let lines = Rc::try_unwrap(logs) + .expect("context released") + .into_inner() + .into_lines(); + let outcome = match outcome { + Ok(result) => match check_size(&lines, &result, limits.output_bytes) { + Ok(()) => Ok(result), + Err(limit) => Err(limit.into()), + }, + Err(err) => Err(err), + }; + Run { + logs: lines, + outcome, + } +} + +#[derive(Clone, Copy)] +enum StopReason { + Timeout, + OutputLimit, +} + +impl StopReason { + fn to_error(self, limits: &Limits) -> SandboxError { + match self { + Self::Timeout => SandboxError::Timeout(limits.timeout), + Self::OutputLimit => SandboxError::OutputLimit(limits.output_bytes), + } + } +} + +async fn eval( + runtime: &AsyncRuntime, + logs: &Rc>, + stop: &Rc>>, + code: String, + bindings: Bindings, +) -> Result { + let context = AsyncContext::full(runtime).await.map_err(js_error)?; + let buffer = logs.clone(); + let stop = stop.clone(); + context + .with(move |ctx| -> Result<(), SandboxError> { + let log = Func::from(move |ctx: Ctx, line: String| -> rquickjs::Result<()> { + let pushed = buffer.borrow_mut().push(line); + if pushed.is_err() { + stop.set(Some(StopReason::OutputLimit)); + return Err(Exception::throw_range(&ctx, "output limit exceeded")); + } + Ok(()) + }); + ctx.globals() + .set("__log", log) + .map_err(|err| eval_error(&ctx, err))?; + ctx.eval::<(), _>(PRELUDE) + .map_err(|err| eval_error(&ctx, err))?; + bindings::install(&ctx, &bindings).map_err(|err| eval_error(&ctx, err))?; + Ok(()) + }) + .await?; + context + .async_with(async move |ctx| { + let promise: Promise = match eval_script(&ctx, &code) { + Ok(promise) => promise, + Err(SandboxError::Js { name, message }) + if name == "SyntaxError" && message == "return not in a function" => + { + eval_script(&ctx, &format!("(async () => {{ {code} }})()"))? + } + Err(err) => return Err(err), + }; + let wrapper: Value = promise + .into_future() + .await + .map_err(|err| eval_error(&ctx, err))?; + let object = wrapper + .into_object() + .ok_or_else(|| message_error("completion value is not an object".to_string()))?; + let value: Value = object.get("value").map_err(|err| eval_error(&ctx, err))?; + let value = match value.clone().into_promise() { + Some(promise) => promise + .into_future::() + .await + .map_err(|err| eval_error(&ctx, err))?, + None => value, + }; + let json = ctx + .json_stringify(value) + .map_err(|err| eval_error(&ctx, err))?; + match json { + None => Ok(serde_json::Value::Null), + Some(text) => { + let text = text.to_string().map_err(js_error)?; + serde_json::from_str(&text).map_err(|err| message_error(err.to_string())) + } + } + }) + .await +} + +fn eval_script<'js>(ctx: &Ctx<'js>, code: &str) -> Result, SandboxError> { + let mut options = EvalOptions::default(); + options.promise = true; + ctx.eval_with_options(code.as_bytes(), options) + .map_err(|err| eval_error(ctx, err)) +} + +fn message_error(message: String) -> SandboxError { + SandboxError::Js { + name: "Error".to_string(), + message, + } +} + +fn js_error(err: rquickjs::Error) -> SandboxError { + message_error(err.to_string()) +} + +fn eval_error(ctx: &Ctx, err: rquickjs::Error) -> SandboxError { + match err { + rquickjs::Error::Exception => caught_error(ctx), + other => js_error(other), + } +} + +fn caught_error(ctx: &Ctx) -> SandboxError { + let value = ctx.catch(); + let object = value.clone().into_object(); + let name = object + .as_ref() + .and_then(|obj| obj.get::<_, String>("name").ok()) + .unwrap_or_else(|| "Error".to_string()); + let message = object + .and_then(|obj| obj.get::<_, String>("message").ok()) + .unwrap_or_else(|| coerced_string(ctx, &value)); + SandboxError::Js { name, message } +} + +fn coerced_string<'js>(ctx: &Ctx<'js>, value: &Value<'js>) -> String { + Coerced::::from_js(ctx, value.clone()) + .map(|coerced| coerced.0) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + fn small_limits(timeout: Duration) -> Limits { + Limits { + timeout, + memory_bytes: 1024 * 1024, + output_bytes: 4096, + } + } + + fn default_global() -> crate::Global { + use clap::Parser; + #[derive(clap::Parser)] + struct Args { + #[command(flatten)] + global: crate::Global, + } + Args::parse_from(["mcptools"]).global + } + + fn empty_bindings() -> Bindings { + Bindings { + names: Vec::new(), + global: default_global(), + flags: crate::mcp::ServeFlags { + discovery: false, + code_mode: false, + }, + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn plain_expression_yields_its_value() { + let run = run("1 + 1", Limits::default(), empty_bindings()).await; + let outcome = run.outcome.unwrap(); + assert_eq!(outcome, serde_json::json!(2)); + assert!(run.logs.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn console_logs_are_captured() { + let run = run("console.log('a'); 'b'", Limits::default(), empty_bindings()).await; + let outcome = run.outcome.unwrap(); + assert_eq!(run.logs, vec!["a".to_string()]); + assert_eq!(outcome, serde_json::json!("b")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn top_level_await_resolves() { + let run = run( + "await Promise.resolve(3)", + Limits::default(), + empty_bindings(), + ) + .await; + assert_eq!(run.outcome.unwrap(), serde_json::json!(3)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn thrown_type_error_is_mapped() { + let run = run( + "throw new TypeError('x')", + Limits::default(), + empty_bindings(), + ) + .await; + let error = run.outcome.unwrap_err(); + assert_eq!( + error, + SandboxError::Js { + name: "TypeError".to_string(), + message: "x".to_string(), + } + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn thrown_error_keeps_prior_logs() { + let run = run( + "console.log('a'); throw new Error('b')", + Limits::default(), + empty_bindings(), + ) + .await; + assert_eq!(run.logs, vec!["a".to_string()]); + assert_eq!( + run.outcome, + Err(SandboxError::Js { + name: "Error".to_string(), + message: "b".to_string(), + }) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn host_apis_are_unavailable() { + for code in [ + "fetch('http://example.com')", + "require('fs')", + "process.env", + "import('fs')", + "import('std')", + ] { + let error = run(code, Limits::default(), empty_bindings()) + .await + .outcome + .unwrap_err(); + assert!( + matches!(error, SandboxError::Js { .. }), + "{code} => {error:?}" + ); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn fresh_runtime_per_call() { + let first = run("globalThis.x = 1", Limits::default(), empty_bindings()).await; + assert_eq!(first.outcome.unwrap(), serde_json::json!(1)); + let second = run("typeof x", Limits::default(), empty_bindings()).await; + assert_eq!(second.outcome.unwrap(), serde_json::json!("undefined")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tight_loop_stops_at_the_deadline() { + let started = Instant::now(); + let limits = small_limits(Duration::from_millis(100)); + let error = run("while(true){}", limits, empty_bindings()) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::Timeout(Duration::from_millis(100))); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn try_catch_cannot_swallow_the_deadline() { + let limits = small_limits(Duration::from_millis(100)); + let error = run( + "try { while(true){} } catch(e) {} 'x'", + limits, + empty_bindings(), + ) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::Timeout(Duration::from_millis(100))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn yielding_loop_stops_at_the_deadline() { + let limits = small_limits(Duration::from_millis(100)); + let error = run( + "while(true){ await Promise.resolve() }", + limits, + empty_bindings(), + ) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::Timeout(Duration::from_millis(100))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn never_resolving_promise_stops_at_the_deadline() { + let limits = small_limits(Duration::from_millis(100)); + let error = run("await new Promise(()=>{})", limits, empty_bindings()) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::Timeout(Duration::from_millis(100))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn catastrophic_backtracking_stops_at_the_deadline() { + let limits = small_limits(Duration::from_millis(100)); + let error = run( + "/(a+)+$/.test('a'.repeat(40)+'!')", + limits, + empty_bindings(), + ) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::Timeout(Duration::from_millis(100))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn log_flood_hits_output_limit_despite_try_catch() { + let limits = small_limits(Duration::from_millis(100)); + let code = "try { for(;;) console.log('x'.repeat(1000)) } catch(e) {} 'x'"; + let error = run(code, limits, empty_bindings()) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::OutputLimit(4096)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn oversized_result_hits_output_limit() { + let limits = small_limits(Duration::from_millis(100)); + let error = run("'x'.repeat(5000)", limits, empty_bindings()) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::OutputLimit(4096)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn log_at_cap_with_empty_result_succeeds() { + let limits = small_limits(Duration::from_millis(100)); + let run = run( + "console.log('x'.repeat(4094)); ''", + limits, + empty_bindings(), + ) + .await; + let outcome = run.outcome.unwrap(); + assert_eq!(run.logs, vec!["x".repeat(4094)]); + assert_eq!(outcome, serde_json::json!("")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn memory_exhaustion_is_recoverable() { + let limits = small_limits(Duration::from_millis(100)); + let code = "const a=[]; while(true) a.push('x'.repeat(1<<20))"; + let error = run(code, limits, empty_bindings()) + .await + .outcome + .unwrap_err(); + assert!( + matches!(&error, SandboxError::Js { name, .. } if name == "InternalError"), + "{error:?}" + ); + let second = run( + "1+1", + small_limits(Duration::from_millis(100)), + empty_bindings(), + ) + .await; + assert_eq!(second.outcome.unwrap(), serde_json::json!(2)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn busy_loop_does_not_stop_the_ticker() { + let ticks = Arc::new(AtomicUsize::new(0)); + let ticker = { + let ticks = ticks.clone(); + tokio::spawn(async move { + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(10)).await; + ticks.fetch_add(1, Ordering::SeqCst); + } + }) + }; + let limits = small_limits(Duration::from_millis(300)); + let error = run("while(true){}", limits, empty_bindings()) + .await + .outcome + .unwrap_err(); + assert_eq!(error, SandboxError::Timeout(Duration::from_millis(300))); + let count = ticks.load(Ordering::SeqCst); + assert!(count >= 15, "ticks = {count}"); + ticker.await.unwrap(); + } +} diff --git a/crates/mcptools/src/sandbox/bindings.rs b/crates/mcptools/src/sandbox/bindings.rs new file mode 100644 index 0000000..a0d6f13 --- /dev/null +++ b/crates/mcptools/src/sandbox/bindings.rs @@ -0,0 +1,234 @@ +use crate::mcp::tools::handle_tools_call; +use crate::mcp::{JsonRpcError, Tool, ToolKind}; +use crate::sandbox::Bindings; +use rquickjs::function::{Async, Opt}; +use rquickjs::{Ctx, Exception, Function, Value}; + +pub fn bound_names(tools: &[Tool], allowed: &[ToolKind]) -> Vec { + tools + .iter() + .filter(|tool| tool.name != "execute") + .filter(|tool| allowed.contains(&tool.kind)) + .map(|tool| tool.name.clone()) + .collect() +} + +pub fn install<'js>(ctx: &Ctx<'js>, bindings: &Bindings) -> rquickjs::Result<()> { + for name in &bindings.names { + let bound = name.clone(); + let global = bindings.global.clone(); + let flags = bindings.flags; + let function = Function::new( + ctx.clone(), + Async(move |ctx: Ctx<'js>, input: Opt>| { + let name = bound.clone(); + let global = global.clone(); + async move { call(ctx, name, global, flags, input.0).await } + }), + )?; + ctx.globals().set(name, function)?; + } + Ok(()) +} + +async fn call<'js>( + ctx: Ctx<'js>, + name: String, + global: crate::Global, + flags: crate::mcp::ServeFlags, + input: Option>, +) -> rquickjs::Result> { + let arguments = arguments(&ctx, input)?; + match handle_tools_call( + Some(serde_json::json!({ "name": name, "arguments": arguments })), + &global, + flags, + ) + .await + { + Ok(envelope) => { + let value = mcptools_core::sandbox::tool_result(envelope); + let text = serde_json::to_string(&value) + .map_err(|err| Exception::throw_type(&ctx, &err.to_string()))?; + ctx.json_parse(text) + } + Err(err) => Err(throw_rpc(&ctx, err)?), + } +} + +fn arguments<'js>( + ctx: &Ctx<'js>, + input: Option>, +) -> rquickjs::Result> { + let value = match input { + None => return Ok(None), + Some(value) if value.is_undefined() => return Ok(None), + Some(value) => value, + }; + match ctx.json_stringify(value)? { + None => Ok(None), + Some(text) => { + let text = text.to_string()?; + serde_json::from_str(&text) + .map(Some) + .map_err(|err| Exception::throw_type(ctx, &err.to_string())) + } + } +} + +fn throw_rpc<'js>(ctx: &Ctx<'js>, err: JsonRpcError) -> rquickjs::Result { + let exception = Exception::from_message(ctx.clone(), &err.message)?; + exception.as_object().set("code", err.code)?; + if let Some(data) = err.data { + let text = serde_json::to_string(&data) + .map_err(|cause| Exception::throw_type(ctx, &cause.to_string()))?; + let value = ctx.json_parse(text)?; + exception.as_object().set("data", value)?; + } + Ok(exception.throw()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::tools::registered_tools; + use crate::sandbox::run; + use clap::Parser; + use mcptools_core::sandbox::Limits; + use serde_json::json; + + fn test_global() -> crate::Global { + #[derive(Parser)] + struct Args { + #[command(flatten)] + global: crate::Global, + } + Args::parse_from(["mcptools"]).global + } + + fn test_flags() -> crate::mcp::ServeFlags { + crate::mcp::ServeFlags { + discovery: false, + code_mode: false, + } + } + + fn read_bindings() -> Bindings { + Bindings { + names: bound_names(®istered_tools(), &[ToolKind::Read]), + global: test_global(), + flags: test_flags(), + } + } + + fn clear_offline_env() { + for key in [ + "ATLASSIAN_BASE_URL", + "ATLASSIAN_EMAIL", + "ATLASSIAN_API_TOKEN", + "JIRA_BASE_URL", + "JIRA_EMAIL", + "JIRA_API_TOKEN", + "JEV_PROVIDER", + ] { + std::env::remove_var(key); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn read_only_set_has_forty_three_names_and_no_writes() { + let read = bound_names(®istered_tools(), &[ToolKind::Read]); + assert_eq!(read.len(), 43); + assert!(read.contains(&"find_tools".to_string())); + assert!(read.contains(&"jira_search".to_string())); + assert!(!read.contains(&"jira_create".to_string())); + assert!(!read.contains(&"images_generate".to_string())); + let all = bound_names( + ®istered_tools(), + &[ToolKind::Read, ToolKind::Write, ToolKind::Spend], + ); + assert_eq!(all.len(), 62); + } + + #[tokio::test(flavor = "multi_thread")] + async fn excluded_tool_is_undefined() { + let run = run( + "return [typeof jira_create, typeof jira_search]", + Limits::default(), + read_bindings(), + ) + .await; + assert_eq!(run.outcome.unwrap(), json!(["undefined", "function"])); + } + + #[tokio::test(flavor = "multi_thread")] + async fn success_returns_structured_content() { + clear_offline_env(); + let first = run( + "const r = await find_tools({ task: 'search jira issues' }); return Array.isArray(r.tools)", + Limits::default(), + read_bindings(), + ) + .await; + assert_eq!(first.outcome.unwrap(), json!(true)); + let second = run( + "return 'content' in (await find_tools({ task: 'x' }))", + Limits::default(), + read_bindings(), + ) + .await; + assert_eq!(second.outcome.unwrap(), json!(false)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn error_rejects_with_code_and_message() { + clear_offline_env(); + let global = test_global(); + let bindings = Bindings { + names: bound_names(®istered_tools(), &[ToolKind::Read]), + global: global.clone(), + flags: test_flags(), + }; + let run = run( + "try { await jira_search({ jql: 'x' }); return null } catch (e) { return [e.code, e.message, e instanceof Error, e.name] }", + Limits::default(), + bindings, + ) + .await; + let result = run.outcome.unwrap(); + let err = handle_tools_call( + Some(json!({"name": "jira_search", "arguments": {"jql": "x"}})), + &global, + test_flags(), + ) + .await + .unwrap_err(); + assert_eq!(result[0].as_i64(), Some(err.code as i64)); + assert_eq!(result[1].as_str(), Some(err.message.as_str())); + assert_eq!(result[2], json!(true)); + assert_eq!(result[3], json!("Error")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn missing_arguments_reject_with_invalid_params() { + let run = run( + "try { await pdf_info(); return null } catch (e) { return e.code }", + Limits::default(), + read_bindings(), + ) + .await; + assert_eq!(run.outcome.unwrap(), json!(-32602)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn concurrent_calls_resolve() { + clear_offline_env(); + let run = run( + "const r = await Promise.all([find_tools({task:'a'}), find_tools({task:'b'})]); return r.length", + Limits::default(), + read_bindings(), + ) + .await; + assert_eq!(run.outcome.unwrap(), json!(2)); + } +} diff --git a/crates/mcptools/tests/common/mod.rs b/crates/mcptools/tests/common/mod.rs new file mode 100644 index 0000000..9b6e2d4 --- /dev/null +++ b/crates/mcptools/tests/common/mod.rs @@ -0,0 +1,98 @@ +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const CLEARED_ENV: [&str; 10] = [ + "MCPTOOLS_DISCOVERY", + "ATLASSIAN_BASE_URL", + "JEV_PROVIDER", + "JEV_ENDPOINT", + "JEV_MODEL", + "JEV_API_KEY", + "OPENCODE_API_KEY", + "OPENROUTER_API_KEY", + "AI_GATEWAY_API_KEY", + "TYPESAFE_API_KEY", +]; + +pub struct McpServer { + child: Child, + input: ChildStdin, + output: BufReader, +} + +pub fn spawn_server(env: &[(&str, &str)]) -> std::io::Result { + let mut command = Command::new(env!("CARGO_BIN_EXE_mcptools")); + command + .args(["mcp", "stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + for key in CLEARED_ENV { + command.env_remove(key); + } + for (key, value) in env { + command.env(key, value); + } + let mut child = command.spawn()?; + let input = child.stdin.take().expect("piped stdin"); + let output = BufReader::new(child.stdout.take().expect("piped stdout")); + let mut server = McpServer { + child, + input, + output, + }; + server.send(&envelope("initialize", serde_json::json!({})))?; + server.receive()?; + server.send(&serde_json::json!({"jsonrpc": "2.0", "method": "notifications/initialized"}))?; + server.receive()?; + Ok(server) +} + +fn envelope(method: &str, params: serde_json::Value) -> serde_json::Value { + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + serde_json::json!({ + "jsonrpc": "2.0", + "id": NEXT_ID.fetch_add(1, Ordering::Relaxed), + "method": method, + "params": params, + }) +} + +impl McpServer { + fn send(&mut self, message: &serde_json::Value) -> std::io::Result<()> { + writeln!(self.input, "{message}") + } + + fn receive(&mut self) -> std::io::Result { + let mut line = String::new(); + if self.output.read_line(&mut line)? == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "server closed stdout before sending a response", + )); + } + Ok(serde_json::from_str(line.trim())?) + } + + pub async fn request(&mut self, req: serde_json::Value) -> serde_json::Value { + self.send(&req).expect("write request to server stdin"); + self.receive().expect("read response from server stdout") + } + + pub async fn tools_call(&mut self, name: &str, args: serde_json::Value) -> serde_json::Value { + let response = self + .request(envelope( + "tools/call", + serde_json::json!({"name": name, "arguments": args}), + )) + .await; + response["result"].clone() + } +} + +impl Drop for McpServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/crates/mcptools/tests/contract_all.rs b/crates/mcptools/tests/contract_all.rs index 41757d4..1ac6744 100644 --- a/crates/mcptools/tests/contract_all.rs +++ b/crates/mcptools/tests/contract_all.rs @@ -9,6 +9,7 @@ fn binary() -> PathBuf { fn tools_list() -> serde_json::Value { let mut child = Command::new(binary()) .args(["mcp", "stdio"]) + .env_remove("MCPTOOLS_DISCOVERY") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -29,127 +30,6 @@ fn tools_list() -> serde_json::Value { serde_json::from_str(&line).unwrap() } -fn deref<'a>(schema: &'a serde_json::Value, root: &'a serde_json::Value) -> &'a serde_json::Value { - match schema.get("$ref").and_then(|v| v.as_str()) { - Some(r) => { - let mut current = root; - for segment in r - .trim_start_matches('#') - .split('/') - .filter(|s| !s.is_empty()) - { - current = ¤t[segment]; - } - current - } - None => schema, - } -} - -fn matches_type(value: &serde_json::Value, name: &str) -> bool { - match name { - "string" => value.is_string(), - "integer" => value.is_i64() || value.is_u64(), - "number" => value.is_number(), - "boolean" => value.is_boolean(), - "array" => value.is_array(), - "object" => value.is_object(), - "null" => value.is_null(), - _ => true, - } -} - -fn check( - value: &serde_json::Value, - schema: &serde_json::Value, - root: &serde_json::Value, - path: &str, - errors: &mut Vec, -) { - if schema.is_boolean() { - return; - } - let schema = deref(schema, root); - if let Some(branches) = schema.get("anyOf").and_then(|v| v.as_array()) { - let mut probe = Vec::new(); - for branch in branches { - let mut trial = Vec::new(); - check(value, branch, root, path, &mut trial); - if trial.is_empty() { - return; - } - probe.extend(trial); - } - errors.push(format!("{path}: no anyOf branch matched")); - errors.extend(probe); - return; - } - match schema.get("type") { - Some(serde_json::Value::String(name)) => { - if !matches_type(value, name) { - errors.push(format!("{path}: expected {name}, got {value}")); - return; - } - } - Some(serde_json::Value::Array(names)) => { - let ok = names - .iter() - .filter_map(|n| n.as_str()) - .any(|n| matches_type(value, n)); - if !ok { - errors.push(format!("{path}: expected {names:?}, got {value}")); - return; - } - } - _ => {} - } - if let Some(allowed) = schema.get("enum").and_then(|v| v.as_array()) { - if !allowed.contains(value) { - errors.push(format!("{path}: {value} not in {allowed:?}")); - } - } - if !value.is_object() && !value.is_array() { - return; - } - if let Some(required) = schema.get("required").and_then(|v| v.as_array()) { - let object = value.as_object().unwrap(); - for key in required.iter().filter_map(|k| k.as_str()) { - if !object.contains_key(key) { - errors.push(format!("{path}: missing required field {key}")); - } - } - } - if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) { - if let Some(object) = value.as_object() { - for (key, subschema) in properties { - if let Some(field) = object.get(key) { - check(field, subschema, root, &format!("{path}.{key}"), errors); - } - } - } - } - if let Some(items) = schema.get("items") { - if let Some(array) = value.as_array() { - for (index, item) in array.iter().enumerate() { - check(item, items, root, &format!("{path}[{index}]"), errors); - } - } - } - if let Some(prefix) = schema.get("prefixItems").and_then(|v| v.as_array()) { - if let Some(array) = value.as_array() { - for (subschema, item) in prefix.iter().zip(array.iter()) { - check(item, subschema, root, path, errors); - } - } - } -} - -fn validate(value: &serde_json::Value, schema: &serde_json::Value) -> Vec { - let mut errors = Vec::new(); - check(value, schema, schema, "$", &mut errors); - errors -} - fn sample(name: &str) -> serde_json::Value { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") @@ -168,7 +48,7 @@ fn mcp_contract_all() { .and_then(|v| v.get("tools")) .and_then(|v| v.as_array()) .unwrap(); - assert_eq!(tools.len(), 61, "unexpected tools/list length"); + assert_eq!(tools.len(), 63, "unexpected tools/list length"); let mut passed = 0; for tool in tools { let name = tool.get("name").and_then(|v| v.as_str()).unwrap(); @@ -189,7 +69,15 @@ fn mcp_contract_all() { "bad inputSchema type in {name}" ); let recorded = sample(name); - let errors = validate(&recorded, output); + let validator = jsonschema::draft202012::new(output) + .unwrap_or_else(|e| panic!("invalid outputSchema for {name}: {e}")); + let mut errors = Vec::new(); + for error in validator.iter_errors(&recorded) { + let path = error.instance_path().as_str(); + let path = if path.is_empty() { "/" } else { path }; + let msg = error.to_string(); + errors.push(format!("{name}: {path}: {msg}")); + } assert!( errors.is_empty(), "sample failed schema in {name}: {errors:?}" diff --git a/crates/mcptools/tests/contract_atlas.rs b/crates/mcptools/tests/contract_atlas.rs index dc57780..b296b2a 100644 --- a/crates/mcptools/tests/contract_atlas.rs +++ b/crates/mcptools/tests/contract_atlas.rs @@ -41,6 +41,7 @@ fn run_fixture(root: &Path) -> Vec { .env_remove("ATLAS_API") .env_remove("ATLAS_BASE_URL") .env_remove("OLLAMA_URL") + .env_remove("MCPTOOLS_DISCOVERY") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() diff --git a/crates/mcptools/tests/contract_atlassian.rs b/crates/mcptools/tests/contract_atlassian.rs index 77eecce..47ee373 100644 --- a/crates/mcptools/tests/contract_atlassian.rs +++ b/crates/mcptools/tests/contract_atlassian.rs @@ -11,6 +11,7 @@ fn binary() -> PathBuf { fn tools_list() -> serde_json::Value { let mut child = Command::new(binary()) .args(["mcp", "stdio"]) + .env_remove("MCPTOOLS_DISCOVERY") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() diff --git a/crates/mcptools/tests/contract_discovery.rs b/crates/mcptools/tests/contract_discovery.rs new file mode 100644 index 0000000..28bb95b --- /dev/null +++ b/crates/mcptools/tests/contract_discovery.rs @@ -0,0 +1,130 @@ +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_mcptools")) +} + +fn request(id: u64, method: &str, params: serde_json::Value) -> serde_json::Value { + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) +} + +fn run(command: &mut Command, requests: &[serde_json::Value]) -> Vec { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + for req in requests { + writeln!(stdin, "{req}").unwrap(); + } + drop(stdin); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + let mut out = Vec::new(); + for _ in requests { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + out.push(serde_json::from_str(&line).unwrap()); + } + child.wait().unwrap(); + out +} + +fn without_jev_env(command: &mut Command) -> &mut Command { + command + .env_remove("JEV_PROVIDER") + .env_remove("JEV_ENDPOINT") + .env_remove("JEV_MODEL") + .env_remove("JEV_API_KEY") + .env_remove("OPENCODE_API_KEY") + .env_remove("OPENROUTER_API_KEY") + .env_remove("AI_GATEWAY_API_KEY") + .env_remove("TYPESAFE_API_KEY") +} + +#[test] +fn discovery_flag_lists_only_find_tools_then_calls_real_tool() { + let home = tempfile::tempdir().unwrap(); + let mut command = Command::new(binary()); + command.args(["mcp", "stdio", "--discovery"]); + without_jev_env(&mut command); + command + .env("HOME", home.path()) + .env_remove("MCPTOOLS_DISCOVERY"); + + let responses = run( + &mut command, + &[ + request(1, "tools/list", serde_json::json!({})), + request( + 2, + "tools/call", + serde_json::json!({"name": "find_tools", "arguments": {"task": "close GUZ-22"}}), + ), + request( + 3, + "tools/call", + serde_json::json!({"name": "jira_query_list", "arguments": {}}), + ), + ], + ); + + let tools = responses[0]["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "find_tools"); + + let found = &responses[1]["result"]["structuredContent"]; + let found_tools = found["tools"].as_array().unwrap(); + assert!(!found_tools.is_empty()); + for tool in found_tools { + assert!(tool["declaration"] + .as_str() + .is_some_and(|s| s.contains("declare function "))); + } + assert_eq!(found["backend"], "local"); + + assert!(responses[2].get("error").is_none()); + assert_eq!( + responses[2]["result"]["structuredContent"]["queries"], + serde_json::json!([]) + ); +} + +#[test] +fn discovery_env_var_alone_lists_only_find_tools() { + let home = tempfile::tempdir().unwrap(); + let mut command = Command::new(binary()); + command.args(["mcp", "stdio"]); + without_jev_env(&mut command); + command + .env("MCPTOOLS_DISCOVERY", "true") + .env("HOME", home.path()); + + let responses = run( + &mut command, + &[request(1, "tools/list", serde_json::json!({}))], + ); + + let tools = responses[0]["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "find_tools"); +} + +#[test] +fn no_flag_no_env_lists_all_63() { + let mut command = Command::new(binary()); + command.args(["mcp", "stdio"]); + without_jev_env(&mut command); + command.env_remove("MCPTOOLS_DISCOVERY"); + + let responses = run( + &mut command, + &[request(1, "tools/list", serde_json::json!({}))], + ); + + let tools = responses[0]["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 63); +} diff --git a/crates/mcptools/tests/contract_linear.rs b/crates/mcptools/tests/contract_linear.rs index 83d199c..1bb82ac 100644 --- a/crates/mcptools/tests/contract_linear.rs +++ b/crates/mcptools/tests/contract_linear.rs @@ -12,6 +12,7 @@ fn binary() -> PathBuf { fn tools_list() -> serde_json::Value { let mut child = Command::new(binary()) .args(["mcp", "stdio"]) + .env_remove("MCPTOOLS_DISCOVERY") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() diff --git a/crates/mcptools/tests/contract_pilot.rs b/crates/mcptools/tests/contract_pilot.rs index b69033c..0f8fa28 100644 --- a/crates/mcptools/tests/contract_pilot.rs +++ b/crates/mcptools/tests/contract_pilot.rs @@ -9,6 +9,7 @@ fn binary() -> PathBuf { fn tools_list() -> serde_json::Value { let mut child = Command::new(binary()) .args(["mcp", "stdio"]) + .env_remove("MCPTOOLS_DISCOVERY") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -73,6 +74,8 @@ fn mcp_contract_pilot() { "bitbucket_repo_list", "bitbucket_workspace_list", "confluence_search", + "execute", + "find_tools", "hn_list_items", "hn_read_item", "images_edit", diff --git a/crates/mcptools/tests/contract_rest.rs b/crates/mcptools/tests/contract_rest.rs index b824efa..7095c62 100644 --- a/crates/mcptools/tests/contract_rest.rs +++ b/crates/mcptools/tests/contract_rest.rs @@ -9,6 +9,7 @@ fn binary() -> PathBuf { fn tools_list() -> serde_json::Value { let mut child = Command::new(binary()) .args(["mcp", "stdio"]) + .env_remove("MCPTOOLS_DISCOVERY") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -244,7 +245,7 @@ fn recorded_annotation() -> serde_json::Value { fn mcp_contract_rest() { let response = tools_list(); let all = tools(&response); - assert_eq!(all.len(), 61, "unexpected tools/list length"); + assert_eq!(all.len(), 63, "unexpected tools/list length"); for tool in &all { let name = tool.get("name").and_then(|v| v.as_str()).unwrap(); let output = tool diff --git a/crates/mcptools/tests/contract_samples/bitbucket_pr_create.json b/crates/mcptools/tests/contract_samples/bitbucket_pr_create.json index 2506671..845ba62 100644 --- a/crates/mcptools/tests/contract_samples/bitbucket_pr_create.json +++ b/crates/mcptools/tests/contract_samples/bitbucket_pr_create.json @@ -1,9 +1,10 @@ { - "id": 2, - "title": "Feat", - "state": "OPEN", "author": "dev", - "source_branch": "feat", + "description": "New feature", "destination_branch": "main", - "html_link": "https://bitbucket.org/ws/repo/pull-requests/2" -} + "html_link": "https://bitbucket.org/ws/repo/pull-requests/2", + "id": 2, + "source_branch": "feat", + "state": "OPEN", + "title": "Feat" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/bitbucket_pr_list.json b/crates/mcptools/tests/contract_samples/bitbucket_pr_list.json index af4059d..0c5a339 100644 --- a/crates/mcptools/tests/contract_samples/bitbucket_pr_list.json +++ b/crates/mcptools/tests/contract_samples/bitbucket_pr_list.json @@ -1,12 +1,14 @@ { + "next_page": "https://api.bitbucket.org/2.0/repositories/ws/repo/pullrequests?page=2", "pull_requests": [ { - "id": 1, - "title": "Fix", "author": "dev", - "state": "OPEN", + "destination_branch": "main", + "id": 1, "source_branch": "feat", - "destination_branch": "main" + "state": "OPEN", + "title": "Fix" } - ] -} + ], + "total_count": 3 +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/bitbucket_pr_read.json b/crates/mcptools/tests/contract_samples/bitbucket_pr_read.json index cd428c2..3f63dd1 100644 --- a/crates/mcptools/tests/contract_samples/bitbucket_pr_read.json +++ b/crates/mcptools/tests/contract_samples/bitbucket_pr_read.json @@ -1,41 +1,48 @@ { - "id": 1, - "title": "Fix", - "state": "OPEN", - "author": "dev", - "source_branch": "feat", - "destination_branch": "main", - "source_repo": "ws/repo", - "destination_repo": "ws/repo", - "created_on": "2026-01-01T00:00:00Z", - "updated_on": "2026-01-02T00:00:00Z", - "reviewers": [ - "rev" - ], "approvals": [ "rev" ], - "html_link": "https://bitbucket.org/ws/repo/pull-requests/1", + "author": "dev", + "comments": [ + { + "author": "rev", + "content": "nit", + "created_on": "2026-01-02T00:00:00Z", + "id": 7, + "inline_line": 42, + "inline_path": "src/main.rs", + "is_inline": true + } + ], + "created_on": "2026-01-01T00:00:00Z", + "description": "Some PR description", + "destination_branch": "main", + "destination_commit": "def4567890", + "destination_repo": "ws/repo", + "diff_content": "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n- old\n+ new", "diffstat": { "files": [ { - "path": "a.rs", - "status": "modified", "lines_added": 3, - "lines_removed": 1 + "lines_removed": 1, + "old_path": "old/a.rs", + "path": "a.rs", + "status": "modified" } ], + "total_deletions": 1, "total_files": 1, - "total_insertions": 3, - "total_deletions": 1 + "total_insertions": 3 }, - "comments": [ - { - "id": 7, - "author": "rev", - "content": "nit", - "created_on": "2026-01-02T00:00:00Z", - "is_inline": false - } - ] -} + "html_link": "https://bitbucket.org/ws/repo/pull-requests/1", + "id": 1, + "reviewers": [ + "rev" + ], + "source_branch": "feat", + "source_commit": "abc1234567", + "source_repo": "ws/repo", + "state": "OPEN", + "title": "Fix", + "updated_on": "2026-01-02T00:00:00Z" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/bitbucket_repo_branches.json b/crates/mcptools/tests/contract_samples/bitbucket_repo_branches.json index 0f9d1c1..e51f19e 100644 --- a/crates/mcptools/tests/contract_samples/bitbucket_repo_branches.json +++ b/crates/mcptools/tests/contract_samples/bitbucket_repo_branches.json @@ -1,11 +1,13 @@ { "branches": [ { - "name": "main", + "author": "dev", + "commit_date": "2026-01-01T00:00:00Z", "commit_hash": "abc", "commit_message": "init", - "commit_date": "2026-01-01T00:00:00Z", - "author": "dev" + "name": "main" } - ] -} + ], + "next_page": "https://api.bitbucket.org/2.0/repositories/ws/repo/refs/branches?page=2", + "total_count": 1 +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/bitbucket_repo_list.json b/crates/mcptools/tests/contract_samples/bitbucket_repo_list.json index 3e53e51..5ec9921 100644 --- a/crates/mcptools/tests/contract_samples/bitbucket_repo_list.json +++ b/crates/mcptools/tests/contract_samples/bitbucket_repo_list.json @@ -1,9 +1,13 @@ { + "next_page": "https://api.bitbucket.org/2.0/repositories/ws?page=2", "repositories": [ { - "slug": "repo", + "full_name": "ws/repo", + "https_url": "https://bitbucket.org/ws/repo.git", "name": "Repo", - "full_name": "ws/repo" + "slug": "repo", + "ssh_url": "git@bitbucket.org:ws/repo.git" } - ] -} + ], + "total_count": 5 +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/bitbucket_workspace_list.json b/crates/mcptools/tests/contract_samples/bitbucket_workspace_list.json index d89d3d6..5c46307 100644 --- a/crates/mcptools/tests/contract_samples/bitbucket_workspace_list.json +++ b/crates/mcptools/tests/contract_samples/bitbucket_workspace_list.json @@ -1,8 +1,10 @@ { + "next_page": "https://api.bitbucket.org/2.0/workspaces?page=2", + "total_count": 2, "workspaces": [ { - "slug": "ws", - "name": "WS" + "name": "WS", + "slug": "ws" } ] -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/confluence_search.json b/crates/mcptools/tests/contract_samples/confluence_search.json index c9aedcd..70b6c91 100644 --- a/crates/mcptools/tests/contract_samples/confluence_search.json +++ b/crates/mcptools/tests/contract_samples/confluence_search.json @@ -1,10 +1,12 @@ { "pages": [ { + "content": "The content", "id": "123", + "page_type": "page", "title": "Runbook", - "page_type": "page" + "url": "https://example.atlassian.net/wiki/spaces/xx/pages/123" } ], "total": 1 -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/execute.json b/crates/mcptools/tests/contract_samples/execute.json new file mode 100644 index 0000000..725821a --- /dev/null +++ b/crates/mcptools/tests/contract_samples/execute.json @@ -0,0 +1,10 @@ +{ + "logs": [ + "started" + ], + "result": 42, + "error": { + "message": "boom", + "name": "Error" + } +} diff --git a/crates/mcptools/tests/contract_samples/find_tools.json b/crates/mcptools/tests/contract_samples/find_tools.json new file mode 100644 index 0000000..989ba28 --- /dev/null +++ b/crates/mcptools/tests/contract_samples/find_tools.json @@ -0,0 +1,38 @@ +{ + "backend": "local", + "fallback": "http_status", + "none": 0.4758709275361789, + "tools": [ + { + "name": "linear_issue_update", + "domain": "linear", + "score": 0.5241290724638211, + "declaration": "interface LinearIssueUpdateInput {\n /** Assignee user UUID or 'me' */\n assignee?: string;\n /** Clear the parent issue (cannot combine with parent) @default null */\n clearParent?: boolean;\n /** New description */\n description?: string;\n /** Issue id or identifier (e.g. GUZ-85) */\n id: string;\n /** Parent issue id or identifier */\n parent?: string;\n /** Workflow state name or UUID (names resolve from the issue identifier team, or team) */\n state?: string;\n /** Team id, key, or name for state lookup */\n team?: string;\n /** New title */\n title?: string;\n}\n\ninterface LinearIssueUpdateOutput {\n blocked_by?: string[];\n description?: string | null;\n id: string;\n identifier: string;\n parent?: string | null;\n state: string;\n title: string;\n url: string;\n}\n\n/** Update a Linear issue by id or identifier. Needs at least one of title, description, state, assignee, parent, clearParent. Returns the updated issue. Requires LINEAR_API_KEY environment variable. */\ndeclare function linear_issue_update(input: LinearIssueUpdateInput): Promise;\n" + }, + { + "name": "jira_update", + "domain": "jira", + "score": 0.3535401287604917, + "declaration": "interface JiraUpdateInput {\n assignee?: string;\n boardId?: number;\n description?: string;\n issueType?: string;\n priority?: string;\n sprint?: string;\n status?: string;\n ticketKey: string;\n}\n\ninterface JiraUpdateFieldUpdateResult {\n error?: string | null;\n field: string;\n success: boolean;\n value?: string | null;\n}\n\ninterface JiraUpdateOutput {\n fields_updated: JiraUpdateFieldUpdateResult[];\n partial_failure: boolean;\n ticket_key: string;\n}\n\n/** Update Jira ticket fields. Supports updating Status, Priority, Type, Assignee, Description (markdown), and Sprint assignment. Can update multiple fields in a single call. Handles status transitions automatically and supports assignee lookup by email, display name, or account ID. Requires JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN environment variables (or ATLASSIAN_* as fallback). */\ndeclare function jira_update(input: JiraUpdateInput): Promise;\n" + }, + { + "name": "linear_issue_get", + "domain": "linear", + "score": 0.29291974247901664, + "declaration": "interface LinearIssueGetInput {\n /** Issue id or identifier (e.g. GUZ-85) */\n id: string;\n}\n\ninterface LinearIssueGetActivity {\n actor?: string | null;\n summary: string;\n timestamp: string;\n}\n\ninterface LinearIssueGetComment {\n author?: string | null;\n body: string;\n created_at: string;\n id: string;\n url: string;\n}\n\ninterface LinearIssueGetOutput {\n activity?: LinearIssueGetActivity[];\n blocked_by?: string[];\n comments?: LinearIssueGetComment[];\n description?: string | null;\n id: string;\n identifier: string;\n parent?: string | null;\n state: string;\n title: string;\n url: string;\n}\n\n/** Get one Linear issue by id or identifier (e.g. GUZ-85). Returns id, identifier, title, URL, state, parent, and blocked-by relations. Requires LINEAR_API_KEY environment variable. */\ndeclare function linear_issue_get(input: LinearIssueGetInput): Promise;\n" + }, + { + "name": "linear_issue_list", + "domain": "linear", + "score": 0.29291974247901664, + "declaration": "interface LinearIssueListInput {\n /** Fetch all pages (up to 50 items) */\n all?: boolean;\n /** Assignee user UUID or 'me' */\n assignee?: string;\n /** Page cursor for pagination */\n cursor?: string;\n /** Cycle number or id */\n cycle?: string;\n /** Label name */\n label?: string;\n /** Max items per page @default 25 */\n limit?: number;\n /** Project id or name (names need team) */\n project?: string;\n /** Title substring to search */\n query?: string;\n /** Workflow state name (e.g. Todo) */\n state?: string;\n /** Team id, key, or name */\n team?: string;\n /** Only issues updated at or after RFC3339 time (e.g. 2026-01-01T00:00:00Z) */\n updatedAfter?: string;\n}\n\ninterface LinearIssueListIssueMini {\n blocked_by?: string[];\n description?: string | null;\n id: string;\n identifier: string;\n parent?: string | null;\n state: string;\n title: string;\n url: string;\n}\n\ninterface LinearIssueListPageInfo {\n endCursor?: string | null;\n hasNextPage: boolean;\n}\n\ninterface LinearIssueListOutput {\n nodes: LinearIssueListIssueMini[];\n pageInfo: LinearIssueListPageInfo;\n}\n\n/** List Linear issues with filters. Returns nodes with id, identifier, title, state, parent, blocked-by plus pageInfo. Requires LINEAR_API_KEY environment variable. */\ndeclare function linear_issue_list(input: LinearIssueListInput): Promise;\n" + }, + { + "name": "linear_issue_create", + "domain": "linear", + "score": 0.29291974247901664, + "declaration": "interface LinearIssueCreateInput {\n /** Assignee user UUID or 'me' */\n assignee?: string;\n /** Issue description */\n description?: string;\n /** Project id or name (names resolve against team) */\n project?: string;\n /** Workflow state name or UUID */\n state?: string;\n /** Team id, key, or name */\n team: string;\n /** Issue title */\n title: string;\n}\n\ninterface LinearIssueCreateOutput {\n blocked_by?: string[];\n description?: string | null;\n id: string;\n identifier: string;\n parent?: string | null;\n state: string;\n title: string;\n url: string;\n}\n\n/** Create a Linear issue in a team. Returns id, identifier, title, URL, state, parent. Requires LINEAR_API_KEY environment variable. */\ndeclare function linear_issue_create(input: LinearIssueCreateInput): Promise;\n" + } + ], + "usage": "Each declaration is the call signature: the input interface is the tools/call arguments object, the Promise type is the result." +} diff --git a/crates/mcptools/tests/contract_samples/hn_list_items.json b/crates/mcptools/tests/contract_samples/hn_list_items.json index a778e8c..38d22a6 100644 --- a/crates/mcptools/tests/contract_samples/hn_list_items.json +++ b/crates/mcptools/tests/contract_samples/hn_list_items.json @@ -1,18 +1,22 @@ { - "story_type": "top", "items": [ { + "author": "pg", + "comments": 42, "id": 8863, + "score": 100, + "time": "2024-01-01T00:00:00Z", "title": "Sample story", - "score": 100 + "url": "https://example.com" } ], "pagination": { "current_page": 1, - "total_pages": 1, - "total_items": 1, "limit": 10, - "next_page_command": null, - "prev_page_command": null - } -} + "next_page_command": "hn_list_items --page 2 --type top", + "prev_page_command": "hn_list_items --page 1 --type top", + "total_items": 1, + "total_pages": 1 + }, + "story_type": "top" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/hn_read_item.json b/crates/mcptools/tests/contract_samples/hn_read_item.json index c469d14..de155f5 100644 --- a/crates/mcptools/tests/contract_samples/hn_read_item.json +++ b/crates/mcptools/tests/contract_samples/hn_read_item.json @@ -21,7 +21,7 @@ "total_pages": 1, "total_comments": 1, "limit": 10, - "next_page_command": null, - "prev_page_command": null + "next_page_command": "hn_read_item --id 8863 --page 2", + "prev_page_command": "hn_read_item --id 8863 --page 1" } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/images_edit.json b/crates/mcptools/tests/contract_samples/images_edit.json index c81208c..93a9b49 100644 --- a/crates/mcptools/tests/contract_samples/images_edit.json +++ b/crates/mcptools/tests/contract_samples/images_edit.json @@ -3,7 +3,16 @@ "/tmp/img-1.png" ], "model": "gpt-image-1", - "size": "1024x1024", + "output_format": "png", "quality": "high", - "output_format": "png" -} + "size": "1024x1024", + "usage": { + "input_tokens": 10, + "input_tokens_details": { + "image_tokens": 3, + "text_tokens": 7 + }, + "output_tokens": 5, + "total_tokens": 15 + } +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/images_generate.json b/crates/mcptools/tests/contract_samples/images_generate.json index c81208c..93a9b49 100644 --- a/crates/mcptools/tests/contract_samples/images_generate.json +++ b/crates/mcptools/tests/contract_samples/images_generate.json @@ -3,7 +3,16 @@ "/tmp/img-1.png" ], "model": "gpt-image-1", - "size": "1024x1024", + "output_format": "png", "quality": "high", - "output_format": "png" -} + "size": "1024x1024", + "usage": { + "input_tokens": 10, + "input_tokens_details": { + "image_tokens": 3, + "text_tokens": 7 + }, + "output_tokens": 5, + "total_tokens": 15 + } +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/images_vary.json b/crates/mcptools/tests/contract_samples/images_vary.json index c81208c..93a9b49 100644 --- a/crates/mcptools/tests/contract_samples/images_vary.json +++ b/crates/mcptools/tests/contract_samples/images_vary.json @@ -3,7 +3,16 @@ "/tmp/img-1.png" ], "model": "gpt-image-1", - "size": "1024x1024", + "output_format": "png", "quality": "high", - "output_format": "png" -} + "size": "1024x1024", + "usage": { + "input_tokens": 10, + "input_tokens_details": { + "image_tokens": 3, + "text_tokens": 7 + }, + "output_tokens": 5, + "total_tokens": 15 + } +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_comment_add.json b/crates/mcptools/tests/contract_samples/jira_comment_add.json index 7615d31..e78a7d1 100644 --- a/crates/mcptools/tests/contract_samples/jira_comment_add.json +++ b/crates/mcptools/tests/contract_samples/jira_comment_add.json @@ -1,6 +1,7 @@ { - "ticket_key": "PROJ-1", + "author": "Alice", + "body": "Looks good", "comment_id": "10000", "created_at": "2026-01-01T00:00:00Z", - "body": "Looks good" -} + "ticket_key": "PROJ-1" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_comment_list.json b/crates/mcptools/tests/contract_samples/jira_comment_list.json index cab94ac..49216c0 100644 --- a/crates/mcptools/tests/contract_samples/jira_comment_list.json +++ b/crates/mcptools/tests/contract_samples/jira_comment_list.json @@ -1,10 +1,11 @@ { "comments": [ { - "ticket_key": "PROJ-1", + "author": "Alice", + "body": "Looks good", "comment_id": "10000", "created_at": "2026-01-01T00:00:00Z", - "body": "Looks good" + "ticket_key": "PROJ-1" } ] -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_comment_update.json b/crates/mcptools/tests/contract_samples/jira_comment_update.json index c137a8c..af5d538 100644 --- a/crates/mcptools/tests/contract_samples/jira_comment_update.json +++ b/crates/mcptools/tests/contract_samples/jira_comment_update.json @@ -1,6 +1,7 @@ { - "ticket_key": "PROJ-1", + "author": "Alice", + "body": "Edited", "comment_id": "10000", "created_at": "2026-01-01T00:00:00Z", - "body": "Edited" -} + "ticket_key": "PROJ-1" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_create.json b/crates/mcptools/tests/contract_samples/jira_create.json index e68f97a..1d538a3 100644 --- a/crates/mcptools/tests/contract_samples/jira_create.json +++ b/crates/mcptools/tests/contract_samples/jira_create.json @@ -1,13 +1,37 @@ { - "key": "PROJ-1", - "summary": "Sample bug", - "status": "To Do", - "labels": [ - "bug" + "assignee": "Bob", + "attachments": [ + { + "id": "10000", + "filename": "screenshot.png", + "mime_type": "image/png", + "size_bytes": 45678, + "size_human": "45.7 kB", + "created": "2024-01-01T12:00:00Z" + } + ], + "comments": [ + { + "ticket_key": "PROJ-1", + "comment_id": "10001", + "author": "Alice", + "body": "hello world", + "created_at": "2024-01-01T12:00:00Z" + } ], "components": [ "api" ], - "comments": [], - "attachments": [] -} + "created": "2024-01-01T11:00:00Z", + "description": "Detailed bug report here.", + "due_date": "2024-02-01", + "issue_type": "Bug", + "key": "PROJ-1", + "labels": [ + "bug" + ], + "priority": "High", + "status": "To Do", + "summary": "Sample bug", + "updated": "2024-01-02T10:00:00Z" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_get.json b/crates/mcptools/tests/contract_samples/jira_get.json index e68f97a..1d538a3 100644 --- a/crates/mcptools/tests/contract_samples/jira_get.json +++ b/crates/mcptools/tests/contract_samples/jira_get.json @@ -1,13 +1,37 @@ { - "key": "PROJ-1", - "summary": "Sample bug", - "status": "To Do", - "labels": [ - "bug" + "assignee": "Bob", + "attachments": [ + { + "id": "10000", + "filename": "screenshot.png", + "mime_type": "image/png", + "size_bytes": 45678, + "size_human": "45.7 kB", + "created": "2024-01-01T12:00:00Z" + } + ], + "comments": [ + { + "ticket_key": "PROJ-1", + "comment_id": "10001", + "author": "Alice", + "body": "hello world", + "created_at": "2024-01-01T12:00:00Z" + } ], "components": [ "api" ], - "comments": [], - "attachments": [] -} + "created": "2024-01-01T11:00:00Z", + "description": "Detailed bug report here.", + "due_date": "2024-02-01", + "issue_type": "Bug", + "key": "PROJ-1", + "labels": [ + "bug" + ], + "priority": "High", + "status": "To Do", + "summary": "Sample bug", + "updated": "2024-01-02T10:00:00Z" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_search.json b/crates/mcptools/tests/contract_samples/jira_search.json index ff1a8c6..4e7fd0f 100644 --- a/crates/mcptools/tests/contract_samples/jira_search.json +++ b/crates/mcptools/tests/contract_samples/jira_search.json @@ -1,10 +1,13 @@ { "issues": [ { + "assignee": "Bob", + "description": "A bug", "key": "PROJ-1", - "summary": "Sample bug", - "status": "To Do" + "status": "To Do", + "summary": "Sample bug" } ], + "next_page_token": "tok123", "total": 1 -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_sprint_list.json b/crates/mcptools/tests/contract_samples/jira_sprint_list.json index 9df5818..d7ce8a5 100644 --- a/crates/mcptools/tests/contract_samples/jira_sprint_list.json +++ b/crates/mcptools/tests/contract_samples/jira_sprint_list.json @@ -1,11 +1,14 @@ { + "has_more": false, "sprints": [ { + "complete_date": "2024-01-10T00:00:00Z", + "end_date": "2024-01-15T00:00:00Z", "id": 1, "name": "Sprint 1", + "start_date": "2024-01-01T00:00:00Z", "state": "active" } ], - "total": 1, - "has_more": false -} + "total": 1 +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/jira_update.json b/crates/mcptools/tests/contract_samples/jira_update.json index 4c804fa..dc0221e 100644 --- a/crates/mcptools/tests/contract_samples/jira_update.json +++ b/crates/mcptools/tests/contract_samples/jira_update.json @@ -1,11 +1,12 @@ { - "ticket_key": "PROJ-1", "fields_updated": [ { + "error": "none", "field": "summary", "success": true, "value": "New" } ], - "partial_failure": false -} + "partial_failure": false, + "ticket_key": "PROJ-1" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_auth_status.json b/crates/mcptools/tests/contract_samples/linear_auth_status.json index 2931a57..6a3c1f7 100644 --- a/crates/mcptools/tests/contract_samples/linear_auth_status.json +++ b/crates/mcptools/tests/contract_samples/linear_auth_status.json @@ -1,4 +1,5 @@ { + "email": "dev@example.com", "id": "u1", "name": "Dev" -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_comment_create.json b/crates/mcptools/tests/contract_samples/linear_comment_create.json index f26bc7b..73d815a 100644 --- a/crates/mcptools/tests/contract_samples/linear_comment_create.json +++ b/crates/mcptools/tests/contract_samples/linear_comment_create.json @@ -1,6 +1,7 @@ { - "id": "c1", + "author": "bob", "body": "Nice", - "url": "https://linear.app/acme/comment/c1", - "created_at": "2026-01-01T00:00:00Z" -} + "created_at": "2026-01-01T00:00:00Z", + "id": "c1", + "url": "https://linear.app/acme/comment/c1" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_comment_list.json b/crates/mcptools/tests/contract_samples/linear_comment_list.json index d6e2d63..376cdef 100644 --- a/crates/mcptools/tests/contract_samples/linear_comment_list.json +++ b/crates/mcptools/tests/contract_samples/linear_comment_list.json @@ -1,13 +1,15 @@ { "nodes": [ { - "id": "c1", + "author": "bob", "body": "Nice", - "url": "https://linear.app/acme/comment/c1", - "created_at": "2026-01-01T00:00:00Z" + "created_at": "2026-01-01T00:00:00Z", + "id": "c1", + "url": "https://linear.app/acme/comment/c1" } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_cycle_list.json b/crates/mcptools/tests/contract_samples/linear_cycle_list.json index 8a79c01..37867ed 100644 --- a/crates/mcptools/tests/contract_samples/linear_cycle_list.json +++ b/crates/mcptools/tests/contract_samples/linear_cycle_list.json @@ -2,10 +2,12 @@ "nodes": [ { "id": "c1", + "name": "Cycle 3", "number": 3 } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_issue_create.json b/crates/mcptools/tests/contract_samples/linear_issue_create.json index 4b94dd9..fb0b458 100644 --- a/crates/mcptools/tests/contract_samples/linear_issue_create.json +++ b/crates/mcptools/tests/contract_samples/linear_issue_create.json @@ -1,7 +1,12 @@ { + "blocked_by": [ + "b1" + ], + "description": "Wire the thing.", "id": "9d1a2b3c-0000-4000-8000-000000000001", "identifier": "GUZ-79", + "parent": "p1", + "state": "In Progress", "title": "Wire the thing", - "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing", - "state": "In Progress" -} + "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_issue_get.json b/crates/mcptools/tests/contract_samples/linear_issue_get.json index 4b94dd9..f773c11 100644 --- a/crates/mcptools/tests/contract_samples/linear_issue_get.json +++ b/crates/mcptools/tests/contract_samples/linear_issue_get.json @@ -1,7 +1,28 @@ { + "activity": [ + { + "actor": "bob", + "timestamp": "2024-01-01T12:00:00Z", + "summary": "created" + } + ], + "blocked_by": [ + "other-id" + ], + "comments": [ + { + "id": "c1", + "body": "hi", + "url": "https://linear.app/acme/issue/GUZ-79/comment/c1", + "author": "bob", + "created_at": "2024-01-01T12:00:00Z" + } + ], + "description": "Wire the thing.", "id": "9d1a2b3c-0000-4000-8000-000000000001", "identifier": "GUZ-79", + "parent": "parent-id", + "state": "In Progress", "title": "Wire the thing", - "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing", - "state": "In Progress" -} + "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_issue_list.json b/crates/mcptools/tests/contract_samples/linear_issue_list.json index 0350b0f..1175218 100644 --- a/crates/mcptools/tests/contract_samples/linear_issue_list.json +++ b/crates/mcptools/tests/contract_samples/linear_issue_list.json @@ -1,14 +1,20 @@ { "nodes": [ { + "blocked_by": [ + "b1" + ], + "description": "Wire the thing.", "id": "9d1a2b3c-0000-4000-8000-000000000001", "identifier": "GUZ-79", + "parent": "p1", + "state": "In Progress", "title": "Wire the thing", - "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing", - "state": "In Progress" + "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing" } ], "pageInfo": { + "endCursor": "cursor123", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_issue_update.json b/crates/mcptools/tests/contract_samples/linear_issue_update.json index 4b94dd9..fb0b458 100644 --- a/crates/mcptools/tests/contract_samples/linear_issue_update.json +++ b/crates/mcptools/tests/contract_samples/linear_issue_update.json @@ -1,7 +1,12 @@ { + "blocked_by": [ + "b1" + ], + "description": "Wire the thing.", "id": "9d1a2b3c-0000-4000-8000-000000000001", "identifier": "GUZ-79", + "parent": "p1", + "state": "In Progress", "title": "Wire the thing", - "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing", - "state": "In Progress" -} + "url": "https://linear.app/acme/issue/GUZ-79/wire-the-thing" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_label_list.json b/crates/mcptools/tests/contract_samples/linear_label_list.json index 5d75032..a876447 100644 --- a/crates/mcptools/tests/contract_samples/linear_label_list.json +++ b/crates/mcptools/tests/contract_samples/linear_label_list.json @@ -6,6 +6,7 @@ } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_project_list.json b/crates/mcptools/tests/contract_samples/linear_project_list.json index 37fe4a9..b6920d8 100644 --- a/crates/mcptools/tests/contract_samples/linear_project_list.json +++ b/crates/mcptools/tests/contract_samples/linear_project_list.json @@ -6,6 +6,7 @@ } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_relation_list.json b/crates/mcptools/tests/contract_samples/linear_relation_list.json index 6f8989a..35e1d56 100644 --- a/crates/mcptools/tests/contract_samples/linear_relation_list.json +++ b/crates/mcptools/tests/contract_samples/linear_relation_list.json @@ -1,14 +1,15 @@ { "nodes": [ { + "direction": "out", "id": "r1", - "rel_type": "blocks", "issue": "GUZ-79", - "related_issue": "GUZ-80", - "direction": "out" + "rel_type": "blocks", + "related_issue": "GUZ-80" } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_state_list.json b/crates/mcptools/tests/contract_samples/linear_state_list.json index 3587320..69ab7d4 100644 --- a/crates/mcptools/tests/contract_samples/linear_state_list.json +++ b/crates/mcptools/tests/contract_samples/linear_state_list.json @@ -7,6 +7,7 @@ } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_team_list.json b/crates/mcptools/tests/contract_samples/linear_team_list.json index 9190bb2..fed0b8d 100644 --- a/crates/mcptools/tests/contract_samples/linear_team_list.json +++ b/crates/mcptools/tests/contract_samples/linear_team_list.json @@ -7,6 +7,7 @@ } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/linear_user_list.json b/crates/mcptools/tests/contract_samples/linear_user_list.json index 9bb151b..3a95f68 100644 --- a/crates/mcptools/tests/contract_samples/linear_user_list.json +++ b/crates/mcptools/tests/contract_samples/linear_user_list.json @@ -1,11 +1,13 @@ { "nodes": [ { + "email": "dev@example.com", "id": "u1", "name": "Dev" } ], "pageInfo": { + "endCursor": "curs", "hasNextPage": false } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/md_fetch.json b/crates/mcptools/tests/contract_samples/md_fetch.json index 1808245..1edfb41 100644 --- a/crates/mcptools/tests/contract_samples/md_fetch.json +++ b/crates/mcptools/tests/contract_samples/md_fetch.json @@ -4,11 +4,14 @@ "content": "# Hello", "html_length": 128, "fetch_time_ms": 12, + "elements_found": 1, "pagination": { "current_page": 1, "total_pages": 1, "total_characters": 7, "limit": 1000, "has_more": false - } -} + }, + "selector_used": "h1", + "strategy_applied": "first" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/md_toc.json b/crates/mcptools/tests/contract_samples/md_toc.json index 301e1c2..d937e01 100644 --- a/crates/mcptools/tests/contract_samples/md_toc.json +++ b/crates/mcptools/tests/contract_samples/md_toc.json @@ -9,5 +9,7 @@ "char_limit": 5 } ], - "fetch_time_ms": 12 -} + "elements_found": 1, + "fetch_time_ms": 12, + "selector_used": "h1" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/pdf_info.json b/crates/mcptools/tests/contract_samples/pdf_info.json index cd8158e..795f9f5 100644 --- a/crates/mcptools/tests/contract_samples/pdf_info.json +++ b/crates/mcptools/tests/contract_samples/pdf_info.json @@ -1,4 +1,6 @@ { + "author": "Alice", + "creator": "pdfgen", "page_count": 3, "title": "Doc" -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/pdf_peek.json b/crates/mcptools/tests/contract_samples/pdf_peek.json index eb0b748..5426500 100644 --- a/crates/mcptools/tests/contract_samples/pdf_peek.json +++ b/crates/mcptools/tests/contract_samples/pdf_peek.json @@ -1,6 +1,7 @@ { - "title": "Doc", - "snippet": "Hello", + "id": "s-1-0", "position": "Beginning", + "snippet": "Hello", + "title": "Doc", "total_chars": 100 -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/pdf_toc.json b/crates/mcptools/tests/contract_samples/pdf_toc.json index 980bdc1..d829b59 100644 --- a/crates/mcptools/tests/contract_samples/pdf_toc.json +++ b/crates/mcptools/tests/contract_samples/pdf_toc.json @@ -1,37 +1,54 @@ { - "title": "Doc", - "metadata": { - "page_count": 3 - }, - "sections": [ - { - "id": "s-1-0", - "level": 1, - "title": "Intro", - "children": [], - "content_preview": "Hello", - "char_count": 5, - "image_count": 0, - "page_range": [ - 1, - 1 - ] - } - ], "index": { "entries": [ { + "char_count": 5, "id": "s-1-0", + "image_count": 0, "level": 1, - "title": "Intro", "path": [ "Intro" ], - "char_count": 5, - "image_count": 0 + "title": "Intro" } ] }, + "metadata": { + "author": "Alice", + "creator": "pdfgen", + "page_count": 3, + "title": "Doc Title" + }, + "sections": [ + { + "char_count": 5, + "children": [ + { + "char_count": 2, + "children": [], + "content_preview": "sub", + "id": "s-1-0-1", + "image_count": 0, + "level": 2, + "page_range": [ + 1, + 1 + ], + "title": "Sub" + } + ], + "content_preview": "Hello", + "id": "s-1-0", + "image_count": 0, + "level": 1, + "page_range": [ + 1, + 1 + ], + "title": "Intro" + } + ], + "title": "Doc", "total_chars": 5, "total_images": 0 -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/ui_annotations_get.json b/crates/mcptools/tests/contract_samples/ui_annotations_get.json index 60448fb..da77438 100644 --- a/crates/mcptools/tests/contract_samples/ui_annotations_get.json +++ b/crates/mcptools/tests/contract_samples/ui_annotations_get.json @@ -1,28 +1,30 @@ { - "id": "abc-123", - "timestamp": "2026-03-03T17:40:15Z", - "tag_name": "div", - "text_content": "Hello", - "element_path": "div.grid > div.entry", - "comment": "Fix the font", - "is_fixed": false, - "component_name": "Entry", "bounding_box": { - "top": 10.0, + "height": 50.0, "left": 20.0, - "width": 100.0, - "height": 50.0 + "top": 10.0, + "width": 100.0 }, + "comment": "Fix the font", + "component_name": "Entry", "computed_styles": { - "color": "rgb(0, 0, 0)", "background_color": "rgb(255, 255, 255)", - "font_size": "16px", + "color": "rgb(0, 0, 0)", + "display": "flex", "font_family": "Inter", - "padding": "8px", - "margin": "0px", - "width": "200px", + "font_size": "16px", "height": "40px", - "display": "flex", - "position": "relative" - } -} + "margin": "0px", + "padding": "8px", + "position": "relative", + "width": "200px" + }, + "element_path": "div.grid > div.entry", + "id": "abc-123", + "is_fixed": false, + "resolution_summary": "Fixed the font size.", + "screenshot": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "tag_name": "div", + "text_content": "Hello", + "timestamp": "2026-03-03T17:40:15Z" +} \ No newline at end of file diff --git a/crates/mcptools/tests/contract_samples/ui_annotations_list.json b/crates/mcptools/tests/contract_samples/ui_annotations_list.json index 3d3d265..36a4ae0 100644 --- a/crates/mcptools/tests/contract_samples/ui_annotations_list.json +++ b/crates/mcptools/tests/contract_samples/ui_annotations_list.json @@ -1,37 +1,41 @@ { "annotations": [ { - "id": "abc-123", - "timestamp": "2026-03-03T17:40:15Z", - "tag_name": "div", - "text_content": "Hello", - "element_path": "div.grid > div.entry", - "comment": "Fix the font", - "is_fixed": false, - "component_name": "Entry", "bounding_box": { - "top": 10.0, + "height": 50.0, "left": 20.0, - "width": 100.0, - "height": 50.0 + "top": 10.0, + "width": 100.0 }, + "comment": "Fix the font", + "component_name": "Entry", "computed_styles": { - "color": "rgb(0, 0, 0)", "background_color": "rgb(255, 255, 255)", - "font_size": "16px", + "color": "rgb(0, 0, 0)", + "display": "flex", "font_family": "Inter", - "padding": "8px", - "margin": "0px", - "width": "200px", + "font_size": "16px", "height": "40px", - "display": "flex", - "position": "relative" - } + "margin": "0px", + "padding": "8px", + "position": "relative", + "width": "200px" + }, + "element_path": "div.grid > div.entry", + "id": "abc-123", + "is_fixed": false, + "resolution_summary": "Fixed the font size.", + "screenshot": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + "tag_name": "div", + "text_content": "Hello", + "timestamp": "2026-03-03T17:40:15Z" } ], "summary": { - "total": 1, + "acknowledged": 0, + "dismissed": 0, "pending": 1, - "resolved": 0 + "resolved": 0, + "total": 1 } -} +} \ No newline at end of file diff --git a/crates/mcptools/tests/e2e_fields_projection.rs b/crates/mcptools/tests/e2e_fields_projection.rs new file mode 100644 index 0000000..5024cbb --- /dev/null +++ b/crates/mcptools/tests/e2e_fields_projection.rs @@ -0,0 +1,121 @@ +mod common; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn fixture_path(dir: &std::path::Path) -> String { + let fixture = dir.join("chain.pdf"); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/chain.pdf"), + &fixture, + ) + .expect("copy fixture into temp dir"); + fixture.to_str().expect("utf-8 temp path").to_string() +} + +#[tokio::test] +async fn fields_projection_pdf_read_projects_output() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = fixture_path(dir.path()); + let mut server = common::spawn_server(&[]).expect("spawn mcptools mcp stdio"); + + let result = server + .tools_call( + "pdf_read", + serde_json::json!({"path": path, "fields": ["id", "title"]}), + ) + .await; + + let structured = result["structuredContent"] + .as_object() + .expect("structured object"); + let mut keys: Vec<&str> = structured.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!(keys, ["id", "title"]); + let text = result["content"][0]["text"].as_str().expect("text body"); + let parsed: serde_json::Value = serde_json::from_str(text).expect("text parses as JSON"); + assert_eq!(parsed, result["structuredContent"]); +} + +#[tokio::test] +async fn fields_projection_pdf_read_without_fields_returns_full_output() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = fixture_path(dir.path()); + let mut server = common::spawn_server(&[]).expect("spawn mcptools mcp stdio"); + + let result = server + .tools_call("pdf_read", serde_json::json!({"path": path})) + .await; + + let structured = result["structuredContent"] + .as_object() + .expect("structured object"); + let mut keys: Vec<&str> = structured.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!(keys, ["id", "images", "text", "title"]); +} + +#[tokio::test] +async fn fields_projection_pdf_read_unknown_field_path_errors() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = fixture_path(dir.path()); + let mut server = common::spawn_server(&[]).expect("spawn mcptools mcp stdio"); + + let response = server + .request(serde_json::json!({ + "jsonrpc": "2.0", + "id": 99, + "method": "tools/call", + "params": { + "name": "pdf_read", + "arguments": {"path": path, "fields": ["bogus"]} + } + })) + .await; + + assert_eq!(response["error"]["code"], serde_json::json!(-32602)); + assert_eq!( + response["error"]["message"], + serde_json::json!("Unknown field path: \"bogus\"") + ); +} + +fn fixture(body: &str) -> ResponseTemplate { + let json: serde_json::Value = serde_json::from_str(body).expect("fixture parses as JSON"); + ResponseTemplate::new(200).set_body_json(json) +} + +#[tokio::test] +async fn fields_projection_jira_search_issues() { + let stub = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/api/3/search/jql")) + .respond_with(fixture(include_str!("fixtures/jira/search.json"))) + .mount(&stub) + .await; + let stub_uri = stub.uri(); + let mut server = common::spawn_server(&[ + ("JIRA_BASE_URL", stub_uri.as_str()), + ("JIRA_EMAIL", "test@example.com"), + ("JIRA_API_TOKEN", "test-token"), + ]) + .expect("spawn mcptools mcp stdio"); + + let result = server + .tools_call( + "jira_search", + serde_json::json!({"query": "project = ISS", "fields": ["issues.key", "total"]}), + ) + .await; + + let structured = &result["structuredContent"]; + assert_eq!( + structured["issues"], + serde_json::json!([{"key": "ISS-1"}, {"key": "ISS-2"}, {"key": "ISS-3"}]) + ); + assert_eq!(structured["total"], serde_json::json!(3)); + assert!(structured.get("next_page_token").is_none()); + let text = result["content"][0]["text"].as_str().expect("text body"); + let parsed: serde_json::Value = serde_json::from_str(text).expect("text parses as JSON"); + assert_eq!(parsed, result["structuredContent"]); +} diff --git a/crates/mcptools/tests/e2e_jira_chain.rs b/crates/mcptools/tests/e2e_jira_chain.rs new file mode 100644 index 0000000..5c72447 --- /dev/null +++ b/crates/mcptools/tests/e2e_jira_chain.rs @@ -0,0 +1,109 @@ +mod common; + +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const CHAIN_SCRIPT: &str = r#" +const sprints = await jira_sprint_list({ boardId: 1 }); +const issues = await jira_search({ query: "sprint in openSprints()" }); +let n = 0; +for (const i of issues.issues) { + const updated = await jira_update({ ticketKey: i.key, description: i.summary }); + if (updated.partial_failure) throw new Error("jira_update failed"); + n++; +} +console.log(n); +"#; + +fn fixture(body: &str) -> ResponseTemplate { + let json: serde_json::Value = serde_json::from_str(body).expect("fixture parses as JSON"); + ResponseTemplate::new(200).set_body_json(json) +} + +async fn start_jira_stub() -> MockServer { + let stub = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/agile/1.0/board/1/sprint")) + .respond_with(fixture(include_str!("fixtures/jira/sprint.json"))) + .mount(&stub) + .await; + Mock::given(method("GET")) + .and(path("/rest/api/3/search/jql")) + .respond_with(fixture(include_str!("fixtures/jira/search.json"))) + .mount(&stub) + .await; + Mock::given(method("PUT")) + .and(path_regex(r"^/rest/api/3/issue/ISS-[0-9]+$")) + .respond_with(fixture(include_str!("fixtures/jira/update.json"))) + .mount(&stub) + .await; + stub +} + +#[tokio::test] +async fn jira_chain_updates_every_search_result() { + let stub = start_jira_stub().await; + let stub_uri = stub.uri(); + let mut server = common::spawn_server(&[ + ("JIRA_BASE_URL", stub_uri.as_str()), + ("JIRA_EMAIL", "test@example.com"), + ("JIRA_API_TOKEN", "test-token"), + ]) + .expect("spawn mcptools mcp stdio"); + let result = server + .tools_call( + "execute", + serde_json::json!({"code": CHAIN_SCRIPT, "allowWrites": true}), + ) + .await; + assert_eq!( + result["structuredContent"]["logs"], + serde_json::json!(["3"]), + "execute result: {result}" + ); + assert_eq!( + result["structuredContent"]["result"], + serde_json::Value::Null + ); + let payload = result.to_string(); + assert!(!payload.contains("ISS-"), "issue keys leaked: {payload}"); + let requests = stub.received_requests().await.expect("request log"); + let puts = requests + .iter() + .filter(|request| { + request.method.as_str() == "PUT" && request.url.path().starts_with("/rest/api/3/issue/") + }) + .count(); + assert_eq!(puts, 3); +} + +#[tokio::test] +async fn jira_chain_no_writes_rejects_before_any_put() { + let stub = start_jira_stub().await; + let stub_uri = stub.uri(); + let mut server = common::spawn_server(&[ + ("JIRA_BASE_URL", stub_uri.as_str()), + ("JIRA_EMAIL", "test@example.com"), + ("JIRA_API_TOKEN", "test-token"), + ]) + .expect("spawn mcptools mcp stdio"); + let result = server + .tools_call("execute", serde_json::json!({"code": CHAIN_SCRIPT})) + .await; + assert_eq!(result["isError"], serde_json::json!(true)); + let error = result["structuredContent"]["error"]["message"] + .as_str() + .expect("error message"); + assert!( + error.contains("pass allowWrites: true to execute"), + "error message: {error}" + ); + let requests = stub.received_requests().await.expect("request log"); + let puts = requests + .iter() + .filter(|request| { + request.method.as_str() == "PUT" && request.url.path().starts_with("/rest/api/3/issue/") + }) + .count(); + assert_eq!(puts, 0); +} diff --git a/crates/mcptools/tests/e2e_pdf_chain.rs b/crates/mcptools/tests/e2e_pdf_chain.rs new file mode 100644 index 0000000..2b1f0ab --- /dev/null +++ b/crates/mcptools/tests/e2e_pdf_chain.rs @@ -0,0 +1,57 @@ +mod common; + +const SECTION_A: &str = "s-1-0"; +const SECTION_B: &str = "s-1-1"; +const SECTION_B_MARKER: &str = "PDF_CHAIN_SECTION_B_MARKER"; +const PDF_SCRIPT: &str = "const toc = await pdf_toc({ path: PDF_PATH });\nconst sectionB = await pdf_read({ path: PDF_PATH, sectionId: SECTION_B });\nconst imgs = await pdf_images({ path: PDF_PATH, sectionId: SECTION_A });\nconsole.log(toc.sections.length, sectionB.text.includes(SECTION_B_MARKER), imgs.images.length);"; + +#[tokio::test] +async fn execute_script_scopes_pdf_calls_by_section() { + let dir = tempfile::tempdir().expect("temp dir"); + let fixture = dir.path().join("chain.pdf"); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/chain.pdf"), + &fixture, + ) + .expect("copy fixture into temp dir"); + let path = fixture.to_str().expect("utf-8 temp path").to_string(); + + let mut server = common::spawn_server(&[]).expect("spawn mcptools mcp stdio"); + + let code = format!( + "const PDF_PATH = {path:?};\nconst SECTION_A = {SECTION_A:?};\nconst SECTION_B = {SECTION_B:?};\nconst SECTION_B_MARKER = {SECTION_B_MARKER:?};\n{PDF_SCRIPT}" + ); + let executed = server + .tools_call("execute", serde_json::json!({"code": code})) + .await; + assert_eq!( + executed["structuredContent"]["logs"], + serde_json::json!(["2 true 1"]) + ); + assert_eq!( + executed["structuredContent"]["result"], + serde_json::json!(null) + ); + + let section_b_images = server + .tools_call( + "pdf_images", + serde_json::json!({"path": path.as_str(), "sectionId": SECTION_B}), + ) + .await; + assert_eq!( + section_b_images["structuredContent"]["images"], + serde_json::json!([]) + ); + + let all_images = server + .tools_call("pdf_images", serde_json::json!({"path": path.as_str()})) + .await; + assert_eq!( + all_images["structuredContent"]["images"] + .as_array() + .expect("images array") + .len(), + 1 + ); +} diff --git a/crates/mcptools/tests/e2e_smoke.rs b/crates/mcptools/tests/e2e_smoke.rs new file mode 100644 index 0000000..5d3da95 --- /dev/null +++ b/crates/mcptools/tests/e2e_smoke.rs @@ -0,0 +1,48 @@ +mod common; + +#[tokio::test] +async fn tools_list_includes_execute_and_find_tools() { + let mut server = common::spawn_server(&[]).expect("spawn mcptools mcp stdio"); + let response = server + .request(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + })) + .await; + let tools = response["result"]["tools"] + .as_array() + .expect("tools/list result contains a tools array"); + let names: Vec<&str> = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + assert!(names.contains(&"execute"), "execute missing: {names:?}"); + assert!( + names.contains(&"find_tools"), + "find_tools missing: {names:?}" + ); +} + +#[tokio::test] +async fn tools_call_returns_structured_result() { + let home = tempfile::tempdir().expect("temp home"); + let mut server = + common::spawn_server(&[("HOME", home.path().to_str().expect("utf-8 home path"))]) + .expect("spawn mcptools mcp stdio"); + let result = server + .tools_call( + "find_tools", + serde_json::json!({"task": "search jira issues"}), + ) + .await; + let structured = result["structuredContent"] + .as_object() + .expect("structuredContent object"); + assert_eq!(structured["backend"], "local"); + assert!(!structured["tools"] + .as_array() + .expect("tools array") + .is_empty()); +} diff --git a/crates/mcptools/tests/fixtures/chain.pdf b/crates/mcptools/tests/fixtures/chain.pdf new file mode 100644 index 0000000..458f2ca Binary files /dev/null and b/crates/mcptools/tests/fixtures/chain.pdf differ diff --git a/crates/mcptools/tests/fixtures/jira/search.json b/crates/mcptools/tests/fixtures/jira/search.json new file mode 100644 index 0000000..e446ae2 --- /dev/null +++ b/crates/mcptools/tests/fixtures/jira/search.json @@ -0,0 +1,30 @@ +{ + "issues": [ + { + "key": "ISS-1", + "fields": { + "summary": "First tracked issue", + "status": { "name": "To Do" }, + "assignee": { "displayName": "Ada Lovelace" } + } + }, + { + "key": "ISS-2", + "fields": { + "summary": "Second tracked issue", + "status": { "name": "In Progress" }, + "assignee": { "displayName": "Grace Hopper" } + } + }, + { + "key": "ISS-3", + "fields": { + "summary": "Third tracked issue", + "status": { "name": "Done" }, + "assignee": null + } + } + ], + "total": 3, + "isLast": true +} diff --git a/crates/mcptools/tests/fixtures/jira/sprint.json b/crates/mcptools/tests/fixtures/jira/sprint.json new file mode 100644 index 0000000..7ff74f1 --- /dev/null +++ b/crates/mcptools/tests/fixtures/jira/sprint.json @@ -0,0 +1,14 @@ +{ + "maxResults": 50, + "startAt": 0, + "isLast": true, + "values": [ + { + "id": 7, + "name": "Current Sprint", + "state": "active", + "startDate": "2026-09-01T09:00:00.000Z", + "endDate": "2026-09-24T09:00:00.000Z" + } + ] +} diff --git a/crates/mcptools/tests/fixtures/jira/update.json b/crates/mcptools/tests/fixtures/jira/update.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/crates/mcptools/tests/fixtures/jira/update.json @@ -0,0 +1 @@ +{}