diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5ad1e3cc87..2837eafd68 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,7 +203,7 @@ export interface Config { } ``` -Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:24`](../packages/code-runtime/code-runtime-worker/src/index.ts) +Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../packages/code-runtime/code-runtime-worker/src/index.ts) ## `@deepseek-ai/dsh-compact-basic` @@ -419,6 +419,57 @@ export interface Config { Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-mcp-client` + +Requires: `tools` + +```ts config-catalog +/** Discriminated union of all supported MCP transport configurations. */ +export type Config = StdioConfig | StreamableHttpConfig + +/** Config for connecting to an MCP server via a spawned child process over stdio. */ +export interface StdioConfig { + /** Transport type: spawn a child process and communicate over stdio. */ + transport: 'stdio' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string + /** Executable to spawn. */ + command: string + /** Arguments passed to the command. */ + args: string[] + /** Extra env vars merged on top of scrubbed ambient env. */ + env: Record + /** Working directory for the child process. */ + cwd: string + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} + +/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ +export interface StreamableHttpConfig { + /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ + transport: 'streamable-http' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string + /** MCP server URL. */ + url: string + /** Extra headers (e.g. auth tokens). */ + headers: Record + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} +``` + +Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts) + ## `@deepseek-ai/dsh-permission` Requires: `bash` · `approval` @@ -794,7 +845,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:147`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f5dc3b7a89..83888c02e1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -82,7 +82,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:31`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) @@ -239,7 +239,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:213`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index cb1661e02f..9237a3cce9 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -39,8 +39,8 @@ interface CodeRunResult { * or value-less run leaves this absent. */ value?: unknown - /** Everything the program emitted, in order (capped by the implementation). */ - logs: CodeLogEntry[] + /** Text the program emitted, in order (capped by the implementation). */ + logs: string[] /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ error?: CodeRunFailure } @@ -65,18 +65,7 @@ type CodeBindingFunction = (args: unknown) => Promise ## Captured output and the failure taxonomy -Logs arrive in emission order, attributed to their channel (the runtime's `console` shim, or stray writes to the underlying streams): - -```ts type-equiv -interface CodeLogEntry { - /** Which channel produced the text. */ - source: 'console' | 'stdout' | 'stderr' - /** The console method used; present only when `source` is `'console'`. */ - level?: 'log' | 'info' | 'warn' | 'error' | 'debug' - /** The captured text (possibly truncated by the implementation's caps, marked in-band). */ - text: string -} -``` +Logs are plain strings in emission order. The runtime captures the program's console and stream output, but channel and console-method metadata are not part of the seam because consumers render only the text. Implementations cap the aggregate output and mark truncation in-band. Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: diff --git a/docs/module-graph.md b/docs/module-graph.md index 7ceab41537..1b804e5214 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -115,6 +115,9 @@ flowchart TD subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end + subgraph group_mcp["packages/mcp"] + pkg_mcp_client["mcp-client"] + end subgraph group_sandbox["packages/sandbox"] pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] @@ -270,6 +273,8 @@ flowchart TD pkg_tool_ask_user --> pkg_user_interaction pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_tools + pkg_mcp_client --> pkg_llm + pkg_mcp_client --> pkg_tools pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_llm pkg_tool_workflow --> pkg_system_prompt @@ -407,6 +412,7 @@ flowchart TD | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 2f7967100a..a374e795bc 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -71,6 +71,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The approval seam — one-shot permission decisions over a waterfall of answerers](implemented/feature/2026-07-06-approval-seam.md) | 2026-07-06 | | [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 | | [The subprocess sandbox — confinement seam, native runners, escalation, and per-session modes](implemented/feature/2026-07-06-sandbox.md) | 2026-07-06 | +| [MCP client plugin — connect to external MCP servers and bridge their tools](implemented/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 | | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md new file mode 100644 index 0000000000..1be5b225fe --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -0,0 +1,212 @@ +# RFC: MCP client plugin — connect to external MCP servers and bridge their tools + +Status: implemented + +## Problem + +The harness had no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code. + +The `ToolRegistry` already accepts raw JSON Schema tool definitions (documented in `dsh-tools` README: "Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"), and the extension cookbook sketches the intended pattern ("MCP | one plugin per server: discover tools → `ctx.tools.register()`"). The infrastructure was ready; the bridge plugin was missing. + +## Decision + +### Package + +A single package `@deepseek-ai/dsh-mcp-client` at `packages/mcp/mcp-client/`. No capability-seam three-package split — there is no foreseeable second MCP client implementation, and the convention is "don't split preemptively" ([capability seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md)). + +### SDK + +Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to `@agentclientprotocol/sdk`. + +### Scope + +MCP Client only (no server side — ACP already covers the "expose harness as an agent" role). Bridge **Tools** only — Resources and Prompts are deferred (they require harness-side consumption mechanisms that don't exist yet, and design space is large). + +### Plugin shape + +Namespace plugin (named exports `name`/`inject`/`Config`/`apply`, no `export default`). `inject: ['tools']`. Each MCP server is one plugin instance in `cordis.yml` — the same package loaded N times with different configs, like `dsh-tool-subagent`. + +### Configuration + +Flat discriminated union on the `transport` field: + +```typescript +interface StdioConfig { + transport: 'stdio' + serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$ + command: string + args?: string[] + env?: Record + cwd?: string + toolCallTimeoutMs?: number // default 60_000 +} + +interface StreamableHttpConfig { + transport: 'streamable-http' + serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$ + url: string + headers?: Record + toolCallTimeoutMs?: number // default 60_000 +} + +type Config = StdioConfig | StreamableHttpConfig +``` + +`serverName` is the stable local identity that namespaces this server's tools in the model-facing name (below). It is deliberately user configuration, NOT the remote `serverInfo.name`: the remote name is untrusted input, is not unique across deployments (prod and staging instances of one server report the same name), and may change on server upgrade — none of which may silently rename model-facing tools. A duplicate `serverName` across live instances is a configuration error: the later instance fails at load with an actionable message, never silent shadowing or skipping. A short `serverName` (`gh`) is also the knob for shortening public names. + +Example `cordis.yml` usage: + +```yaml +- id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: github + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN + +- id: mcp-web + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: web + transport: streamable-http + url: http://localhost:3000/mcp + headers: + Authorization: !!js `Bearer ${process.env.MCP_TOKEN}` +``` + +The model sees `mcp__github__create_issue`, `mcp__github__search_code`, `mcp__web__search`. + +### Lifecycle + +Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now. Public names are pure functions of `(serverName, rawName)`, so an HMR swap that keeps `serverName` recreates identical model-facing names — session history and permission rules stay valid — and adding or removing an unrelated server never renames an existing tool. + +### Tool discovery and registration + +Every MCP tool has two names: + +- `rawName` — the exact MCP `Tool.name`, used only on the wire (`tools/call`). +- `publicName` — the globally unique model-facing name registered in the `ToolRegistry`: + + mcp____ + +This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp____` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`). + +1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. +2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. +3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name. +4. No `presentCall`/`presentResult` — the ACP bridge's generic-card fallback handles rendering. +5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself. + +### Public name normalization + +MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name: + +```typescript +function publicToolName(serverName: string, rawName: string): string { + const joined = `mcp__${serverName}__${rawName}` + const normalized = joined.replace(/[^A-Za-z0-9_-]/g, '_') + if (normalized === joined && normalized.length <= 64) return normalized + const hash = sha256(`${serverName}\0${rawName}`).slice(0, 12) + return `${normalized.slice(0, 64 - 13)}_${hash}` +} +``` + +### Name conflict handling + +MCP guarantees tool-name uniqueness only [within one server](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names); cross-server collisions are the norm, not the exception (a [Microsoft Research survey](https://www.microsoft.com/en-us/research/blog/tool-space-interference-in-the-mcp-era-designing-for-agent-compatibility-at-scale/#namespacing-issues-and-naming-ambiguity) of 1,470 servers found 775 colliding tool names; `search` alone appears in 32 servers, and the official GitHub server publishes bare `create_issue`). The always-on namespace makes collisions structurally impossible instead of handling them at collision time: + +- Two servers publishing `search` coexist as `mcp__github__search` and `mcp__web__search`. +- A native harness tool named `search` is unaffected. +- Duplicate `serverName` config fails the later instance at load (see Configuration). +- A server listing the same tool name twice is an invalid tool list: the sync throws and the previous generation stays registered. +- A registry conflict during the swap can only mean a foreign tool squats on this server's `mcp____` namespace: the partial generation is rolled back (zero tools from this server) and the error is logged loudly. + +Tools are never silently skipped; which tools are available never depends on plugin load order. + +### Naming invariants + +1. Every MCP tool has the stable identity `(serverName, rawName)`; every active identity has exactly one public name. +2. Public names are deterministic, globally unique, and satisfy the DeepSeek 64-char `[A-Za-z0-9_-]` contract. +3. MCP `tools/call` always receives the original raw name. +4. Connecting, disconnecting, or re-syncing an unrelated server never renames an existing tool. +5. Registration order never determines which tool is available. + +### Tool execution + +A unified `execute` handler for all tools from one MCP server: + +1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server. +2. Map the result: + - Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries). + - `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md)). + - `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`). +3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server. + +### Subprocess environment (stdio transport) + +Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub. + +### Disconnection / crash + +No auto-reconnect. If the MCP server process exits or the transport closes: + +1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers). +2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`. +3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness. + +This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry." + +## Alternatives considered + +### MCP Server side (expose harness tools to external MCP clients) + +Deferred. The ACP bridge already exposes the harness as an agent server. Adding an MCP server layer would duplicate that with a different protocol, and the primary user need is consuming external tools, not exposing them. + +### Capability-seam three-package split (interface / impl / consumer) + +Rejected. There is no foreseeable alternative MCP client implementation — MCP has one protocol, one SDK. The convention is "don't split preemptively" until a second implementation appears. + +### Auto-reconnect with exponential backoff + +Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed. + +### Bridge Resources and Prompts + +Deferred. Resources need a harness-side mechanism to decide WHEN to inject content (system prompt? on demand? model-triggered?). Prompts need a "prompt template" concept the harness lacks. Both require their own design; Tools are the high-value, low-risk starting point. + +### Raw model-facing tool names with an optional `toolPrefix` + +Rejected — this was the original proposal, built on the premise that "most MCP servers already use semantic prefixes in their tool names (e.g. `github_create_issue`)". The premise is false: the official GitHub server publishes `create_issue`, the reference filesystem server `read_file`, Sentry `search_issues` — and the Microsoft survey above shows collisions are common at ecosystem scale. Collision-time prefixing (or warn-and-skip) also makes the available tool set depend on plugin load order, and a tool could be silently renamed when an unrelated server is added — invalidating session history and permission rules mid-conversation. No surveyed multi-server agent product ships raw names. + +### Server-only namespace (`github__create_issue`, no `mcp__` marker) + +Rejected for v1. It prevents cross-server collisions but does not separate MCP registrations from native harness tools, and it forfeits MCP-wide policy shapes (`mcp__*`). The marker costs 5 characters; the `mcp____` spelling matches Claude Code and Codex, maximizing model familiarity. If the ToolRegistry later grows source-aware namespaces, dropping the literal marker can be revisited as a naming-policy change. + +### Deriving the namespace from the server-announced `serverInfo.name` + +Rejected. The remote name is untrusted, non-unique across deployments, and changeable on upgrade; tool identity and permission rules must not silently follow it. The namespace is local configuration. + +### Preserve multiple TextBlocks in tool result + +Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit. + +## Testing + +Coverage is named per tier; each behavior lives at the cheapest tier that can express it. + +- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package. +- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal. +- **Snapshot**: deliberately none. MCP tools introduce no new transcript surface — they register as raw `ToolDefinition`s and render through the ACP bridge's generic-card fallback, which the bridge's unit suite already pins (`packages/ui/acp/tests/stream-update.spec.ts`). Adding an MCP server to the snapshot example's `cordis.yml` would mutate the pinned `text-turn` system-prompt fixture (forcing a with-key re-record of every recorded golden) and make every replay depend on spawning an external MCP server process — for zero new rendering behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then. + +## Consequences + +- A `cordis.yml` entry per MCP server is the entire integration cost: `serverName: filesystem` + a stdio command (or a Streamable HTTP URL) puts `mcp__filesystem__read_file` in the model's tool list, callable, with the raw `read_file` on the wire. +- Public names are part of session history and permission/config surfaces; the naming algorithm is a v1 contract pinned by tests, and changing it after release is a breaking change. +- The `mcp____` qualifier costs tokens on every name. Accepted: descriptions and JSON schemas dominate tool-definition tokens, and the qualifier buys stable identity, collision isolation, and MCP-wide policy shapes (`mcp__*`, `mcp__github__*`). +- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. +- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's. +- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. +- Crash recovery is manual (HMR edit or restart) — accepted for v1; a `reconnect` config remains open as future work. diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 1962ff8d9e..779844067a 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -21,7 +21,7 @@ {"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} {"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} -{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index b251bacddd..b68703ad48 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -87,7 +87,7 @@ {"type":"assistant/message","seq":85,"time":1783611775503,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the `run_code` tool to execute a program that calls `tools.bash` with the command `echo BOTH_OK` and returns its output."},{"type":"tool-call","id":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}],"usage":{"inputTokens":5530,"outputTokens":108,"cacheReadTokens":0,"reasoningTokens":37}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84],"surfaceOp":"append"} {"type":"tool/call","seq":86,"time":1783611775504,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Echo BOTH_OK\\\" });\\nreturn result;\"}"}} {"type":"tool/code-dispatch","seq":87,"time":1783611775590,"data":{"parentCallId":"call_00_AZFzvUwuC4vAUoICrfke5147","subCallId":"call_00_AZFzvUwuC4vAUoICrfke5147:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Echo BOTH_OK"},"isError":false,"resultSummary":"BOTH_OK\n"}} -{"type":"tool/result","seq":88,"time":1783611775592,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[86],"surfaceOp":"append"} +{"type":"tool/result","seq":88,"time":1783611775592,"data":{"turn":1,"step":1,"callId":"call_00_AZFzvUwuC4vAUoICrfke5147","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[86],"surfaceOp":"append"} {"type":"step/end","seq":89,"time":1783611775592,"data":{"turn":1,"step":1}} {"type":"step/start","seq":90,"time":1783611775592,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":91,"time":1783611776183,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index c49b9ff3fa..8dac383517 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -112,7 +112,7 @@ {"type":"tool/call","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} {"type":"tool/code-dispatch","seq":111,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} {"type":"tool/code-dispatch","seq":112,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} -{"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[],"dispatches":2}},"sourceEventSeqs":[110],"surfaceOp":"append"} +{"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[110],"surfaceOp":"append"} {"type":"step/end","seq":114,"time":1783611772938,"data":{"turn":1,"step":1}} {"type":"step/start","seq":115,"time":1783611772938,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":116,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/knip.json b/knip.json index 3dc41214db..ad33bec613 100644 --- a/knip.json +++ b/knip.json @@ -113,6 +113,11 @@ "packages/fs/tool-fs": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/mcp/mcp-client": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"] } } } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index dce5700afd..d2cbc643a4 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -288,9 +288,17 @@ describe('bash tool', () => { it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => { const ctx = await setup() + ctx.systemPrompt.section({ name: 'test:before-bash', order: 104, text: 'before' }) + ctx.systemPrompt.section({ name: 'test:after-bash', order: 106, text: 'after' }) const assembly = await ctx.systemPrompt.assemble() const section = assembly.sections.find(s => s.name === 'tool:bash') - expect(section?.order).toBe(105) + expect(assembly.sections.map(s => s.name)).toEqual([ + 'harness:identity', + 'deployment:persona', + 'test:before-bash', + 'tool:bash', + 'test:after-bash', + ]) expect(section?.text).toContain('[exit code: N]') }) diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 0c69674e7a..342799dabb 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -23,7 +23,7 @@ Every field is validated (positive numbers) and defaulted; there are no other tu - **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work. - **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys. - **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`). -- **Logs stream eagerly** — console/stdout/stderr entries cross the port as they happen, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed entries, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once. +- **Logs stream eagerly** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed text, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once. - **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags. - **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving. @@ -31,6 +31,8 @@ Every field is validated (positive numbers) and defaulted; there are no other tu Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). +The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details. + ## Model Experience Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index c9d25ef4d8..77169f8eab 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -15,7 +15,6 @@ "types": "./lib/types/worker.d.ts", "default": "./lib/worker.cjs" }, - "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index db78f9d72e..7f36364a7c 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -7,7 +7,6 @@ import { inspect } from 'node:util' import { serialize } from 'node:v8' -import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime' import { logTruncationMarker } from './protocol.ts' import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' @@ -28,12 +27,12 @@ export interface PatchableStream { } /** - * Ordered log capture under one shared byte budget, delivered to a sink as - * each entry lands (the real sink streams entries over the port eagerly, so + * Ordered text capture under one shared byte budget, delivered to a sink as + * each item lands (the real sink streams text over the port eagerly, so * captured output survives a mid-run termination). Once the budget is - * exhausted it emits exactly one in-band marker entry (on the `stderr` - * diagnostics channel) and silently drops everything after — the cap is a - * blast-radius bound, so "how much was lost" intentionally stays unmeasured. + * exhausted it emits exactly one in-band marker and silently drops everything + * after. The cap is a blast-radius bound, so "how much was lost" intentionally + * stays unmeasured. */ export class LogBuffer { private remaining: number @@ -42,28 +41,28 @@ export class LogBuffer { // under Node's native strip-only mode, which rejects non-erasable syntax — // and parameter properties are non-erasable. private readonly maxBytes: number - private readonly sink: (entry: CodeLogEntry) => void + private readonly sink: (text: string) => void - constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) { + constructor(maxBytes: number, sink: (text: string) => void) { this.maxBytes = maxBytes this.sink = sink this.remaining = maxBytes } /** - * Emit one entry to the sink, charging its text against the budget (drops + marks once exhausted). - * @param entry - the log entry to deliver. + * Emit text to the sink, charging it against the budget (drops + marks once exhausted). + * @param text - the captured text to deliver. */ - push(entry: CodeLogEntry): void { + push(text: string): void { if (this.truncated) return - const cost = Buffer.byteLength(entry.text, 'utf8') + const cost = Buffer.byteLength(text, 'utf8') if (cost > this.remaining) { this.truncated = true - this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) }) + this.sink(logTruncationMarker(this.maxBytes)) return } this.remaining -= cost - this.sink(entry) + this.sink(text) } } @@ -84,7 +83,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS) args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ') const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> for (const level of CONSOLE_LEVELS) { - shim[level] = (...args: unknown[]) => { logs.push({ source: 'console', level, text: render(args) }) } + shim[level] = (...args: unknown[]) => { logs.push(render(args)) } } return shim } @@ -98,17 +97,16 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS) * * @param logs - the buffer captured writes are pushed into. * @param stream - the stream whose `write` slot is patched. - * @param source - the log source the captured writes are attributed to. * @returns the restore function (the in-process tests un-patch; the real * worker never needs to). */ -export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, source: 'stdout' | 'stderr'): () => void { +export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void { // The slot's VALUE is stored for restore and reassigned — never invoked // detached, so the unbound-method concern does not apply. // eslint-disable-next-line @typescript-eslint/unbound-method const original = stream.write stream.write = (chunk: unknown, ...rest: unknown[]): boolean => { - logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) }) + logs.push(typeof chunk === 'string' ? chunk : String(chunk)) // Node's optional-encoding shape: the callback is whichever of the next // two positions holds a function (a non-function there is the encoding). const callback = [rest[0], rest[1]].find( @@ -256,9 +254,9 @@ export async function runWorkerMain( data: WorkerBootData, streams: { stdout: PatchableStream; stderr: PatchableStream }, ): Promise { - const logs = new LogBuffer(data.maxLogBytes, (entry) => { port.postMessage({ type: 'log', entry }) }) - captureStreamWrites(logs, streams.stdout, 'stdout') - captureStreamWrites(logs, streams.stderr, 'stderr') + const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) }) + captureStreamWrites(logs, streams.stdout) + captureStreamWrites(logs, streams.stderr) const pending = new Map() wireReplies(port, pending) diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index aee20f9d71..4a13baa07c 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -12,14 +12,11 @@ import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import z from 'schemastery' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' -import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import type { CodeBindingFunction, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts' import { logTruncationMarker } from './protocol.ts' import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' -export type { BootstrapPort, PatchableStream } from './bootstrap.ts' -export type { CallMessage, DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' - /** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */ export interface Config { /** @@ -112,10 +109,6 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } -/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */ -const LOG_SOURCES = new Set(['console', 'stdout', 'stderr']) -const LOG_LEVELS = new Set(['log', 'info', 'warn', 'error', 'debug']) - /** * Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and * can post anything — `null`, primitives, objects with poisoned fields — so @@ -134,20 +127,8 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined { return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args } } case 'log': { - const entry = m.entry - if (typeof entry !== 'object' || entry === null) return undefined - const e = entry as Record - if (typeof e.text !== 'string') return undefined - if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined - if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined - return { - type: 'log', - entry: { - source: e.source as CodeLogEntry['source'], - ...e.level !== undefined ? { level: e.level as Exclude } : {}, - text: e.text, - }, - } + if (typeof m.text !== 'string') return undefined + return { type: 'log', text: m.text } } case 'done': { if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} } @@ -291,33 +272,33 @@ export class WorkerCodeRuntime extends CodeRuntime { return new Promise((resolve) => { let settled = false const answered = new Set() - const logs: CodeLogEntry[] = [] - const strayLogs: CodeLogEntry[] = [] + const logs: string[] = [] + const strayLogs: string[] = [] // One host-side budget covers normal, forged, and stray-pipe log entries. The first // overflow emits the shared in-band marker and drops everything after it. let logBudget = this.config.maxLogBytes let logsTruncated = false - const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => { + const admit = (text: string, sink: string[]): void => { if (logsTruncated) return - const cost = Buffer.byteLength(entry.text, 'utf8') + const cost = Buffer.byteLength(text, 'utf8') if (cost > logBudget) { logsTruncated = true - sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) }) + sink.push(logTruncationMarker(this.config.maxLogBytes)) return } logBudget -= cost - sink.push(entry) + sink.push(text) } // No settled guard: `finish` snapshots the arrays when it resolves, so // a chunk flushing after settlement mutates only the discarded buffers, // and the ledger bounds that growth until the pipes close. - const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => { - admit({ source, text: chunk.toString('utf8') }, strayLogs) + const captureStray = (chunk: Buffer): void => { + admit(chunk.toString('utf8'), strayLogs) } - worker.stdout.on('data', captureStray('stdout')) - worker.stderr.on('data', captureStray('stderr')) + worker.stdout.on('data', captureStray) + worker.stderr.on('data', captureStray) // Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker; // logs captured before timeout, abort, or failure remain in the result. @@ -386,7 +367,7 @@ export class WorkerCodeRuntime extends CodeRuntime { // this listener would crash the host process. Junk drops silently. const message = parseWorkerMessage(raw) if (!message) return - if (message.type === 'log' && !settled) admit(message.entry, logs) + if (message.type === 'log' && !settled) admit(message.text, logs) onCall(message) onDone(message) }) diff --git a/packages/code-runtime/code-runtime-worker/src/protocol.ts b/packages/code-runtime/code-runtime-worker/src/protocol.ts index 739f1e4eb5..1ce108b7cc 100644 --- a/packages/code-runtime/code-runtime-worker/src/protocol.ts +++ b/packages/code-runtime/code-runtime-worker/src/protocol.ts @@ -5,8 +5,6 @@ * @module @deepseek-ai/dsh-code-runtime-worker/src/protocol */ -import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime' - /** What the host hands the worker at spawn, via `workerData`. */ export interface WorkerBootData { /** The type-stripped (plain JS) program body. */ @@ -20,7 +18,7 @@ export interface WorkerBootData { } /** Worker → host: one bridged binding call. */ -export interface CallMessage { +interface CallMessage { type: 'call' /** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */ id: number @@ -32,10 +30,10 @@ export interface CallMessage { args: unknown } -/** Worker → host: one captured log entry, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */ -export interface LogMessage { +/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */ +interface LogMessage { type: 'log' - entry: CodeLogEntry + text: string } /** diff --git a/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts b/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts index e41f4455bb..111aa4f15f 100644 --- a/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/bootstrap.spec.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest' import { EventEmitter } from 'node:events' -import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts' -import type { BootstrapPort, PatchableStream, PendingCall } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts' -import type { ReplyMessage, WorkerToHost } from '@deepseek-ai/dsh-code-runtime-worker/src/protocol.ts' -import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime' +import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts' +import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts' +import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts' /** * An in-process stand-in for the worker's parentPort: the test plays the @@ -31,8 +30,8 @@ class FakePort implements BootstrapPort { this.emitter.emit('message', message) } - logs(): CodeLogEntry[] { - return this.sent.filter(message => message.type === 'log').map(message => message.entry) + logs(): string[] { + return this.sent.filter(message => message.type === 'log').map(message => message.text) } done(): WorkerToHost | undefined { @@ -48,12 +47,12 @@ const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 } describe('LogBuffer', () => { it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => { - const seen: CodeLogEntry[] = [] - const buffer = new LogBuffer(10, entry => seen.push(entry)) - buffer.push({ source: 'console', level: 'log', text: '12345' }) - buffer.push({ source: 'console', level: 'log', text: '123456' }) - buffer.push({ source: 'console', level: 'log', text: 'dropped' }) - expect(seen.map(entry => entry.text)).toEqual([ + const seen: string[] = [] + const buffer = new LogBuffer(10, text => seen.push(text)) + buffer.push('12345') + buffer.push('123456') + buffer.push('dropped') + expect(seen).toEqual([ '12345', '[dsh-code-runtime-worker] log capture truncated at 10 bytes', ]) @@ -61,40 +60,37 @@ describe('LogBuffer', () => { }) describe('makeConsoleShim', () => { - it('captures the five levels and renders non-strings inspect-style', () => { - const seen: CodeLogEntry[] = [] - const shim = makeConsoleShim(new LogBuffer(1_000, entry => seen.push(entry))) + it('captures the five methods and renders non-strings inspect-style', () => { + const seen: string[] = [] + const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text))) shim.log('plain', { a: 1 }) shim.info('i') shim.warn('w') shim.error('e') shim.debug('d') - expect(seen.map(entry => entry.level)).toEqual(['log', 'info', 'warn', 'error', 'debug']) - expect(seen[0]?.text).toBe('plain { a: 1 }') - expect(seen.every(entry => entry.source === 'console')).toBe(true) + expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd']) }) }) describe('captureStreamWrites', () => { it('redirects writes into the buffer and restores on request', () => { - const seen: CodeLogEntry[] = [] - const buffer = new LogBuffer(1_000, entry => seen.push(entry)) + const seen: string[] = [] + const buffer = new LogBuffer(1_000, text => seen.push(text)) let underlying = '' const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } } - const restore = captureStreamWrites(buffer, stream, 'stdout') + const restore = captureStreamWrites(buffer, stream) stream.write('captured', 'utf8') stream.write(Buffer.from('bytes')) restore() stream.write('after') - expect(seen.map(entry => entry.text)).toEqual(['captured', 'bytes']) - expect(seen[0]).toMatchObject({ source: 'stdout' }) + expect(seen).toEqual(['captured', 'bytes']) expect(underlying).toBe('after') }) it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => { const buffer = new LogBuffer(1_000, () => {}) const stream: PatchableStream = { write: () => true } - captureStreamWrites(buffer, stream, 'stdout') + captureStreamWrites(buffer, stream) const calls: (Error | null | undefined)[] = [] stream.write('two-arg', (error?: Error | null) => calls.push(error)) stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error)) @@ -107,7 +103,7 @@ describe('captureStreamWrites', () => { it('still fires the callback for a write the exhausted budget drops', async () => { const buffer = new LogBuffer(4, () => {}) const stream: PatchableStream = { write: () => true } - captureStreamWrites(buffer, stream, 'stdout') + captureStreamWrites(buffer, stream) stream.write('this write overflows the budget and is dropped') await new Promise(resolve => stream.write('also dropped', resolve)) }) @@ -210,7 +206,7 @@ describe('runWorkerMain', () => { code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', namespaces: [{ global: 'tools', names: ['double'] }], }, fakeStreams()) - expect(port.logs()).toEqual([{ source: 'console', level: 'log', text: 'got 42' }]) + expect(port.logs()).toEqual(['got 42']) expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } }) }) @@ -268,6 +264,6 @@ describe('runWorkerMain', () => { // The patch stays installed for the worker's lifetime; writes during the // program landed in order. Here the program wrote nothing via streams, so // only the post-run write above went through the patched slot. - expect(port.logs().at(-1)).toMatchObject({ source: 'stdout' }) + expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker') }) }) diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 24006a7c04..ff68fd09fe 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -38,9 +38,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' - const result = JSON.parse(lastLine) as { value?: unknown; logs: { source: string; level?: string; text: string }[]; error?: unknown } + const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown } expect(result.error).toBeUndefined() expect(result.value).toBe(42) - expect(result.logs).toContainEqual({ source: 'console', level: 'log', text: 'halfway 42' }) + expect(result.logs).toContain('halfway 42') }) }) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index a7386938c5..23c9a8ee60 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -28,7 +28,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { expect(runtime.isolation).toBe('worker-thread') }) - it('runs TypeScript (erasable syntax), captures console/stdout in order, returns the value', async () => { + it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => { const { runtime } = await setup() const result = await runtime.run({ program: ` @@ -43,12 +43,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { }) expect(result.error).toBeUndefined() expect(result.value).toBe(3) - expect(result.logs.map(entry => [entry.source, entry.level ?? null])).toEqual([ - ['console', 'log'], - ['stdout', null], - ['console', 'warn'], - ]) - expect(result.logs[0]?.text).toBe('point { x: 1, y: 2 }') + expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful']) }) it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => { @@ -115,7 +110,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => { bindings: [], }) expect(result.error?.kind).toBe('exception') - expect(result.logs.map(entry => entry.text)).toContain('before') + expect(result.logs).toContain('before') }) }) @@ -210,8 +205,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1', bindings: [], }) - expect(result.logs.at(-1)?.text).toContain('truncated at 300 bytes') - const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0) + expect(result.logs.at(-1)).toContain('truncated at 300 bytes') + const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0) expect(total).toBeLessThan(1_000) }) @@ -241,7 +236,7 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { }) expect(result.error).toBeUndefined() expect(result.value).toBe('done') - expect(result.logs).toContainEqual({ source: 'stdout', text: 'flushed' }) + expect(result.logs).toContain('flushed') }) it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => { @@ -268,8 +263,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { bindings: [], }) expect(result.error).toBeUndefined() - expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' }) - expect(result.logs.map(entry => entry.text)).not.toContain('ef') + expect(result.logs).toContain('abcd') + expect(result.logs).not.toContain('ef') }, 15_000) }) @@ -304,11 +299,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { { type: 'call', id: 1e9, global: 7, name: 'real', args: {} }, { type: 'call', id: 1e9, global: 'tools', name: 7, args: {} }, { type: 'log' }, - { type: 'log', entry: null }, - { type: 'log', entry: { source: 'stdout', text: 7 } }, - { type: 'log', entry: { source: 'nope', text: 'x' } }, - { type: 'log', entry: { source: 'console', level: 'nope', text: 'x' } }, - { type: 'log', entry: { source: 'console', level: 7, text: 'x' } }, + { type: 'log', text: null }, + { type: 'log', text: 7 }, + { type: 'log', text: {} }, { type: 'done', error: 5 }, { type: 'done', error: { message: 5 } }, ]) parentPort.postMessage(junk); @@ -329,7 +322,7 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { // code and an unbounded result. program: ` const { parentPort } = await import('node:worker_threads'); - for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', entry: { source: 'stdout', text: 'F'.repeat(100), forged: true } }); + for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true }); parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) }); for (;;) {} `, @@ -341,10 +334,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => { expect(value.endsWith('… [truncated]')).toBe(true) expect(value.length).toBeLessThan(120) const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes' - const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0) + const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0) expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8')) - expect(result.logs.at(-1)?.text).toBe(marker) - expect(result.logs.every(entry => !('forged' in entry))).toBe(true) + expect(result.logs.at(-1)).toBe(marker) }) it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => { diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 94c3bad61c..1f680741a1 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -16,7 +16,7 @@ Semantics every implementation must honor (contract details in the class JSDoc): ## Vocabulary -`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. +`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, ordered capped `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. ## Model Experience diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index e629a855b5..bd8efe1377 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -10,7 +10,6 @@ import type { CodeRunRequest, CodeRunResult } from './types.ts' export type { CodeBindingFunction, CodeBindingNamespace, - CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult, diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index 8278f33a39..d7669a4785 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -54,20 +54,6 @@ export interface CodeRunRequest { signal?: AbortSignal } -/** - * One captured output entry, in emission order. `source` says which channel - * produced it: the program's `console` (shimmed by the runtime), or a stray - * write to the underlying stdout/stderr streams. - */ -export interface CodeLogEntry { - /** Which channel produced the text. */ - source: 'console' | 'stdout' | 'stderr' - /** The console method used; present only when `source` is `'console'`. */ - level?: 'log' | 'info' | 'warn' | 'error' | 'debug' - /** The captured text (possibly truncated by the implementation's caps, marked in-band). */ - text: string -} - /** * Why a run failed. The kinds are orthogonal outcomes reported independently * (per docs/defensive-patterns.md): a budget expiry is not an exception, an @@ -98,8 +84,8 @@ export interface CodeRunResult { * or value-less run leaves this absent. */ value?: unknown - /** Everything the program emitted, in order (capped by the implementation). */ - logs: CodeLogEntry[] + /** Text the program emitted, in order (capped by the implementation). */ + logs: string[] /** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */ error?: CodeRunFailure } diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 4ff6d8f313..7811ef0531 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -55,7 +55,7 @@ describe('CodeRuntime service seam', () => { it('reports a failed run as an error field on a resolved result, never a rejection', async () => { const { runtime } = await setup() runtime.nextResult = { - logs: [{ source: 'console', level: 'error', text: 'boom' }], + logs: ['boom'], error: { kind: 'exception', message: 'boom' }, } const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f1d196c746..5d16b5d496 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -555,7 +555,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AssembledSection', - declaration: 'export interface AssembledSection {\n name: string;\n order: number;\n text: string;\n}', + declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}', }, { name: 'BashExecRequest', @@ -609,10 +609,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n}', }, - { - name: 'CodeLogEntry', - declaration: 'export interface CodeLogEntry {\n source: \'console\' | \'stdout\' | \'stderr\';\n level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n text: string;\n}', - }, { name: 'CodeRunFailure', declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}', @@ -623,7 +619,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CodeRunResult', - declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: CodeLogEntry[];\n error?: CodeRunFailure;\n}', + declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}', }, { name: 'CollectedOutput', diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 566395e55a..5eb66d95f8 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -65,10 +65,6 @@ export interface PromptSection { export interface AssembledSection { /** The contributing section's unique name. */ name: string - // TODO(assembled-section-order): drop this output field; registry order has - // already sorted the array, and no production renderer/listener reads it. - /** The contributing section's order (sections arrive sorted ascending). */ - order: number /** The resolved (but not yet interpolated) section text. */ text: string } @@ -403,12 +399,11 @@ export class SystemPrompt extends Service { } const assembly: PromptAssembly = { sections: [...sectionByName.values()] + .sort((a, b) => a.order - b.order) .map(section => ({ name: section.name, - order: section.order, text: typeof section.text === 'function' ? section.text(context) : section.text, - })) - .sort((a, b) => a.order - b.order), + })), tools: orderTools(collected, this.toolOrder, knownNames), variables, } diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index a452bb6797..aac3f79e88 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -139,7 +139,7 @@ describe('scoped assemble dispatch', () => { scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise) => { shaped.push(context.scope) const result = await next() - result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' }) + result.sections.push({ name: 'listener:extra', text: 'listener text' }) return result }) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index c7436711fa..b084104d22 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -21,9 +21,9 @@ describe('SystemPrompt', () => { await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' }) const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.map(s => [s.name, s.order])).toEqual([ - ['harness:identity', -100], - ['deployment:persona', 0], + expect(assembly.sections.map(s => s.name)).toEqual([ + 'harness:identity', + 'deployment:persona', ]) expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`) // The names are reserved by the plugin — one owner per section. @@ -183,7 +183,7 @@ describe('SystemPrompt', () => { const contexts: AssembleContext[] = [] ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => { contexts.push(context) - assembly.sections.push({ name: 'from-a', order: 100, text: 'a' }) + assembly.sections.push({ name: 'from-a', text: 'a' }) return next() }) // Listener B (registered later, runs after A) sees A's contribution. @@ -235,8 +235,8 @@ describe('SystemPrompt', () => { it('filters out empty section text from renderPrompt', () => { const result = renderPrompt({ sections: [ - { name: 'empty', order: 0, text: '' }, - { name: 'real', order: 1, text: 'content' }, + { name: 'empty', text: '' }, + { name: 'real', text: 'content' }, ], tools: [], variables: {}, @@ -356,13 +356,13 @@ describe('SystemPrompt', () => { }) it('names "(none)" when no variables are registered at all', () => { - expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} })) + expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} })) .toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)') }) it('throws when a referenced variable has no value for this assembly', () => { expect(() => renderPrompt({ - sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }], + sections: [{ name: 'persona', text: 'in {{cwd}}' }], tools: [], variables: { cwd: undefined }, })).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")') @@ -370,7 +370,7 @@ describe('SystemPrompt', () => { it('throws on a malformed complete reference, e.g. inner spaces', () => { expect(() => renderPrompt({ - sections: [{ name: 's', order: 0, text: 'on {{ model }}' }], + sections: [{ name: 's', text: 'on {{ model }}' }], tools: [], variables: { model: 'm' }, })).toThrow('malformed prompt variable reference "{{ model }}" in section "s"') @@ -378,7 +378,7 @@ describe('SystemPrompt', () => { it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => { const text = renderPrompt({ - sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }], + sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }], tools: [], variables: {}, }) @@ -390,7 +390,7 @@ describe('SystemPrompt', () => { { text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' }, ])('throws on a mangled reference with a }} still following ($label)', ({ text }) => { expect(() => renderPrompt({ - sections: [{ name: 's', order: 0, text }], + sections: [{ name: 's', text }], tools: [], variables: { model: 'm' }, })).toThrow('malformed prompt variable reference at') @@ -400,7 +400,7 @@ describe('SystemPrompt', () => { // `in` would find Object.prototype.constructor and splice function // source into the prompt; Object.hasOwn must reject it instead. expect(() => renderPrompt({ - sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }], + sections: [{ name: 's', text: 'on {{constructor}}' }], tools: [], variables: { model: 'm' }, })).toThrow('unknown prompt variable "{{constructor}}"') @@ -416,7 +416,7 @@ describe('SystemPrompt', () => { it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => { const text = renderPrompt({ - sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }], + sections: [{ name: 's', text: 'v = {{model}}!' }], tools: [], variables: { model: 'literal {{sneaky}} inside' }, }) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 8156e21bb5..012182db1b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -108,14 +108,13 @@ function renderValue(value: unknown): string { /** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */ interface RunCodeMeta { logs: CodeRunResult['logs'] - dispatches: number } /** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { if (typeof meta !== 'object' || meta === null) return undefined const m = meta as Record - if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined + if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined return m as unknown as RunCodeMeta } @@ -251,12 +250,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => } if (result.error) { - const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : '' + const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`) } const rendered = renderValue(result.value) - const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0) - const meta: RunCodeMeta = { logs: result.logs, dispatches } + const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0) + const meta: RunCodeMeta = { logs: result.logs } return { content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }], meta, @@ -278,7 +277,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => presentResult: (_args, result) => { const meta = asRunCodeMeta(result.meta) if (!meta) return undefined - const output = meta.logs.map(entry => entry.text).join('\n') + const output = meta.logs.join('\n') return { card: 'generic', ...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {}, diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index fe5ba5402a..54575403cf 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -328,7 +328,7 @@ describe('the run_code dispatch bridge', () => { const tools = request.bindings[0]!.functions const first = await tools.echo!({ value: 'one' }) const second = await tools.echo!({ value: 'two' }) - return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second } + return { logs: [`saw ${String(first)}`], value: second } } const result = await runCode(ctx, 'const …: string = …', { agent }) expect(result.isError).toBe(false) @@ -339,7 +339,7 @@ describe('the run_code dispatch bridge', () => { { parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' }, { parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' }, ]) - expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 }) + expect(result.meta).toEqual({ logs: ['saw echo:one'] }) }) it('exposes only an opaque parent token to nested result observers', async () => { @@ -503,7 +503,7 @@ describe('the run_code dispatch bridge', () => { it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) runtime.behavior = () => Promise.resolve({ - logs: [{ source: 'console', level: 'log', text: 'got this far' }], + logs: ['got this far'], error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' }, }) const result = await runCode(ctx, 'program') @@ -627,7 +627,7 @@ describe('the run_code dispatch bridge', () => { const view = tool.presentResult?.({ code: 'return 1' }, { content: [{ type: 'text', text: 'model-facing' }], isError: false, - meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 }, + meta: { logs: ['printed'] }, }) // The result omits the title — an update replaces only provided fields, // so the pending card's program title persists through completion. @@ -636,9 +636,10 @@ describe('the run_code dispatch bridge', () => { content: [{ type: 'text', text: 'printed' }], }) // No captured output → no content either; everything pending persists. - expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } })) + expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } })) .toEqual({ card: 'generic' }) // Replay with an unrecognizable meta falls back to the generic rendering. + expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined() expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined() expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined() }) diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000000..153afde8a9 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,7 @@ +# MCP — Model Context Protocol + +Packages bridging the harness to the MCP ecosystem. + +| Package | Role | +|---|---| +| `mcp-client/` | MCP client bridge: connects to external MCP servers and registers their tools on `ctx.tools` | diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md new file mode 100644 index 0000000000..e8c4f54f5f --- /dev/null +++ b/packages/mcp/mcp-client/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-mcp-client + +MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools under server-qualified names (`mcp____`). + +## Usage + +One plugin instance per MCP server in `cordis.yml`: + +```yaml +- id: mcp-github + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: github + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-github'] + env: + GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN + +- id: mcp-web + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: web + transport: streamable-http + url: http://localhost:3000/mcp + headers: + Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`' +``` + +The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same server-qualified shape Claude Code and Codex use. HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart; an unchanged `serverName` reproduces identical tool names. + +## Config + +| Field | Transport | Required | Description | +|---|---|---|---| +| `transport` | both | yes | `"stdio"` or `"streamable-http"` | +| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique across live instances | +| `command` | stdio | yes | Executable to spawn | +| `args` | stdio | no | Arguments passed to the command | +| `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env | +| `cwd` | stdio | no | Working directory for the child process | +| `url` | http | yes | MCP server URL | +| `headers` | http | no | Extra headers (e.g. auth tokens) | +| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | + +## Tool naming + +Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp____` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool. + +- Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces. +- A duplicate `serverName` across live instances fails the later plugin instance at load. +- A server listing the same tool name twice is rejected as an invalid tool list. +- A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error. + +## Behavior + +- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name. +- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered. +- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server. +- Image content in results is discarded with a placeholder (the harness has no image block type). +- On disconnect/crash: all tools are unregistered; no auto-reconnect. + +## Services consumed + +| Service | Usage | +|---|---| +| `ctx.tools` | Register/unregister MCP tools | + +## Model Experience + +### Discovered MCP tools + +**What the model sees**: After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it. + +**Token effect**: Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call. + +### Tool-call history and results + +**What the model sees**: The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path. + +**Token effect**: Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context. + +## Known Limitations and Deferred Work + +- **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. +- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. +- **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart. +- **Non-text results are lossy** — image, audio, and resource payloads are replaced with placeholders, and a structured-only result has no model-visible structured representation. diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json new file mode 100644 index 0000000000..6b8f145108 --- /dev/null +++ b/packages/mcp/mcp-client/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-mcp-client", + "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@modelcontextprotocol/server-everything": "^2026.7.4", + "@modelcontextprotocol/server-filesystem": "^2026.7.4", + "cordis": "^4.0.0-rc.7", + "zod": "^4.4.3" + } +} diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts new file mode 100644 index 0000000000..4a18f85ff5 --- /dev/null +++ b/packages/mcp/mcp-client/src/index.ts @@ -0,0 +1,177 @@ +/** + * MCP client bridge plugin: connects to an external MCP server and registers + * its tools on `ctx.tools` under server-qualified public names + * (`mcp____`). Each plugin instance connects to one MCP + * server; load multiple instances in `cordis.yml` for multiple servers. + * + * Namespace plugin (named exports, no default export). Lifecycle is + * effect-scoped: disposal disconnects from the server, unregisters all tools, + * and releases the `serverName` namespace reservation. HMR hot-swaps by + * disposing the old instance and creating a new one; identical `serverName` + * reproduces identical public tool names. + * + * @module @deepseek-ai/dsh-mcp-client + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' +import { createTransport } from './transport.ts' +import { syncTools } from './tools.ts' +// Side-effect type import: declaration-merges `ctx.tools` onto Context. +import type {} from '@deepseek-ai/dsh-tools' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'mcp-client' + +/** Services required by this plugin. */ +export const inject = ['tools'] + +/** Default timeout for individual MCP tool calls (ms). */ +const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 + +/** + * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the + * 64-char public-name budget so typical raw tool names survive unhashed. + */ +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ + +/** + * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps + * in one process — tests — must not see each other's names). A duplicate + * namespace is a configuration error surfaced at plugin load, never silent + * shadowing. + */ +const activeServerNames = new WeakMap>() + +// ---- Config ---- + +/** Config for connecting to an MCP server via a spawned child process over stdio. */ +export interface StdioConfig { + /** Transport type: spawn a child process and communicate over stdio. */ + transport: 'stdio' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string + /** Executable to spawn. */ + command: string + /** Arguments passed to the command. */ + args: string[] + /** Extra env vars merged on top of scrubbed ambient env. */ + env: Record + /** Working directory for the child process. */ + cwd: string + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} + +/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ +export interface StreamableHttpConfig { + /** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ + transport: 'streamable-http' + /** + * Stable local namespace for this server's model-facing tool names + * (`mcp____`). Must match `[A-Za-z0-9_-]{1,32}` and be + * unique across live mcp-client instances. + */ + serverName: string + /** MCP server URL. */ + url: string + /** Extra headers (e.g. auth tokens). */ + headers: Record + /** Timeout per callTool invocation (ms). */ + toolCallTimeoutMs: number +} + +/** Discriminated union of all supported MCP transport configurations. */ +export type Config = StdioConfig | StreamableHttpConfig + +export const Config = z.union([ + z.object({ + transport: z.const('stdio'), + serverName: z.string().required().pattern(SERVER_NAME_PATTERN), + command: z.string().required(), + args: z.array(String).default([]), + env: z.dict(String).default({}), + cwd: z.string().default(''), + toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + }), + z.object({ + transport: z.const('streamable-http'), + serverName: z.string().required().pattern(SERVER_NAME_PATTERN), + url: z.string().required(), + headers: z.dict(String).default({}), + toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), + }), +]) as unknown as z + +// ---- Plugin apply ---- + +export function apply(ctx: Context, config: Config): void { + // Reserve the namespace first: a duplicate `serverName` fails THIS instance + // at load with an actionable error and leaves the earlier instance intact. + ctx.effect(() => { + let names = activeServerNames.get(ctx.root) + if (!names) { + names = new Set() + activeServerNames.set(ctx.root, names) + } + if (names.has(config.serverName)) { + throw new Error( + `mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`, + ) + } + names.add(config.serverName) + return () => void names.delete(config.serverName) + }, 'mcp-client.serverName') + + const transport = createTransport(config) + const client = new Client( + { name: 'dsh-mcp-client', version: '0.0.1' }, + { capabilities: {} }, + ) + + const opts = { + serverName: config.serverName, + toolCallTimeoutMs: config.toolCallTimeoutMs, + } + + // Connect and set up tools. Errors during connect/first sync are logged, + // not thrown (the plugin simply has no tools registered). `ready` resolves + // to an accessor for the CURRENT disposer generation, so the effect + // disposer below always unregisters the live set, not the first one. + const ready = (async () => { + await client.connect(transport) + + let disposers = await syncTools(client, ctx, opts, new Map()) + + client.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`) + try { + disposers = await syncTools(client, ctx, opts, disposers) + } catch (error) { + // Fetch-phase failure: the previous generation is still registered + // and `disposers` still owns it — keep serving the last good list. + ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`) + } + }, + ) + + return () => disposers + })().catch((error: unknown) => { + ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`) + return () => new Map void>() + }) + + ctx.effect(() => async () => { + const live = await ready + for (const dispose of live().values()) dispose() + try { await client.close() } catch { /* transport already gone */ } + }, 'mcp-client.connection') +} diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts new file mode 100644 index 0000000000..ae01fc0f84 --- /dev/null +++ b/packages/mcp/mcp-client/src/tools.ts @@ -0,0 +1,230 @@ +/** + * Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry + * under deterministic server-qualified public names, and handles re-sync when + * the server's tool list changes. + * + * Naming contract (see the mcp-client RFC "Naming invariants"): every MCP tool + * has the stable identity `(serverName, rawName)`; the model-facing public name + * is `mcp____`, normalized to the DeepSeek function-name + * constraints. The raw name is only ever sent on the wire (`tools/call`); the + * public name is never parsed to recover it. + * + * @module + */ + +import { createHash } from 'node:crypto' +import type { Client } from '@modelcontextprotocol/sdk/client/index.js' +import type { Context } from 'cordis' +import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' + +/** Resolved options relevant to tool bridging. */ +export interface ToolBridgeOptions { + serverName: string + toolCallTimeoutMs: number +} + +/** State for one sync generation: the current set of disposers keyed by public name. */ +export type ToolDisposers = Map void> + +/** + * DeepSeek function-name contract: at most 64 characters. Wire-protocol + * constant, not configuration. + */ +const MAX_PUBLIC_NAME_LENGTH = 64 + +/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */ +const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g + +/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */ +const HASH_LENGTH = 12 + +/** + * Derive the model-facing public name for one MCP tool. + * + * Deterministic pure function of `(serverName, rawName)`: the clean case is + * `mcp____` verbatim. When character replacement or + * truncation to the DeepSeek function-name contract (64 chars, + * `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the + * identity is appended so distinct MCP identities never collapse into the + * same public name. + * + * @param serverName - Stable local namespace from plugin config. + * @param rawName - The MCP server's own tool name. + * @returns The globally unique, model-facing ToolRegistry name. + */ +export function publicToolName(serverName: string, rawName: string): string { + const joined = `mcp__${serverName}__${rawName}` + const normalized = joined.replace(INVALID_NAME_CHARS, '_') + if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized + const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH) + return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}` +} + +/** + * Sync the MCP server's tool list into the harness ToolRegistry. + * + * Two phases keep the swap safe: + * + * 1. Fetch: drain `client.listTools()` pagination and build the full next + * generation of `ToolDefinition`s under public names. Any failure here + * (network error, duplicate raw name in the server's list) rejects and + * leaves the previous generation registered untouched. + * 2. Swap: dispose the previous generation, register the new one. A registry + * conflict here can only mean a foreign registration squats on this + * server's `mcp____` namespace — the partial generation is + * rolled back (zero tools from this server), the error is logged, and an + * empty map is returned. + * + * @param client - Connected MCP Client instance used to list and call tools. + * @param ctx - Cordis context providing the `tools` service for registration. + * @param opts - Bridge options: server namespace and per-call timeout. + * @param previous - Disposer map from the prior sync generation; disposed + * during the swap phase (only after the fetch phase succeeded). + * @returns A map of registered public tool names to their unregister + * disposers — the exact set of live registrations owned by this server. + */ +export async function syncTools( + client: Client, + ctx: Context, + opts: ToolBridgeOptions, + previous: ToolDisposers, +): Promise { + // Phase 1: fetch and build the next generation without touching the registry. + const definitions = new Map() + let cursor: string | undefined + do { + const response = await client.listTools(cursor ? { cursor } : undefined) + for (const tool of response.tools) { + const publicName = publicToolName(opts.serverName, tool.name) + if (definitions.has(publicName)) { + throw new Error( + `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`, + ) + } + definitions.set(publicName, { + name: publicName, + description: tool.description ?? '', + parameters: tool.inputSchema, + execute: createExecutor(client, tool.name, opts), + }) + } + cursor = response.nextCursor + } while (cursor) + + // Phase 2: swap generations. + for (const dispose of previous.values()) dispose() + const disposers: ToolDisposers = new Map() + try { + for (const [publicName, definition] of definitions) { + disposers.set(publicName, ctx.tools.register(definition)) + } + } catch (error) { + // A conflict on an `mcp____`-qualified name means a foreign + // registration occupies this server's namespace. Roll back so the model + // sees either the full generation or none of it — never a partial set. + for (const dispose of disposers.values()) dispose() + ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`) + return new Map() + } + return disposers +} + +/** + * The shape we read from each MCP content block. Intentionally looser than the + * SDK's `ContentBlock` type: we're at a network trust boundary (data arrives + * from an external MCP server process via JSON-RPC), so fields that the SDK + * declares required may be absent at runtime if the server is buggy. + */ +interface McpContentBlock { + type: string + text?: string + mimeType?: string +} + +/** + * Create an execute function for one MCP tool. The executor closes over the + * raw MCP tool name and calls `client.callTool` with it (never the public + * name), with abort signal and timeout, then maps the result to harness + * ContentBlocks. + * + * When the MCP server returns `isError: true`, the executor throws so that + * the ToolRegistry's catch path produces an `isError` result for the model. + */ +function createExecutor( + client: Client, + rawName: string, + opts: ToolBridgeOptions, +): ToolDefinition['execute'] { + return async (args: unknown, exec: ToolExecution) => { + // The agent loop passes `JSON.parse(model_arguments)` which is usually an + // object, but can be any JSON value if the model misbehaves (outputs a bare + // string/number/null). Fallback to {} lets the MCP server produce a + // specific "missing required param" error the model can learn from. + const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record + const result = await client.callTool( + { name: rawName, arguments: argsObj }, + undefined, + { + ...exec.signal ? { signal: exec.signal } : {}, + timeout: opts.toolCallTimeoutMs, + }, + ) + + // The SDK may return a legacy `toolResult` shape; normalize to content array. + if (!('content' in result) || !Array.isArray(result.content)) { + const text = 'toolResult' in result + ? JSON.stringify(result.toolResult) + : '(no output)' + return [{ type: 'text' as const, text }] + } + + // Trust boundary: the SDK's return type erases to `any[]` due to the + // union of CallToolResult | CompatibilityCallToolResult. We process each + // element defensively in extractText (reading only .type/.text/.mimeType + // with optional fallbacks). + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const content: McpContentBlock[] = result.content + const text = extractText(content, rawName) + + // MCP isError → throw so ToolRegistry produces an isError result for the model. + if ('isError' in result && result.isError === true) { + throw new Error(text) + } + + return [{ type: 'text', text }] + } +} + +/** + * Extract text from an MCP content array into a single string. + * - text blocks: join with '\n' + * - image/audio/resource blocks: replaced with a placeholder + * + * Defensive: fields that the MCP spec declares required (mimeType, text) are + * guarded with fallbacks because this is a network trust boundary. + */ +function extractText(mcpContent: McpContentBlock[], toolName: string): string { + const parts: string[] = [] + + for (const block of mcpContent) { + switch (block.type) { + case 'text': + if (block.text !== undefined) parts.push(block.text) + break + case 'image': + parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`) + break + case 'audio': + parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`) + break + case 'resource': + case 'resource_link': + parts.push('[resource: content discarded]') + break + default: + parts.push(`[unsupported content type: ${block.type}]`) + } + } + + return parts.join('\n') || `(${toolName} returned no text content)` +} diff --git a/packages/mcp/mcp-client/src/transport.ts b/packages/mcp/mcp-client/src/transport.ts new file mode 100644 index 0000000000..6f7c584b20 --- /dev/null +++ b/packages/mcp/mcp-client/src/transport.ts @@ -0,0 +1,56 @@ +/** + * Transport factory: creates the appropriate MCP transport based on the + * plugin's resolved config. Stdio spawns a child process (with credential + * scrubbing); Streamable HTTP connects to a URL. + * + * @module + */ + +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { Config } from './index.ts' + +/** + * Credential-shaped ambient env vars are NOT forwarded to the child by default + * (the parent harness's own secrets must not leak into a spawned process + * implicitly). Same pattern as `dsh-subagent-acp`. + */ +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ +function buildChildEnv(extra: Record): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + } + return { ...env, ...extra } +} + +/** + * Create an MCP transport from the resolved plugin config. + * + * @param config - Resolved plugin config discriminated on `transport`. + * @returns A connected-ready MCP Transport (stdio or Streamable HTTP). + */ +export function createTransport(config: Config): Transport { + switch (config.transport) { + case 'stdio': + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: buildChildEnv(config.env), + cwd: config.cwd, + }) + case 'streamable-http': + // The MCP SDK's StreamableHTTPClientTransport has optional callback + // properties typed without `| undefined` (exactOptionalPropertyTypes + // mismatch with the Transport interface). The cast is safe — the SDK + // constructed the object, it simply doesn't declare the optionals + // strictly enough for our tsconfig. + return new StreamableHTTPClientTransport( + new URL(config.url), + { requestInit: { headers: config.headers } }, + ) as Transport + } +} diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts new file mode 100644 index 0000000000..4b43411346 --- /dev/null +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -0,0 +1,282 @@ +/** + * Tests for the mcp-client plugin's `apply` lifecycle entry point. + * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +// ---- Mock MCP SDK ---- + +// vi.mock factories are hoisted above every import/const, so the mock fns and +// class must be created inside vi.hoisted to exist when the factories run. +const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => { + const mockConnect = vi.fn<() => Promise>() + const mockClose = vi.fn<() => Promise>() + const mockListTools = vi.fn() + const mockCallTool = vi.fn() + const mockSetNotificationHandler = vi.fn() + class MockClient { + connect = mockConnect + close = mockClose + listTools = mockListTools + callTool = mockCallTool + setNotificationHandler = mockSetNotificationHandler + } + return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } +}) + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: MockClient, +})) + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn(), +})) + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(), +})) + +// vi.mock is hoisted above static imports, so the module under test sees the +// mocked SDK even through a static import. +import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts' + +// ---- Helpers ---- + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +function sleep(ms: number): Promise { + // Annotated binding (not withResolvers()): the tests lint layer runs + // no-invalid-void-type with default options, which rejects the explicit + // type argument in call position but accepts the inferred form. + const gate: PromiseWithResolvers = Promise.withResolvers() + setTimeout(gate.resolve, ms) + return gate.promise +} + +const stdioConfig: Config = { + transport: 'stdio', + serverName: 'srv', + command: 'echo', + args: [], + env: {}, + cwd: '', + toolCallTimeoutMs: 60_000, +} + +// ---- Tests ---- + +describe('mcp-client plugin module exports', () => { + it('exports name, inject, and Config', () => { + expect(name).toBe('mcp-client') + expect(inject).toEqual(['tools']) + expect(ConfigSchema).toBeDefined() + }) + + it('Config schema rejects a missing serverName', () => { + expect(() => ConfigSchema({ + transport: 'stdio', + command: 'echo', + } as never)).toThrow() + }) + + it('Config schema rejects an invalid serverName', () => { + // schemastery unions wrap branch errors in a generic "expected ... but got" + // message, so assert the throw, not the inner pattern text. + expect(() => ConfigSchema({ + transport: 'stdio', + serverName: 'bad name!', + command: 'echo', + } as never)).toThrow() + expect(() => ConfigSchema({ + transport: 'stdio', + serverName: 'x'.repeat(33), + command: 'echo', + } as never)).toThrow() + }) + + it('Config schema accepts a valid serverName', () => { + const resolved = ConfigSchema({ + transport: 'stdio', + serverName: 'github-prod_1', + command: 'echo', + } as never) + expect(resolved.serverName).toBe('github-prod_1') + }) +}) + +describe('apply (plugin lifecycle)', () => { + let ctx: Context + + beforeEach(async () => { + vi.clearAllMocks() + mockConnect.mockResolvedValue(undefined) + mockClose.mockResolvedValue(undefined) + mockListTools.mockResolvedValue({ + tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) + ctx = await mountRegistry() + }) + + it('connects, syncs tools under the namespace, and registers a notification handler', async () => { + apply(ctx, stdioConfig) + await sleep(50) + + expect(mockConnect).toHaveBeenCalled() + expect(mockListTools).toHaveBeenCalled() + expect(mockSetNotificationHandler).toHaveBeenCalled() + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + expect(ctx.tools.get('remote')).toBeUndefined() + }) + + it('rejects a duplicate serverName at load and leaves the first instance intact', async () => { + apply(ctx, stdioConfig) + await sleep(50) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + + expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/) + // First instance unaffected. + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + }) + + it('releases the serverName reservation on dispose', async () => { + const first = new Context() + await first.plugin(SystemPrompt) + await first.plugin(ToolRegistry) + apply(first, stdioConfig) + await sleep(50) + + await first.fiber.dispose() + await sleep(50) + + // Same root would conflict; a fresh app root reuses the name freely, + // and the disposed instance no longer holds the reservation on its root. + const second = new Context() + await second.plugin(SystemPrompt) + await second.plugin(ToolRegistry) + expect(() => { apply(second, stdioConfig) }).not.toThrow() + }) + + it('scopes serverName reservations per app root', async () => { + const other = await mountRegistry() + + apply(ctx, stdioConfig) + // Same serverName on a DIFFERENT root is fine. + expect(() => { apply(other, stdioConfig) }).not.toThrow() + await sleep(50) + + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + expect(other.tools.get('mcp__srv__remote')).toBeDefined() + }) + + it('logs error and registers no tools when connect fails; dispose is a no-op', async () => { + mockConnect.mockRejectedValue(new Error('connection refused')) + + apply(ctx, stdioConfig) + await sleep(50) + + expect(mockListTools).not.toHaveBeenCalled() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + + // Disposal exercises the empty fallback accessor: nothing to unregister, + // close still attempted, no throw. + await ctx.fiber.dispose() + await sleep(50) + expect(mockClose).toHaveBeenCalled() + }) + + it('re-syncs tools on ToolListChanged notification', async () => { + apply(ctx, stdioConfig) + await sleep(50) + + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + + // Simulate the notification handler being invoked with a new tool list. + mockListTools.mockResolvedValue({ + tools: [{ name: 'updated', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + + // Extract and call the notification handler. + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await handler() + + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() + }) + + it('keeps the previous generation when a re-sync fails', async () => { + apply(ctx, stdioConfig) + await sleep(50) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + + mockListTools.mockRejectedValue(new Error('flaky server')) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + // Must not reject (contained), and must keep the last good generation. + await handler() + + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + }) + + it('effect disposer unregisters the CURRENT generation and closes client', async () => { + // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the + // registry must survive to observe the unregistration. + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig) + await sleep(50) + + // Advance to a second generation first. + mockListTools.mockResolvedValue({ + tools: [{ name: 'updated', inputSchema: { type: 'object' } }], + nextCursor: undefined, + }) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await handler() + expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() + + await fiber.dispose() + await sleep(50) + + expect(mockClose).toHaveBeenCalled() + // The live (second) generation was unregistered, not just the first. + expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined() + }) + + it('effect disposer handles client.close failure gracefully', async () => { + mockClose.mockRejectedValue(new Error('already closed')) + + apply(ctx, stdioConfig) + await sleep(50) + + // Should not throw when dispose is triggered. + await ctx.fiber.dispose() + await sleep(50) + + expect(mockClose).toHaveBeenCalled() + }) + + it('uses streamable-http config path', async () => { + const httpConfig: Config = { + transport: 'streamable-http', + serverName: 'web', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer x' }, + toolCallTimeoutMs: 30_000, + } + + apply(ctx, httpConfig) + await sleep(50) + + expect(mockConnect).toHaveBeenCalled() + expect(ctx.tools.get('mcp__web__remote')).toBeDefined() + }) +}) diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts new file mode 100644 index 0000000000..d127412736 --- /dev/null +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -0,0 +1,65 @@ +/** + * Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin. + * Registers controlled tools with predictable behavior for asserting edge cases. + * + * Run: node --import tsx fixture-server.ts + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' + +const server = new McpServer( + { name: 'fixture-server', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } }, +) + +server.registerTool('add', { + title: 'Add Tool', + description: 'Adds two numbers.', + inputSchema: { a: z.number().describe('First number'), b: z.number().describe('Second number') }, +}, async args => ({ + content: [{ type: 'text', text: String(args.a + args.b) }], +})) + +server.registerTool('greet', { + title: 'Greet Tool', + description: 'Greets a person by name.', + inputSchema: { name: z.string().describe('Name to greet') }, +}, async args => ({ + content: [{ type: 'text', text: `Hello, ${args.name}!` }], +})) + +server.registerTool('fail', { + title: 'Fail Tool', + description: 'Always returns an error.', + inputSchema: {}, +}, async () => ({ + content: [{ type: 'text', text: 'Something went wrong' }], + isError: true, +})) + +server.registerTool('image', { + title: 'Image Tool', + description: 'Returns an image content block.', + inputSchema: {}, +}, async () => ({ + content: [ + { type: 'text', text: 'Here is an image:' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'text', text: 'End of image.' }, + ], +})) + +// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract. +// Exercises the bridge's normalize-and-hash public-name path end to end. +server.registerTool('admin.reset', { + title: 'Admin Reset Tool', + description: 'Tool with a dotted name (normalization test).', + inputSchema: {}, +}, async () => ({ + content: [{ type: 'text', text: 'reset done' }], +})) + +const transport = new StdioServerTransport() +await server.connect(transport) diff --git a/packages/mcp/mcp-client/tests/load-path.spec.ts b/packages/mcp/mcp-client/tests/load-path.spec.ts new file mode 100644 index 0000000000..5507cd5b83 --- /dev/null +++ b/packages/mcp/mcp-client/tests/load-path.spec.ts @@ -0,0 +1,29 @@ +/** + * Real-load-path guard for @deepseek-ai/dsh-mcp-client. `mcp-client` is a + * NAMESPACE plugin with `inject` — so a stray `export default apply` would + * make the cordis Loader's `unwrapExports` (`exports.default ?? exports`) + * collapse the module to the bare `apply` function, DROPPING `inject`. The + * plugin would then read `ctx.tools` without having injected it and throw + * `cannot get property … without inject` the moment it loads (postmortem 0001). + * + * This test unwraps the module through the REAL `Loader.prototype.unwrapExports` + * and verifies the namespace shape is preserved. + */ + +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as mcpClient from '@deepseek-ai/dsh-mcp-client' + +describe('dsh-mcp-client real-load-path guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in mcpClient).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(mcpClient) as Record + expect(unwrapped).toBe(mcpClient) + expect(unwrapped.name).toBe('mcp-client') + expect(unwrapped.inject).toEqual(['tools']) + expect(typeof unwrapped.apply).toBe('function') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts new file mode 100644 index 0000000000..686d51acea --- /dev/null +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -0,0 +1,441 @@ +/** + * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against: + * 1. A self-written fixture server over stdio (controlled edge cases) + * 2. @modelcontextprotocol/server-everything (official integration test server) + * 3. @modelcontextprotocol/server-filesystem (real filesystem operations) + * 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP + * + * No API key needed — all servers are local/keyless. + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { z } from 'zod' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' +import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' +import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +// Resolve package-local .bin for pnpm-hoisted MCP server binaries. +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const localBin = join(packageDir, 'node_modules', '.bin') + +// ---- Helpers ---- + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +/** Apply the MCP client plugin and wait for tools to be registered. */ +async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise { + // Annotated bindings (not withResolvers()): the tests lint layer runs + // no-invalid-void-type with default options, which rejects the explicit + // type argument in call position but accepts the inferred form. + const gate: PromiseWithResolvers = Promise.withResolvers() + const timer = setTimeout( + () => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) }, + timeoutMs, + ) + ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() }) + apply(ctx, config) + await gate.promise +} + +function sleep(ms: number): Promise { + const gate: PromiseWithResolvers = Promise.withResolvers() + setTimeout(gate.resolve, ms) + return gate.promise +} + +/** Narrow a result content block to its text, failing the test on any other shape. */ +function textOf(block: unknown): string { + if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') { + return block.text + } + throw new Error(`expected a text content block, got ${JSON.stringify(block)}`) +} + +let callSeq = 0 +function nextCallId(): CallId { + return CallId(`e2e-${++callSeq}`) +} + +// ---- Fixture server tests ---- + +describe('fixture server — controlled scenarios', () => { + let ctx: Context + + const fixtureConfig: Config = { + transport: 'stdio', + serverName: 'fixture', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServerPath], + env: { TSX_TSCONFIG_PATH: repoTsconfig }, + cwd: packageDir, + toolCallTimeoutMs: 15_000, + } + + beforeAll(async () => { + ctx = await mountRegistry() + await applyAndWait(ctx, fixtureConfig) + }, 30_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await sleep(200) + }) + + it('discovers all fixture tools under the server namespace', () => { + const schemas = ctx.tools.schemas() + const names = schemas.map(s => s.name) + expect(names).toContain('mcp__fixture__add') + expect(names).toContain('mcp__fixture__greet') + expect(names).toContain('mcp__fixture__fail') + expect(names).toContain('mcp__fixture__image') + // Raw names are not registered. + expect(names).not.toContain('add') + }) + + it('normalizes the dotted tool name with a deterministic hash suffix', () => { + const publicName = publicToolName('fixture', 'admin.reset') + expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/) + expect(ctx.tools.get(publicName)).toBeDefined() + }) + + it('executes the dotted tool via its normalized public name', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' }) + }) + + it('executes add(2, 3) → "5"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 }, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: '5' }) + }) + + it('executes greet("World") → "Hello, World!"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' }, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' }) + }) + + it('executes fail() → isError result', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {}, + }) + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ type: 'text' }) + }) + + it('executes image() → image placeholder', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, + }) + expect(result.isError).toBe(false) + const text = textOf(result.content[0]) + expect(text).toContain('Here is an image:') + expect(text).toContain('[image: image/png, content discarded]') + expect(text).toContain('End of image.') + }) +}) + +describe('fixture server — duplicate serverName', () => { + it('rejects a second instance with the same serverName on one root', async () => { + const ctx = await mountRegistry() + const config: Config = { + transport: 'stdio', + serverName: 'dup', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServerPath], + env: { TSX_TSCONFIG_PATH: repoTsconfig }, + cwd: packageDir, + toolCallTimeoutMs: 15_000, + } + await applyAndWait(ctx, config) + + expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/) + + await ctx.fiber.dispose() + await sleep(200) + }, 30_000) +}) + +describe('fixture server — disposal', () => { + it('disposes cleanly without error', async () => { + const ctx = await mountRegistry() + await applyAndWait(ctx, { + transport: 'stdio', + serverName: 'fixture', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServerPath], + env: { TSX_TSCONFIG_PATH: repoTsconfig }, + cwd: packageDir, + toolCallTimeoutMs: 15_000, + }) + + // Tools are registered before dispose. + expect(ctx.tools.get('mcp__fixture__add')).toBeDefined() + expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4) + + // Dispose should complete without throwing. + await ctx.fiber.dispose() + await sleep(200) + }, 30_000) +}) + +// ---- @modelcontextprotocol/server-everything ---- + +describe('server-everything — official test server', () => { + let ctx: Context + + const config: Config = { + transport: 'stdio', + serverName: 'everything', + command: join(localBin, 'mcp-server-everything'), + args: ['stdio'], + env: {}, + cwd: '', + toolCallTimeoutMs: 30_000, + } + + beforeAll(async () => { + ctx = await mountRegistry() + await applyAndWait(ctx, config) + }, 60_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await sleep(500) + }) + + it('discovers tools from server-everything', () => { + const schemas = ctx.tools.schemas() + const names = schemas.map(s => s.name) + expect(names).toContain('mcp__everything__echo') + expect(names).toContain('mcp__everything__get-sum') + expect(names).toContain('mcp__everything__get-tiny-image') + expect(names.length).toBeGreaterThanOrEqual(8) + }) + + it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' }, + }) + expect(result.isError).toBe(false) + expect(textOf(result.content[0])).toBe('Echo: hello') + }) + + it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 }, + }) + expect(result.isError).toBe(false) + expect(textOf(result.content[0])).toContain('10') + }) + + it('executes get-tiny-image → image placeholder', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, + }) + expect(result.isError).toBe(false) + expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]') + }) +}) + +// ---- @modelcontextprotocol/server-filesystem ---- + +describe('server-filesystem — real filesystem operations', () => { + let ctx: Context + let tempDir: string + + beforeAll(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mcp-fs-e2e-')) + + ctx = await mountRegistry() + const config: Config = { + transport: 'stdio', + serverName: 'filesystem', + command: join(localBin, 'mcp-server-filesystem'), + args: [tempDir], + env: {}, + cwd: '', + toolCallTimeoutMs: 30_000, + } + await applyAndWait(ctx, config) + }, 60_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await sleep(500) + await rm(tempDir, { recursive: true, force: true }) + }) + + it('discovers filesystem tools', () => { + const schemas = ctx.tools.schemas() + const names = schemas.map(s => s.name) + expect(names).toContain('mcp__filesystem__read_file') + expect(names).toContain('mcp__filesystem__write_file') + expect(names).toContain('mcp__filesystem__list_directory') + }) + + it('write_file + read_file round-trip', async () => { + const filePath = join(tempDir, 'test.txt') + const content = 'Hello from MCP e2e test!' + + // Write via MCP tool + const writeResult = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, + }) + expect(writeResult.isError).toBe(false) + + // Verify file was actually written (world verification) + const onDisk = await readFile(filePath, 'utf8') + expect(onDisk).toBe(content) + + // Read back via MCP tool + const readResult = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, + }) + expect(readResult.isError).toBe(false) + expect(textOf(readResult.content[0])).toContain(content) + }) + + it('list_directory shows written file', async () => { + // Ensure a file exists + await writeFile(join(tempDir, 'listed.txt'), 'listed') + + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir }, + }) + expect(result.isError).toBe(false) + expect(textOf(result.content[0])).toContain('listed.txt') + }) +}) + +// ---- Streamable HTTP transport ---- + +describe('streamable-http — in-process MCP server', () => { + let ctx: Context + let httpServer: Server + let baseUrl: string + /** Authorization header values observed by the HTTP server, in arrival order. */ + const seenAuth: Array = [] + + /** + * Stateless Streamable HTTP endpoint: a fresh McpServer + server transport + * per request (the SDK's documented stateless pattern — no session id, no + * SSE stream to keep). The tool set mirrors a minimal fixture server. + */ + async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise { + seenAuth.push(req.headers.authorization) + const server = new McpServer( + { name: 'http-fixture', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ) + server.registerTool('ping', { + description: 'Replies pong.', + inputSchema: {}, + }, async () => ({ + content: [{ type: 'text', text: 'pong' }], + })) + server.registerTool('shout', { + description: 'Upper-cases a message.', + inputSchema: { message: z.string().describe('Message to upper-case') }, + }, async args => ({ + content: [{ type: 'text', text: args.message.toUpperCase() }], + })) + // Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and + // explicit-undefined identically; exactOptionalPropertyTypes forbids the + // SDK-documented explicit `sessionIdGenerator: undefined` spelling). + const transport = new StreamableHTTPServerTransport({}) + res.on('close', () => { void transport.close(); void server.close() }) + // Same exactOptionalPropertyTypes mismatch the client transport factory + // documents (src/transport.ts): the SDK types optional callbacks without + // `| undefined`. The SDK constructed the object; the cast is safe. + await server.connect(transport as Transport) + await transport.handleRequest(req, res) + } + + beforeAll(async () => { + httpServer = createServer((req, res) => { + handleMcpRequest(req, res).catch((error: unknown) => { + res.writeHead(500).end(String(error)) + }) + }) + const listening: PromiseWithResolvers = Promise.withResolvers() + httpServer.listen(0, '127.0.0.1', listening.resolve) + await listening.promise + const address = httpServer.address() + if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`) + baseUrl = `http://127.0.0.1:${address.port}/mcp` + + ctx = await mountRegistry() + const config: Config = { + transport: 'streamable-http', + serverName: 'web', + url: baseUrl, + headers: { Authorization: 'Bearer e2e-test-token' }, + toolCallTimeoutMs: 15_000, + } + await applyAndWait(ctx, config) + }, 30_000) + + afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + await sleep(200) + const closed: PromiseWithResolvers = Promise.withResolvers() + httpServer.close(() => { closed.resolve() }) + await closed.promise + }) + + it('discovers tools under the server namespace over HTTP', () => { + const names = ctx.tools.schemas().map(s => s.name) + expect(names).toContain('mcp__web__ping') + expect(names).toContain('mcp__web__shout') + }) + + it('executes ping() → "pong" over HTTP', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__web__ping', arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'pong' }) + }) + + it('executes shout({ message }) with args over HTTP', async () => { + const result = await ctx.tools.execute({ + callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' }, + }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' }) + }) + + it('sends configured headers on every HTTP request', () => { + expect(seenAuth.length).toBeGreaterThan(0) + for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token') + }) +}) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts new file mode 100644 index 0000000000..8fff832434 --- /dev/null +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -0,0 +1,602 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' +import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +// ---- Mock MCP Client ---- + +interface MockTool { + name: string + description?: string + inputSchema: Record +} + +interface MockCallResult { + content: Array<{ type: string; text?: string; mimeType?: string }> + isError?: boolean +} + +function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) { + return { + listTools: vi.fn().mockResolvedValue({ tools, nextCursor: undefined }), + callTool: vi.fn().mockResolvedValue(callResult), + setNotificationHandler: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } +} + +// ---- Test harness helper ---- + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +const defaultOpts: ToolBridgeOptions = { + serverName: 'srv', + toolCallTimeoutMs: 60_000, +} + +// ---- Tests ---- + +describe('publicToolName', () => { + it('joins clean names verbatim', () => { + expect(publicToolName('github', 'create_issue')).toBe('mcp__github__create_issue') + expect(publicToolName('everything', 'get-sum')).toBe('mcp__everything__get-sum') + }) + + it('replaces invalid characters and appends an identity hash', () => { + const name = publicToolName('srv', 'admin.reset') + expect(name).toMatch(/^mcp__srv__admin_reset_[0-9a-f]{12}$/) + expect(name.length).toBeLessThanOrEqual(64) + }) + + it('truncates over-long names and appends an identity hash', () => { + const rawName = 'a'.repeat(80) + const name = publicToolName('srv', rawName) + expect(name).toHaveLength(64) + expect(name).toMatch(/_[0-9a-f]{12}$/) + expect(name.startsWith('mcp__srv__aaa')).toBe(true) + }) + + it('is deterministic and collision-free for distinct identities', () => { + // Two raw names that normalize to the same base must not collapse. + const a = publicToolName('srv', 'admin.reset') + const b = publicToolName('srv', 'admin_reset') + expect(a).toBe(publicToolName('srv', 'admin.reset')) + expect(a).not.toBe(b) + }) +}) + +describe('syncTools', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('registers tools under server-qualified public names', async () => { + const client = createMockClient([ + { name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } }, + { name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } }, + ]) + + const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + + expect(disposers.size).toBe(2) + expect(ctx.tools.get('mcp__srv__greet')).toBeDefined() + expect(ctx.tools.get('mcp__srv__add')).toBeDefined() + // Raw names are NOT registered. + expect(ctx.tools.get('greet')).toBeUndefined() + expect(ctx.tools.get('add')).toBeUndefined() + }) + + it('lets two servers publish the same raw name side by side', async () => { + const clientA = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) + const clientB = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) + + await syncTools(clientA as never, ctx, { ...defaultOpts, serverName: 'github' }, new Map()) + await syncTools(clientB as never, ctx, { ...defaultOpts, serverName: 'web' }, new Map()) + + expect(ctx.tools.get('mcp__github__search')).toBeDefined() + expect(ctx.tools.get('mcp__web__search')).toBeDefined() + }) + + it('coexists with a native tool of the same raw name', async () => { + ctx.tools.register({ + name: 'search', + description: 'Native search', + parameters: { type: 'object' }, + execute: async () => [{ type: 'text', text: 'native' }], + }) + const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }]) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + + expect(ctx.tools.get('search')).toBeDefined() + expect(ctx.tools.get('mcp__srv__search')).toBeDefined() + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} }) + expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) + }) + + it('rejects a tool list where one raw name appears twice', async () => { + const client = createMockClient([ + { name: 'dup', inputSchema: { type: 'object' } }, + { name: 'dup', inputSchema: { type: 'object' } }, + ]) + + await expect(syncTools(client as never, ctx, defaultOpts, new Map())) + .rejects.toThrow(/listed tool "dup" more than once/) + // Nothing registered, previous generation untouched (it was empty). + expect(ctx.tools.get('mcp__srv__dup')).toBeUndefined() + }) + + it('keeps the previous generation when the fetch phase fails', async () => { + const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }]) + const first = await syncTools(client as never, ctx, defaultOpts, new Map()) + expect(ctx.tools.get('mcp__srv__stable')).toBeDefined() + + client.listTools.mockRejectedValue(new Error('network down')) + await expect(syncTools(client as never, ctx, defaultOpts, first)).rejects.toThrow('network down') + + // The previous generation is still live. + expect(ctx.tools.get('mcp__srv__stable')).toBeDefined() + }) + + it('rolls back the whole generation when a foreign tool squats on the namespace', async () => { + // A foreign registration occupies one of this server's public names. + ctx.tools.register({ + name: 'mcp__srv__taken', + description: 'Squatter', + parameters: { type: 'object' }, + execute: async () => [{ type: 'text', text: 'squatter' }], + }) + const client = createMockClient([ + { name: 'free', inputSchema: { type: 'object' } }, + { name: 'taken', inputSchema: { type: 'object' } }, + ]) + + const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + + // All-or-nothing: the non-conflicting tool is rolled back too. + expect(disposers.size).toBe(0) + expect(ctx.tools.get('mcp__srv__free')).toBeUndefined() + // The squatter is untouched. + expect(ctx.tools.get('mcp__srv__taken')).toBeDefined() + }) + + it('unregisters previous tools before re-syncing', async () => { + const client = createMockClient([ + { name: 'old_tool', inputSchema: { type: 'object' } }, + ]) + + const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + expect(ctx.tools.get('mcp__srv__old_tool')).toBeDefined() + + // Second sync with different tools should remove old_tool. + client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined }) + const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers) + + expect(ctx.tools.get('mcp__srv__old_tool')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__new_tool')).toBeDefined() + expect(secondDisposers.size).toBe(1) + }) + + it('drains paginated listTools responses', async () => { + const client = createMockClient([]) + client.listTools + .mockResolvedValueOnce({ tools: [{ name: 'page1', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [{ name: 'page2', inputSchema: { type: 'object' } }], nextCursor: undefined }) + + const disposers = await syncTools(client as never, ctx, defaultOpts, new Map()) + + expect(disposers.size).toBe(2) + expect(ctx.tools.get('mcp__srv__page1')).toBeDefined() + expect(ctx.tools.get('mcp__srv__page2')).toBeDefined() + }) +}) + +describe('tool execution', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('calls MCP callTool with the RAW name and returns text content', async () => { + const client = createMockClient( + [{ name: 'echo', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'hello world' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) + + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) + // The wire sees the raw MCP name, never the public name. + expect(client.callTool).toHaveBeenCalledWith( + { name: 'echo', arguments: { msg: 'hi' } }, + undefined, + expect.objectContaining({ timeout: 60_000 }), + ) + }) + + it('sends the raw name for normalized public names', async () => { + const client = createMockClient( + [{ name: 'admin.reset', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'reset done' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const publicName = publicToolName('srv', 'admin.reset') + const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} }) + + expect(result.isError).toBe(false) + expect(client.callTool).toHaveBeenCalledWith( + { name: 'admin.reset', arguments: {} }, + undefined, + expect.anything(), + ) + }) + + it('joins multiple text blocks with newline', async () => { + const client = createMockClient( + [{ name: 'multi', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'line1' }, { type: 'text', text: 'line2' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} }) + + expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) + }) + + it('discards image content with placeholder', async () => { + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) + }) + + it('maps isError to an error result via throw', async () => { + const client = createMockClient( + [{ name: 'fail', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'something went wrong' }], isError: true }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} }) + + expect(result.isError).toBe(true) + expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) + }) + + it('passes abort signal to callTool', async () => { + const controller = new AbortController() + const client = createMockClient( + [{ name: 'slow', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'done' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal }) + + expect(client.callTool).toHaveBeenCalledWith( + expect.anything(), + undefined, + expect.objectContaining({ signal: controller.signal }), + ) + }) + + it('handles legacy toolResult shape', async () => { + const client = createMockClient( + [{ name: 'legacy', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({ toolResult: { key: 'value' } }) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} }) + + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) + }) +}) + +describe('tool execution edge cases', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('handles audio content with placeholder', async () => { + const client = createMockClient( + [{ name: 'audio_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'audio', mimeType: 'audio/mp3' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' }) + }) + + it('handles resource content with placeholder', async () => { + const client = createMockClient( + [{ name: 'res_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'resource' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) + }) + + it('handles resource_link content with placeholder', async () => { + const client = createMockClient( + [{ name: 'link_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'resource_link' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) + }) + + it('handles unknown content types', async () => { + const client = createMockClient( + [{ name: 'unknown_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'video' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' }) + }) + + it('handles image with missing mimeType (buggy server)', async () => { + const client = createMockClient( + [{ name: 'img2', inputSchema: { type: 'object' } }], + { content: [{ type: 'image' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' }) + }) + + it('handles audio with missing mimeType (buggy server)', async () => { + const client = createMockClient( + [{ name: 'audio_no_mime', inputSchema: { type: 'object' } }], + { content: [{ type: 'audio' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' }) + }) + + it('handles text block with missing text (buggy server)', async () => { + const client = createMockClient( + [{ name: 'notext', inputSchema: { type: 'object' } }], + { content: [{ type: 'text' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) + }) + + it('handles empty content array', async () => { + const client = createMockClient( + [{ name: 'empty_tool', inputSchema: { type: 'object' } }], + { content: [] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) + }) + + + it('handles legacy toolResult with undefined value', async () => { + const client = createMockClient( + [{ name: 'legacy2', inputSchema: { type: 'object' } }], + ) + client.callTool.mockResolvedValue({}) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) + + expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) + }) + + it('handles isError with non-text content (fallback error message)', async () => { + const client = createMockClient( + [{ name: 'err_notext', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png' }], isError: true }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) + + expect(result.isError).toBe(true) + expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' }) + }) + + + it('uses tool description when provided', async () => { + const client = createMockClient([ + { name: 'described', description: 'A described tool', inputSchema: { type: 'object' } }, + ]) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const tool = ctx.tools.get('mcp__srv__described') + expect(tool?.description).toBe('A described tool') + }) + + it('uses empty description when tool has no description', async () => { + const client = createMockClient([ + { name: 'nodesc', inputSchema: { type: 'object' } }, + ]) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const tool = ctx.tools.get('mcp__srv__nodesc') + expect(tool?.description).toBe('') + }) +}) + +describe('createTransport', () => { + it('creates StdioClientTransport for stdio config', () => { + const config: Config = { + transport: 'stdio', + serverName: 'srv', + command: 'node', + args: ['server.js'], + env: {}, + cwd: '/tmp', + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + expect(transport).toHaveProperty('start') + expect(transport).toHaveProperty('close') + }) + + it('creates StreamableHTTPClientTransport for http config without headers', () => { + const config: Config = { + transport: 'streamable-http', + serverName: 'srv', + url: 'http://localhost:3000/mcp', + headers: {}, + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + expect(transport).toHaveProperty('start') + expect(transport).toHaveProperty('close') + }) + + it('creates StreamableHTTPClientTransport for http config with headers', () => { + const config: Config = { + transport: 'streamable-http', + serverName: 'srv', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer token' }, + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + expect(transport).toHaveProperty('start') + expect(transport).toHaveProperty('close') + }) + + it('scrubs sensitive env vars and forwards the rest', () => { + const original = { ...process.env } + try { + process.env.SAFE_VAR = 'kept' + process.env.MY_SECRET = 'hidden' + process.env.API_KEY = 'hidden' + process.env.AUTH_TOKEN = 'hidden' + + const config: Config = { + transport: 'stdio', + serverName: 'srv', + command: 'echo', + args: [], + env: { EXTRA: 'injected' }, + cwd: '', + toolCallTimeoutMs: 60_000, + } + // createTransport internally calls buildChildEnv; we verify by inspecting + // the constructed StdioClientTransport. Since we can't inspect private fields + // easily, we at least confirm it doesn't throw and returns a transport. + const transport = createTransport(config) + expect(transport).toBeDefined() + } finally { + // Restore env + delete process.env.SAFE_VAR + delete process.env.MY_SECRET + delete process.env.API_KEY + delete process.env.AUTH_TOKEN + for (const key of Object.keys(process.env)) { + if (!(key in original)) Reflect.deleteProperty(process.env, key) + } + } + }) + + it('merges explicit env on top of scrubbed ambient env', () => { + const config: Config = { + transport: 'stdio', + serverName: 'srv', + command: 'echo', + args: [], + env: { CUSTOM: 'value' }, + cwd: '', + toolCallTimeoutMs: 60_000, + } + const transport = createTransport(config) + expect(transport).toBeDefined() + }) +}) + +describe('tool execution — non-object args fallback', () => { + let ctx: Context + + beforeEach(async () => { + ctx = await mountRegistry() + }) + + it('coerces null args to empty object for callTool', async () => { + const client = createMockClient( + [{ name: 'coerce', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'ok' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + // Simulate model emitting `null` as tool arguments (malformed). + await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) + + expect(client.callTool).toHaveBeenCalledWith( + { name: 'coerce', arguments: {} }, + undefined, + expect.anything(), + ) + }) + + it('coerces primitive string args to empty object for callTool', async () => { + const client = createMockClient( + [{ name: 'coerce2', inputSchema: { type: 'object' } }], + { content: [{ type: 'text', text: 'ok' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) + + expect(client.callTool).toHaveBeenCalledWith( + { name: 'coerce2', arguments: {} }, + undefined, + expect.anything(), + ) + }) +}) diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json new file mode 100644 index 0000000000..e9c9266415 --- /dev/null +++ b/packages/mcp/mcp-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a480dc798e..4e0a2af98a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -709,7 +709,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -724,6 +724,34 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/mcp/mcp-client: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.0 + version: 1.29.0(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@modelcontextprotocol/server-everything': + specifier: ^2026.7.4 + version: 2026.7.4 + '@modelcontextprotocol/server-filesystem': + specifier: ^2026.7.4 + version: 2026.7.10(zod@4.4.3) + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/sandbox/sandbox: devDependencies: '@deepseek-ai/dsh-llm': @@ -2553,6 +2581,12 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2579,6 +2613,10 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2598,6 +2636,24 @@ packages: '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@modelcontextprotocol/server-everything@2026.7.4': + resolution: {integrity: sha512-ydMW/M6rk9tK23b+U38trsNLHhd5eF+ntiv2Vr+RPMDhbiKY/IKrZU25ukvSXVPUBvy7TxTPWpeV4KcYcXg72w==} + hasBin: true + + '@modelcontextprotocol/server-filesystem@2026.7.10': + resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==} + hasBin: true + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -2843,6 +2899,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3375,6 +3435,10 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3389,9 +3453,36 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -3413,6 +3504,9 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -3429,9 +3523,16 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -3451,6 +3552,14 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3465,6 +3574,13 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -3473,9 +3589,29 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cordis@4.0.0-rc.7: resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} hasBin: true @@ -3488,6 +3624,13 @@ packages: '@cordisjs/plugin-loader': optional: true + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3696,6 +3839,10 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3707,6 +3854,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + diff@9.0.0: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} @@ -3723,20 +3874,52 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -3745,6 +3928,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3811,10 +3997,32 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3831,6 +4039,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -3858,6 +4069,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3869,6 +4084,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -3878,11 +4097,22 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + functional-red-black-tree@1.0.1: resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} @@ -3894,6 +4124,14 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -3905,6 +4143,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} @@ -3920,6 +4163,10 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -3927,6 +4174,18 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.29: + resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3937,6 +4196,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3949,6 +4212,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3957,6 +4224,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -3968,6 +4238,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -3975,10 +4248,22 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3986,6 +4271,12 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -4001,10 +4292,16 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4080,6 +4377,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4087,6 +4390,9 @@ packages: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -4172,6 +4478,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -4262,6 +4571,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -4284,6 +4596,10 @@ packages: engines: {node: '>= 20'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -4320,6 +4636,14 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mermaid@11.16.0: resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} @@ -4407,10 +4731,26 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4426,6 +4766,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-addon-landlock-run-linux-arm64@0.0.0-test.0: resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==} engines: {node: '>=20'} @@ -4503,10 +4847,25 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -4542,12 +4901,22 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -4566,6 +4935,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4576,6 +4952,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -4590,10 +4970,17 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + protobufjs@7.6.4: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -4606,9 +4993,24 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -4667,6 +5069,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -4674,6 +5080,9 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4696,6 +5105,20 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4704,9 +5127,29 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -4718,9 +5161,32 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -4764,6 +5230,10 @@ packages: resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4846,6 +5316,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} @@ -4890,13 +5364,24 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: @@ -5024,6 +5509,17 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -5431,11 +5927,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -5593,17 +6089,23 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate + '@hono/node-server@1.19.14(hono@4.12.29)': + dependencies: + hono: 4.12.29 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5628,6 +6130,15 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5655,6 +6166,50 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.29) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.29 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@modelcontextprotocol/server-everything@2026.7.4': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + cors: 2.8.6 + express: 5.2.1 + jszip: 3.10.1 + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + + '@modelcontextprotocol/server-filesystem@2026.7.10(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + diff: 8.0.4 + glob: 10.5.0 + minimatch: 10.2.5 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - zod + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5800,6 +6355,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -6330,6 +6888,11 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -6338,6 +6901,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -6345,6 +6912,23 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + ansis@4.3.1: {} anynum@1.0.0: {} @@ -6365,6 +6949,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -6377,8 +6963,26 @@ snapshots: birpc@4.0.0: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bowser@2.14.1: {} + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -6391,6 +6995,16 @@ snapshots: cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + ccount@2.0.1: {} chai@6.2.2: {} @@ -6401,12 +7015,28 @@ snapshots: dependencies: readdirp: 4.1.2 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@7.2.0: {} commander@8.3.0: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): dependencies: '@standard-schema/spec': 1.1.0 @@ -6431,6 +7061,13 @@ snapshots: '@cordisjs/plugin-include': link:vendor/include '@cordisjs/plugin-loader': link:vendor/loader + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -6665,6 +7302,8 @@ snapshots: dependencies: robust-predicates: 3.0.3 + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -6673,6 +7312,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@8.0.4: {} + diff@9.0.0: {} dompurify@3.4.11: @@ -6683,16 +7324,40 @@ snapshots: optionalDependencies: oxc-resolver: 11.20.0 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + empathic@2.0.1: {} + encodeurl@2.0.0: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + es-toolkit@1.49.0: {} esbuild@0.28.1: @@ -6724,6 +7389,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -6823,8 +7490,54 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} fast-check@4.8.0: @@ -6837,6 +7550,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.3: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -6866,6 +7581,17 @@ snapshots: dependencies: flat-cache: 4.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6878,6 +7604,11 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -6886,9 +7617,15 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + functional-red-black-tree@1.0.1: {} gaxios@7.1.5: @@ -6907,6 +7644,24 @@ snapshots: transitivePeerDependencies: - supports-color + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -6919,6 +7674,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@17.7.0: {} globrex@0.1.2: {} @@ -6936,10 +7700,20 @@ snapshots: google-logging-utils@1.1.3: {} + gopd@1.2.0: {} + hachure-fill@0.5.2: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.29: {} + hookable@6.1.1: {} html-encoding-sniffer@6.0.0: @@ -6950,6 +7724,14 @@ snapshots: html-escaper@2.0.2: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6968,28 +7750,46 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} + immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internmap@1.0.1: {} internmap@2.0.3: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + + isarray@1.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -7005,8 +7805,16 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -7083,10 +7891,21 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsx-ast-utils-x@0.1.0: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -7176,6 +7995,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -7237,6 +8060,8 @@ snapshots: longest-streak@3.1.0: {} + lru-cache@10.4.3: {} + lru-cache@11.5.1: {} magic-string@0.30.21: @@ -7257,6 +8082,8 @@ snapshots: marked@16.4.2: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -7361,6 +8188,10 @@ snapshots: mdn-data@2.27.1: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -7576,10 +8407,22 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -7588,6 +8431,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + node-addon-landlock-run-linux-arm64@0.0.0-test.0: optional: true @@ -7656,8 +8501,20 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.3: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -7732,12 +8589,18 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parse5@8.0.1: dependencies: entities: 8.0.0 + parseurl@1.3.3: {} + partial-json@0.1.7: {} path-data-parser@0.1.0: {} @@ -7748,12 +8611,21 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -7769,6 +8641,8 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + protobufjs@7.6.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7783,6 +8657,11 @@ snapshots: '@types/node': 25.9.3 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -7794,8 +8673,32 @@ snapshots: pure-rand@8.4.0: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@1.0.0: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} refa@0.12.1: @@ -7880,12 +8783,24 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rw@1.3.3: {} sade@1.8.1: dependencies: mri: 1.2.0 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -7907,22 +8822,107 @@ snapshots: semver@7.8.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + smol-toml@1.6.1: {} source-map-js@1.2.1: {} stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -7956,6 +8956,8 @@ snapshots: dependencies: tldts-core: 7.4.5 + toidentifier@1.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.4.5 @@ -8017,6 +9019,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typebox@1.1.38: {} typescript-eslint@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): @@ -8064,12 +9072,18 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + uuid@14.0.1: {} + vary@1.1.2: {} + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: debug: 4.4.3 @@ -8199,6 +9213,20 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index b5d2fa53d9..650319e358 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -478,6 +478,15 @@ function walkSchemaExpr( } return } + // A union of objects (discriminated union config): collect keys from all + // variants. Each variant is visited the same way as an intersect element. + if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) { + for (const el of call.arguments[0].elements) { + const part = unwrapExpr(el) + if (ts.isCallExpression(part)) { visit(part); continue } + } + return + } // A chained refinement (`z.object({…}).default(…)` etc.): the keys live on // the call the chain hangs off — keep unwrapping toward it. const base = unwrapExpr(call.expression.expression) diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 2ecfc76a2e..ca89b2d9a9 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -408,8 +408,7 @@ ], "isError": false, "meta": { - "logs": [], - "dispatches": 1 + "logs": [] } }, "sourceEventSeqs": [ @@ -1606,8 +1605,7 @@ ], "isError": false, "meta": { - "logs": [], - "dispatches": 1 + "logs": [] } }, "sourceEventSeqs": [ diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index bb0ee1d4d0..82fb190727 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -22,7 +22,7 @@ {"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}} {"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}} -{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 42be996773..51d98c659e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -101,7 +101,6 @@ { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" }, { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 29f954f68f..a4288b2dd3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -61,6 +61,7 @@ "./packages/session-query/*/src", "./packages/ui/*/src", "./packages/util/*/src", + "./packages/mcp/*/src", "./packages/support/*/src" ] } diff --git a/tsconfig.build.json b/tsconfig.build.json index 40ec94c0c7..fc1e9f488e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -82,6 +82,7 @@ { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, - { "path": "./packages/hooks/hooks-codex" } + { "path": "./packages/hooks/hooks-codex" }, + { "path": "./packages/mcp/mcp-client" } ] } diff --git a/tsconfig.json b/tsconfig.json index 881da0e12d..02b01678ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -93,6 +93,7 @@ { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, - { "path": "./packages/hooks/hooks-codex" } + { "path": "./packages/hooks/hooks-codex" }, + { "path": "./packages/mcp/mcp-client" } ] }