From 3e6437a77ae91dc05e96960adc410ad038a2c16f Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:08:29 -0500 Subject: [PATCH 01/14] refactor(agent): nest tool-set and MCP under agent subpaths Move the tool-set and MCP implementations into @openrouter/agent subpath exports while retaining @openrouter/mcp as a compatibility facade. Keep the MCP SDK optional so base agent installs stay lean. Co-Authored-By: Claude --- .changeset/agent-mcp-subpath.md | 20 + .changeset/agent-tool-set.md | 5 +- README.md | 3 +- packages/agent-tool-set/README.md | 213 ------- packages/agent-tool-set/package.json | 55 -- packages/agent-tool-set/src/index.ts | 40 -- packages/agent-tool-set/tsconfig.json | 8 - packages/agent-tool-set/vitest.config.ts | 33 -- packages/agent/README.md | 535 ++++++++---------- .../THIRD_PARTY_NOTICES.md | 2 +- packages/agent/package.json | 51 +- packages/agent/src/lib/async-params.ts | 6 +- .../src/lib/tool-set-types.ts} | 6 +- .../src => agent/src/lib}/tool-set.ts | 7 +- packages/agent/src/lib/tool-types.ts | 4 +- packages/agent/src/lib/tool.ts | 4 +- .../src/mcp}/auth/auth-resolver.ts | 0 .../src => agent/src/mcp}/auth/auth-types.ts | 0 .../{mcp/src => agent/src/mcp}/build-tools.ts | 0 .../src/mcp}/cache/cache-store.ts | 0 .../src/mcp}/cache/cache-types.ts | 0 .../src => agent/src/mcp}/cache/serialize.ts | 0 packages/agent/src/mcp/close-quietly.ts | 28 + packages/agent/src/mcp/create-mcp-tools.ts | 95 ++++ .../{mcp/src => agent/src/mcp}/elicitation.ts | 0 packages/{mcp/src => agent/src/mcp}/errors.ts | 2 +- packages/{mcp/src => agent/src/mcp}/handle.ts | 2 +- packages/agent/src/mcp/index.ts | 35 ++ .../src => agent/src/mcp}/mcp-connection.ts | 0 .../{mcp/src => agent/src/mcp}/rehydrate.ts | 0 .../src => agent/src/mcp}/resource-tools.ts | 0 .../src => agent/src/mcp}/result-mapper.ts | 0 .../src/mcp}/schema/json-schema-guards.ts | 0 .../src/mcp}/schema/json-schema-to-zod.ts | 0 .../src => agent/src/mcp}/tool-wrapper.ts | 0 .../src => agent/src/mcp}/transport-types.ts | 0 packages/agent/src/mcp/types.ts | 125 ++++ packages/agent/src/mcp/version.ts | 5 + .../tests/e2e/mcp}/mcp-tools.e2e.test.ts | 6 +- .../unit/call-model-active-tools.test.ts | 4 +- .../tests/unit/filter-tools-by-ids.test-d.ts | 8 +- .../tests/unit/mcp}/build-tools.test.ts | 6 +- packages/agent/tests/unit/mcp/cache.test.ts | 181 ++++++ .../tests/unit/mcp/create-mcp-tools.test.ts | 102 ++++ .../tests/unit/mcp}/elicitation.test.ts | 2 +- .../unit/mcp}/json-schema-to-zod.test.ts | 4 +- .../unit/mcp}/list-tools-pagination.test.ts | 6 +- .../tests/unit/mcp}/loop-key.test.ts | 14 +- .../tests/unit/mcp}/rehydrate.test.ts | 0 .../tests/unit/mcp}/resource-tools.test.ts | 2 +- .../tests/unit/mcp}/result-mapper.test.ts | 4 +- .../tests/unit/resolved-tools.test-d.ts | 6 +- .../tests/unit/server-tool-id.test-d.ts | 0 .../unit/tool-name-correlation.test-d.ts | 2 +- .../tests/unit/tool-set.test.ts | 18 +- packages/agent/tsconfig.json | 6 +- packages/agent/tsconfig.typecheck.json | 1 + packages/mcp/README.md | 185 +----- packages/mcp/src/cache.ts | 4 + packages/mcp/src/create-mcp-tools.ts | 115 +--- packages/mcp/src/index.ts | 51 +- packages/mcp/src/schema.ts | 4 + packages/mcp/src/types.ts | 175 +----- packages/mcp/tests/unit/cache.test.ts | 215 +------ .../mcp/tests/unit/create-mcp-tools.test.ts | 355 +----------- packages/mcp/tests/unit/index.test.ts | 25 + packages/mcp/tests/unit/schema.test.ts | 23 + packages/mcp/tests/unit/types.test.ts | 32 ++ pnpm-lock.yaml | 15 +- 69 files changed, 1111 insertions(+), 1744 deletions(-) create mode 100644 .changeset/agent-mcp-subpath.md delete mode 100644 packages/agent-tool-set/README.md delete mode 100644 packages/agent-tool-set/package.json delete mode 100644 packages/agent-tool-set/src/index.ts delete mode 100644 packages/agent-tool-set/tsconfig.json delete mode 100644 packages/agent-tool-set/vitest.config.ts rename packages/{agent-tool-set => agent}/THIRD_PARTY_NOTICES.md (95%) rename packages/{agent-tool-set/src/types.ts => agent/src/lib/tool-set-types.ts} (99%) rename packages/{agent-tool-set/src => agent/src/lib}/tool-set.ts (99%) rename packages/{mcp/src => agent/src/mcp}/auth/auth-resolver.ts (100%) rename packages/{mcp/src => agent/src/mcp}/auth/auth-types.ts (100%) rename packages/{mcp/src => agent/src/mcp}/build-tools.ts (100%) rename packages/{mcp/src => agent/src/mcp}/cache/cache-store.ts (100%) rename packages/{mcp/src => agent/src/mcp}/cache/cache-types.ts (100%) rename packages/{mcp/src => agent/src/mcp}/cache/serialize.ts (100%) create mode 100644 packages/agent/src/mcp/close-quietly.ts create mode 100644 packages/agent/src/mcp/create-mcp-tools.ts rename packages/{mcp/src => agent/src/mcp}/elicitation.ts (100%) rename packages/{mcp/src => agent/src/mcp}/errors.ts (98%) rename packages/{mcp/src => agent/src/mcp}/handle.ts (99%) create mode 100644 packages/agent/src/mcp/index.ts rename packages/{mcp/src => agent/src/mcp}/mcp-connection.ts (100%) rename packages/{mcp/src => agent/src/mcp}/rehydrate.ts (100%) rename packages/{mcp/src => agent/src/mcp}/resource-tools.ts (100%) rename packages/{mcp/src => agent/src/mcp}/result-mapper.ts (100%) rename packages/{mcp/src => agent/src/mcp}/schema/json-schema-guards.ts (100%) rename packages/{mcp/src => agent/src/mcp}/schema/json-schema-to-zod.ts (100%) rename packages/{mcp/src => agent/src/mcp}/tool-wrapper.ts (100%) rename packages/{mcp/src => agent/src/mcp}/transport-types.ts (100%) create mode 100644 packages/agent/src/mcp/types.ts create mode 100644 packages/agent/src/mcp/version.ts rename packages/{mcp/tests/e2e => agent/tests/e2e/mcp}/mcp-tools.e2e.test.ts (94%) rename packages/{agent-tool-set => agent}/tests/unit/filter-tools-by-ids.test-d.ts (89%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/build-tools.test.ts (93%) create mode 100644 packages/agent/tests/unit/mcp/cache.test.ts create mode 100644 packages/agent/tests/unit/mcp/create-mcp-tools.test.ts rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/elicitation.test.ts (96%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/json-schema-to-zod.test.ts (96%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/list-tools-pagination.test.ts (92%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/loop-key.test.ts (94%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/rehydrate.test.ts (100%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/resource-tools.test.ts (99%) rename packages/{mcp/tests/unit => agent/tests/unit/mcp}/result-mapper.test.ts (92%) rename packages/{agent-tool-set => agent}/tests/unit/resolved-tools.test-d.ts (94%) rename packages/{agent-tool-set => agent}/tests/unit/server-tool-id.test-d.ts (100%) rename packages/{agent-tool-set => agent}/tests/unit/tool-set.test.ts (99%) create mode 100644 packages/mcp/src/cache.ts create mode 100644 packages/mcp/src/schema.ts create mode 100644 packages/mcp/tests/unit/index.test.ts create mode 100644 packages/mcp/tests/unit/schema.test.ts create mode 100644 packages/mcp/tests/unit/types.test.ts diff --git a/.changeset/agent-mcp-subpath.md b/.changeset/agent-mcp-subpath.md new file mode 100644 index 00000000..858cde38 --- /dev/null +++ b/.changeset/agent-mcp-subpath.md @@ -0,0 +1,20 @@ +--- +"@openrouter/agent": minor +"@openrouter/mcp": minor +--- + +Add the full MCP integration under the canonical `@openrouter/agent/mcp` subpath. `@modelcontextprotocol/sdk` is an optional peer, so base agent installations and imports do not install or load MCP support. The existing `@openrouter/mcp` package remains as a compatibility facade and now re-exports the canonical agent subpaths. + +```ts +import { callModel, OpenRouter } from '@openrouter/agent'; +import { createMCPTools } from '@openrouter/agent/mcp'; + +const mcp = await createMCPTools({ url: 'https://mcp.example.com/mcp' }); +const result = callModel(new OpenRouter(), { + model: 'openai/gpt-4o-mini', + input: 'Use the remote tools.', + tools: mcp.tools, +}); +``` + +Install `@modelcontextprotocol/sdk` alongside `@openrouter/agent` when using `/mcp`. Existing `@openrouter/mcp` imports continue to work, but new code should prefer `@openrouter/agent/mcp`. diff --git a/.changeset/agent-tool-set.md b/.changeset/agent-tool-set.md index e054ecc4..2493e63c 100644 --- a/.changeset/agent-tool-set.md +++ b/.changeset/agent-tool-set.md @@ -1,13 +1,12 @@ --- -"@openrouter/agent-tool-set": minor "@openrouter/agent": minor --- -Add `@openrouter/agent-tool-set` (port of ai-tool-set v1.0.0, MIT © Chris Cook): declarative activate / deactivate / activateWhen / deactivateWhen for tools with state- and context-aware predicates. Integrates with a new `activeTools?: readonly string[]` option on `callModel` that filters which tools are sent to the model for a given call. +Add `@openrouter/agent/tool-set` (port of ai-tool-set v1.0.0, MIT © Chris Cook): declarative activate / deactivate / activateWhen / deactivateWhen for tools with state- and context-aware predicates. Integrates with a new `activeTools?: readonly string[]` option on `callModel` that filters which tools are sent to the model for a given call. ```ts import { callModel, OpenRouter, serverTool, tool } from '@openrouter/agent'; -import { createToolSet } from '@openrouter/agent-tool-set'; +import { createToolSet } from '@openrouter/agent/tool-set'; import { z } from 'zod/v4'; type AppContext = { accountId: string }; diff --git a/README.md b/README.md index 4fd642aa..117499ad 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Monorepo for the OpenRouter TypeScript agent ecosystem. | Package | Path | Description | | --- | --- | --- | -| [`@openrouter/agent`](./packages/agent) | `packages/agent` | Agent toolkit for building AI applications with OpenRouter — tool orchestration, streaming, multi-turn conversations, and format compatibility. | +| [`@openrouter/agent`](./packages/agent) | `packages/agent` | Agent toolkit with optional `@openrouter/agent/tool-set` and `@openrouter/agent/mcp` subpaths. | +| [`@openrouter/mcp`](./packages/mcp) | `packages/mcp` | Compatibility facade for the canonical `@openrouter/agent/mcp` integration. | ## Development diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md deleted file mode 100644 index 11613f18..00000000 --- a/packages/agent-tool-set/README.md +++ /dev/null @@ -1,213 +0,0 @@ -# @openrouter/agent-tool-set - -Declarative, state-aware activation and deactivation for tools used with `@openrouter/agent`. - -Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Cook), adapted for this SDK's ordered `Tool[]` / `callModel` model. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). - -## What it adds - -- **Stable tool-set IDs** for every addressable tool: - - client tools → `function.name` - - server tools → `server:${config.type}` by default (overridable via `serverTool(config, { id })`) -- A **typed three-way partition** of those IDs: definitely enabled, definitely disabled, conditional. -- **Exhaustive runtime snapshots** from `resolve()` / `resolveSituation()` — every ID appears in `statusByTool`. -- **Named declarative situations** with compile-time exact tool tuples when the situation is fully static. -- Integration with `callModel`'s `activeTools` option via the snapshot's spread-safe `.callModel` input. - -## Install - -```bash -pnpm add @openrouter/agent-tool-set -``` - -## Usage - -```ts -import { OpenRouter, tool, serverTool, callModel } from '@openrouter/agent'; -import { - createToolSet, - type InferEnabledIds, - type InferDisabledIds, - type InferConditionalIds, - type InferAllIds, -} from '@openrouter/agent-tool-set'; -import { z } from 'zod/v4'; - -type AppContext = { - isAuthenticated: boolean; - isAdmin: boolean; -}; - -const listOrders = tool({ - name: 'list_orders', - inputSchema: z.object({}), - execute: async () => ({ orders: [] }), -}); - -const cancelOrder = tool({ - name: 'cancel_order', - inputSchema: z.object({ id: z.string() }), - execute: async () => ({ ok: true }), -}); - -const login = tool({ - name: 'login', - inputSchema: z.object({}), - execute: async () => ({ token: '…' }), -}); - -const webSearch = serverTool({ type: 'web_search_2025_08_26' }); -// id defaults to 'server:web_search_2025_08_26' - -const allTools = [listOrders, cancelOrder, login, webSearch] as const; - -const toolSet = createToolSet({ tools: allTools }) - .deactivate('cancel_order') - .activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true) - .defineSituations({ - guest: { - enabled: ['login', 'server:web_search_2025_08_26'], - disabled: ['list_orders', 'cancel_order'], - }, - authenticated: { - enabled: ['list_orders', 'server:web_search_2025_08_26'], - disabled: ['login'], - conditional: { - cancel_order: ({ context }) => context?.isAdmin === true, - }, - }, - }); - -// Compile-time partition of the *base* set (before a situation overlay): -type All = InferAllIds; -// 'list_orders' | 'cancel_order' | 'login' | 'server:web_search_2025_08_26' -type Enabled = InferEnabledIds; // excludes cancel_order + list_orders (conditional) -type Disabled = InferDisabledIds; // 'cancel_order' -type Conditional = InferConditionalIds; // 'list_orders' - -const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); - -// Named static situation → exact tool tuple at compile time -const guest = toolSet.resolveSituation('guest'); -// guest.tools is exactly [login, webSearch] -// guest.enabled / guest.disabled / guest.statusByTool are exhaustive - -const authenticated = toolSet.resolveSituation('authenticated', { - context: { isAuthenticated: true, isAdmin: false }, -}); - -const result = callModel(client, { - model: 'openai/gpt-4o-mini', - input: 'List my orders.', - ...authenticated.callModel, -}); -``` - -## Identity - -| Kind | Tool-set ID | -| --- | --- | -| Client `tool({ name: 'x' })` | `'x'` | -| `serverTool({ type: 'web_search_2025_08_26' })` | `'server:web_search_2025_08_26'` | -| `serverTool(config, { id: 'server:public_search' })` | `'server:public_search'` | - -Duplicate IDs throw at `createToolSet` construction. Activation methods accept only known IDs. - -## Compile-time vs runtime exactness - -| Resolution style | Developer-time knowledge | Runtime knowledge | -| --- | --- | --- | -| Static `activate` / `deactivate` | Exact partition and filtered `tools` tuple | Exact snapshot | -| Named static situation (`enabled`/`disabled` only) | Exact partition and filtered `tools` tuple | Exact snapshot | -| `activateWhen` / `deactivateWhen` / situation `conditional` | `tools` is a readonly array of possible active members; length and positions are not exact | Exact snapshot after predicates | -| Mutable `ToolSet` | Widened partition; `tools` is a readonly array of possible active members | Exact snapshot | - -The type system cannot execute predicates. If any IDs are conditional, `snapshot.tools` and `snapshot.callModel.tools` are arrays whose member union is limited to the active upper bound, but their length and positions remain unknown. Static-only partitions retain exact filtered tuples. At runtime, all snapshot arrays and `statusByTool` reflect the resolved predicates exactly. - -## API - -### `createToolSet({ tools, mutable? })` - -Build a set from an ordered tool array. Optional `TShared` types the `context` argument on predicates. Defaults to immutable. - -### `.tools` - -Concrete tools tuple in construction order (client + server), regardless of activation. - -### `.activate(id | id[])` / `.deactivate(id | id[])` - -Static flip (last-call-wins). Accepts client names **and** server IDs. Updates the compile-time partition. - -### `.activateWhen(id, predicate)` / `.activateWhen({ [id]: predicate })` - -Conditional activation — defaults inactive, becomes active when predicate returns `true`. Moves the ID into the conditional partition. - -### `.deactivateWhen(id, predicate)` / `.deactivateWhen({ [id]: predicate })` - -Conditional deactivation — defaults active, becomes inactive when predicate returns `true`. Also moves the ID into the conditional partition. - -Predicate input: `{ state?: ConversationState; context?: TShared }`. - -### `.defineSituations({ [name]: config })` - -Declarative named situations. Each config may include: - -- `enabled?: readonly Id[]` — statically on -- `disabled?: readonly Id[]` — statically off -- `conditional?: { [id]: predicate | { mode?, predicate } }` — runtime rules - -Situation overlays the base partition for every ID it mentions; unmentioned IDs keep the base state. Unknown, duplicate, or conflicting IDs within one situation throw. - -### `.resolve(input?)` → snapshot - -```ts -{ - tools: /* exact tuple when static; possible-member array when conditional */; - activeTools: /* active *client* names for callModel */; - callModel: { tools, activeTools }; // safe to spread into callModel() - enabled: /* every active ID (client + server) */; - disabled: /* every inactive ID */; - statusByTool: { - [id]: { - enabled: boolean; - reason: 'default' | 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen' | 'situation'; - directive?: 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen'; - predicate?: boolean; // true when a runtime predicate decided the result - }; - }; -} -``` - -### `.resolveSituation(name, input?)` → snapshot - -Same shape as `resolve`, with the named situation overlay applied first. - -### `.inferTools(input?)` - -Back-compat alias for `resolve`. Prefer `resolve` in new code. - -### `.clone({ mutable? })` - -Copy state, optionally flipping mode. - -### Inference utilities - -```ts -type All = InferAllIds; -type Enabled = InferEnabledIds; -type Disabled = InferDisabledIds; -type Conditional = InferConditionalIds; -``` - -### `InferToolSet` - -Alias of the agent's `CorrelatedToolEventUnion` — name-correlated preliminary/result stream events based on a tools tuple. - -## Notes - -- Immutable by default (every mutator returns a new `ToolSet` with refined partition types). -- `mutable: true` mutates in place. Partition type parameters may widen for soundness; runtime state is still exact. -- Last-call-wins: each directive on a given ID replaces any prior one for that ID. -- Server tools participate fully in activation once they have an ID. When active they appear in `tools` (and `enabled` / `statusByTool`) but **not** in `activeTools`, which remains the client-name list expected by `callModel`. -- Keep a snapshot's `tools` and `activeTools` together by spreading `.callModel`; `callModel` cannot verify `activeTools` against an unrelated tools array. -- `callModel` ignores names in `activeTools` that are not present in `tools`. Tool-set snapshots avoid stale names by deriving both arrays from the same set. diff --git a/packages/agent-tool-set/package.json b/packages/agent-tool-set/package.json deleted file mode 100644 index 0cfabc79..00000000 --- a/packages/agent-tool-set/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@openrouter/agent-tool-set", - "version": "0.0.0", - "author": "OpenRouter", - "description": "Declarative activation/deactivation for @openrouter/agent tools. Port of ai-tool-set (MIT © Chris Cook) adapted for callModel + tool().", - "keywords": [ - "openrouter", - "agent", - "tools", - "toolset", - "typescript", - "ai" - ], - "license": "Apache-2.0", - "type": "module", - "main": "./esm/index.js", - "exports": { - ".": { - "types": "./esm/index.d.ts", - "default": "./esm/index.js" - }, - "./package.json": "./package.json" - }, - "sideEffects": false, - "repository": { - "type": "git", - "url": "https://github.com/OpenRouterTeam/typescript-agent.git", - "directory": "packages/agent-tool-set" - }, - "publishConfig": { - "access": "public", - "provenance": true - }, - "files": [ - "esm", - "package.json", - "README.md", - "THIRD_PARTY_NOTICES.md" - ], - "scripts": { - "lint": "biome check src tests", - "lint:fix": "biome check --write src tests", - "build": "tsc", - "test": "vitest --run --project unit", - "test:watch": "vitest --watch --project unit", - "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "compile": "tsc" - }, - "dependencies": { - "@openrouter/agent": "workspace:*" - }, - "peerDependencies": { - "zod": "^4.0.0" - } -} diff --git a/packages/agent-tool-set/src/index.ts b/packages/agent-tool-set/src/index.ts deleted file mode 100644 index c8650d41..00000000 --- a/packages/agent-tool-set/src/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -export { createToolSet, ToolSet } from './tool-set.js'; -export type { - ActivatePartition, - ActivationInput, - ActivationPredicate, - ApplySituationPartition, - ClientToolName, - ClientToolNamesOfTuple, - ConditionalPartition, - DeactivatePartition, - EmptyPartition, - EmptySituations, - FilterToolsByIds, - InferAllIds, - InferConditionalIds, - InferDisabledIds, - InferEnabledIds, - InferSituationEntry, - InferSituationMap, - InferToolSet, - InitialPartition, - Partition, - ResolvedToolSnapshot, - ResolvedTools, - ServerToolIdOf, - ServerToolIdsOfTuple, - SituationConditionalRule, - SituationConfig, - SituationMap, - SituationNames, - StatusByToolMap, - StatusReason, - ToolById, - ToolIdOf, - ToolIdsOfTuple, - ToolSetLike, - ToolStatusEntry, - WidenedPartition, - WidenedSituationMap, -} from './types.js'; diff --git a/packages/agent-tool-set/tsconfig.json b/packages/agent-tool-set/tsconfig.json deleted file mode 100644 index 51bb3edc..00000000 --- a/packages/agent-tool-set/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "esm" - }, - "include": ["src"], - "exclude": ["node_modules", "esm"] -} diff --git a/packages/agent-tool-set/vitest.config.ts b/packages/agent-tool-set/vitest.config.ts deleted file mode 100644 index c64e27f0..00000000 --- a/packages/agent-tool-set/vitest.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { config } from 'dotenv'; -import { defineConfig } from 'vitest/config'; - -config({ - path: new URL('../../.env', import.meta.url), -}); - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - env: { - OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, - }, - typecheck: { - enabled: true, - }, - projects: [ - { - extends: true, - test: { - name: 'unit', - include: [ - 'tests/unit/**/*.test.ts', - 'src/lib/**/*.test.ts', - ], - testTimeout: 10000, - hookTimeout: 10000, - }, - }, - ], - }, -}); diff --git a/packages/agent/README.md b/packages/agent/README.md index ec5865c7..3e03529c 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -56,6 +56,31 @@ const text = await result.getText(); console.log(text); ``` +## Optional subpaths + +The base package stays focused: `@openrouter/agent` is marked `sideEffects: false`, +and the root entry does not import either optional integration. + +- `@openrouter/agent/tool-set` adds declarative, state-aware tool activation with + no additional package installation. +- `@openrouter/agent/mcp` adds remote MCP discovery, caching, rehydration, and + tool wrapping. Install its optional peer only when you use this subpath: + +```bash +pnpm add @openrouter/agent @modelcontextprotocol/sdk +``` + +```ts +import { createMCPTools } from '@openrouter/agent/mcp'; +import { createToolSet } from '@openrouter/agent/tool-set'; +``` + +Because the MCP SDK is an optional peer rather than a regular dependency, base +agent consumers do not install its transitive dependencies. The compiled MCP +adapter is included in the agent tarball, but bundlers only reach it through the +explicit `/mcp` exports. `@openrouter/mcp` remains available as a compatibility +facade for existing applications. + ## Features ### Multiple Response Consumption Patterns @@ -68,14 +93,10 @@ const result = callModel(client, { model, input, tools }); // Await the final text const text = await result.getText(); -// Await the full response with usage data (the FINAL round only) +// Await the full response with usage data const response = await result.getResponse(); console.log(response.usage); // { inputTokens, outputTokens, cost, ... } -// Await aggregate usage across EVERY round of the tool loop -const usage = await result.getUsage(); -console.log(usage); // { modelCalls, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens, cost? } - // Stream text deltas for await (const delta of result.getTextStream()) { process.stdout.write(delta); @@ -113,47 +134,12 @@ What each stream emits: | `getReasoningStream()` | reasoning deltas | | `getToolStream()` | tool-call **argument deltas**; `preliminary_result` events for generator tools — *not* execution results | | `getToolCallsStream()` | parsed tool calls as they complete | -| `getItemsStream()` | all output items (messages, function calls, …) — output items **only**, no usage/response metadata | -| `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events, and each round's `response.completed` (with that round's usage block) | - -#### Usage across a multi-round tool loop - -`getResponse()` resolves to the **final** round's response, so in a -multi-round tool loop the tokens spent on the intermediate `tool_calls` -generations are not in `response.usage`. `getItemsStream()` carries output -items only and never surfaces `response.completed`, so usage is not reachable -from that stream either. - -`getUsage()` closes the gap with aggregate totals across every model call the -run made — the initial request, each tool-round follow-up, the empty-final -retry, the `allowFinalResponse` final turn, and approval-resume requests: - -```typescript -const result = callModel(client, { model, input, tools }); - -for await (const item of result.getItemsStream()) { - render(item); -} - -const usage = await result.getUsage(); -console.log(usage.modelCalls, usage.totalTokens, usage.cost); -``` - -It gates on run completion like `getResponse()` does, so the totals are final -whether you await it directly, after `getResponse()`, or after draining any of -the streaming getters. Unlike `getResponse()` it never rejects — a failed run -still consumed tokens — and returns the totals accrued so far, with -`modelCalls: 0` and zeroed tokens when no model call completed. `cost` is -present only when the server reported cost accounting. - -Same `SessionUsageTotals` shape and numbers as the `SessionEnd` hook's -`totalUsage`. For **per-call** granularity use the `PostModelCall` hook (one -emit per model call, with `turnType`/`turnNumber`) or read each round's -`response.completed` off `getFullResponsesStream()`. +| `getItemsStream()` | all output items (messages, function calls, …) | +| `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events | ### Tool Types -The `tool()` factory creates type-safe tools with full Zod schema inference. In addition to the legacy kinds below, the unified `run` interface with `lifecycle: 'sync' | 'background' | 'deferred'` covers [async tools](#async-tools) whose results arrive after the tool round, and `tool.agent()` creates [subagent tools](#agent-tools-subagents). +The `tool()` factory creates type-safe tools with full Zod schema inference. Three tool types are supported: **Regular tools** — automatically executed by the agent loop: @@ -196,26 +182,6 @@ const confirmTool = tool({ }); ``` -**Forced tool choices** are one-shot for each resolved semantic value. After a -forced choice produces a tool call, unchanged follow-up choices are relaxed to -`auto` so the model can either call another tool or answer in text. A dynamic -choice re-arms when it resolves to a different value (or after an unforced -turn): - -```typescript -callModel(client, { - model: 'openai/gpt-4o', - input: 'Plan, research, then submit.', - tools: [planTool, searchTool, submitTool] as const, - toolChoice: ({ numberOfTurns }) => - numberOfTurns === 0 - ? { type: 'function', name: 'plan' } - : numberOfTurns === 3 - ? { type: 'function', name: 'submit' } - : 'auto', -}); -``` - ### Stop Conditions Control when the agent loop stops executing tools: @@ -344,37 +310,11 @@ if (verdict) console.warn(verdict.message); Detection is **deterministic** — a verdict is a pure function of the transcript, so the same sequence of calls/text always fires at the same -point. Repeated **rounds** build a per-tool streak: interleaved calls to -*other* tools don't reset it, and N identical calls fanned out in parallel -within ONE round count once (a streak measures the model re-issuing a call -*after seeing its result*, which requires a round trip). - -Two kinds of evidence accumulate side by side, and the stronger one decides: - -- **Round-set streaks.** A round's identity for one tool is the **set** of - calls it made, so a fan-out of *distinct* arguments reissued verbatim - counts: `read(a), read(b), read(c)` every round accumulates. Ordering - within the round is irrelevant, and a round whose membership changes — in - either direction — resets this streak, since adding or dropping work is - progress for the round as a unit. -- **Per-call streaks.** Each `(tool, arguments)` identity also counts its own - consecutive rounds, whatever its round-mates did. A call repeating inside - varying company (`[a,b]`, `[a,c]`, `[a,d]` — `a` is a 3-peat) is flagged - even though every round's set differs, and a repeat spanning an approval - pause keeps counting when the paused member drops from the resumed round. - For an exactly-repeating round both counts are equal, so nothing - double-fires. - -When a repeating fan-out crosses a rung, every call in the round gets the -verdict (so `block` stops the whole fan-out, not just one member), and calls -carrying the SAME evidence share byte-identical text — the `steer` rung -dedupes on exact text, so one piece of evidence injects one correction. A -round can carry two pieces of evidence at once (`[a]`, `[a,b]`, `[a,b]`: by -round 3, `a` is a 3-peat call while `{a,b}` is a 2-peat set), in which case -each renders its own message — at most two per tool per round, each stating -a distinct fact. When the per-call count alone crosses a rung, only that -call is refused and genuinely new round-mates run free. The streak crosses -a graduated ladder — strongest crossed rung wins: +point. Identical calls in consecutive **rounds** build a per-tool streak: +interleaved calls to *other* tools don't reset it, and N identical calls +fanned out in parallel within ONE round count once (a streak measures the +model re-issuing a call *after seeing its result*, which requires a round +trip). The streak crosses a graduated ladder — strongest crossed rung wins: | Action | Effect | |---|---| @@ -424,11 +364,10 @@ rungs (a weaker threshold at or past an enabled stronger one) warn, and so does an `escalate` rung without an `escalation` config (or vice versa). **Tools declare what identifies a call** via `loopKey` on the tool -definition — a computed function over the call's validated arguments, like -every other tool hook, or `false` to exempt: +definition — a **function or a variable**: ```typescript -// Compute the identity — a web-search tool normalizes its query. +// Function: compute the identity — a web-search tool normalizes its query. tool({ name: 'web_search', inputSchema: z.object({ query: z.string() }), @@ -436,16 +375,17 @@ tool({ execute: async ({ query }) => search(query), }); -// Return the subset of fields that matter. A bash call is identified by -// the command AND where it runs; other fields (e.g. verbose) don't count. +// Variable (field list): declarative subset — data, not code, so it +// survives serializable tool caches. A bash call is identified by the +// command AND where it runs; other fields (e.g. verbose) don't count. tool({ name: 'bash', inputSchema: z.object({ command: z.string(), cwd: z.string(), verbose: z.boolean() }), - loopKey: ({ command, cwd }) => ({ command, cwd }), + loopKey: ['command', 'cwd'], execute: async ({ command, cwd }) => run(command, cwd), }); -// false: statically exempt — repetition is this tool's job. +// Variable (false): statically exempt — repetition is this tool's job. tool({ name: 'check_status', inputSchema: z.object({ jobId: z.string() }), @@ -458,24 +398,8 @@ A function-form `loopKey` may return `null` to exempt an individual call. Returning `undefined`, throwing, or returning unhashable material (bigint, circular, >64 levels deep) falls back to the full-arguments identity with a warning — detection never fails a run. Without any `loopKey`, the full -validated arguments object is the identity. A field-name array -(`loopKey: ['command', 'cwd']`) is also accepted — data rather than code, -so it can be serialized into MCP tool caches and advertised over the wire -via `_meta['openrouter/loopKey']`. MCP-wrapped tools accept a `loopKey` -via `markMcp(tool, { loopKey })` or the `loopKeys` map on -`createMCPTools`. - -> **Exempt tools that repeat by design — including repeating *fan-outs*.** -> The detector compares arguments, not results, so a call whose arguments are -> stable while its results change is indistinguishable from a loop. Since a -> round's identity is now the whole *set* of a tool's calls, this covers -> parallel shapes too: an agent that re-reads the same context files at the -> start of every turn, or fans out a fixed set of pollers, accumulates a streak -> and is refused at the default `block` rung from round 3 — and because every -> call in the round gets the verdict, that is N synthesized error outputs per -> round, not one. These shapes were invisible before this behavior existed, so -> `loopKey: false` (or a `loopKey` returning `null`) is the opt-out for any -> tool whose repetition is legitimate. +validated arguments object is the identity. MCP-wrapped tools accept a +`loopKey` via `markMcp(tool, { loopKey })` (prefer the field-list form). **Fingerprints are a cross-port contract**: key material is canonicalized per RFC 8785 (JCS) and hashed with SHA-256 over the UTF-8 bytes, so the @@ -499,7 +423,7 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Varying-input loops.** The fingerprint is identity-based: a model that invents a fresh nonce/timestamp field each call evades the default - whole-arguments identity entirely. A `loopKey` that returns the meaningful + whole-arguments identity entirely. A `loopKey` that names the meaningful fields closes this per tool; the structural fix (outcome hashing — the progress-ledger detector from the design doc) is planned, not shipped. - **Paraphrased repetition.** Text detectors require exact repeated token @@ -512,179 +436,6 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Manual/client-executed calls** pause the loop for the caller and are not recorded (only executed, blocked, and parse-error calls are evidence). -- **Cross-tool round patterns.** Streaks are per tool: a loop alternating - BETWEEN tools with no per-tool repetition (`read(a)` one round, `grep(a)` - the next, forever) shows each tool a sparse pattern its own evidence - cannot condemn. Interleaved calls to other tools never *reset* a tool's - streak, so an every-other-round repeat still accumulates — slowly. - -### Async Tools - -One `tool()` shape covers every execution lifecycle. Write an ordinary `run` — an async function or an async generator — and say what kind it is with `lifecycle`: - -- **`'sync'`** (default): awaited in the round, exactly like `execute`. -- **`'background'`**: the loop keeps going. Work settling within the grace window (`graceMs`, default 250ms) behaves like a plain sync call; otherwise the model immediately receives a pending placeholder and the return value is injected as a `tool_task_result` message when it settles. -- **`'deferred'`**: `run` returns `ctx.defer(taskId)` to park the call on durable external work — the run pauses (`status: 'awaiting_async_tools'`) until the task is completed from any process. Returning a plain value resolves immediately. - -Generator `run` yields become the task's **log** (feeding check-ins, `tool.preliminary_result` events, and transcripts); the generator's **return** is the result, validated against `outputSchema`. Non-generator bodies log with `ctx.log()`. - -```typescript -const renderVideo = tool({ - name: 'render_video', - lifecycle: 'background', - inputSchema: z.object({ script: z.string() }), - outputSchema: z.object({ url: z.string() }), - ack: 'Rendering started.', - timeoutMs: 300_000, - run: async function* ({ script }, ctx) { - const job = await renderer.start(script, { signal: ctx?.signal }); - ctx?.onMessage((msg) => job.reprioritize(msg)); // steering opt-in - for await (const p of job.progress()) yield { pct: p }; - return job.result(); - }, -}); - -const legalReview = tool({ - name: 'request_legal_review', - lifecycle: 'deferred', - inputSchema: z.object({ contractId: z.string() }), - outputSchema: z.object({ approved: z.boolean() }), - run: async ({ contractId }, ctx) => { - const ticket = await legal.open(contractId, { conversationId: ctx?.conversationId }); - return ctx!.defer(ticket.id); - }, -}); -``` - -Deferred completion is typed and lives on the tool — callable from any process holding the `StateAccessor`: - -```typescript -// webhook handler — hours later, different process -await legalReview.resolve(client, { - state: makeAccessor(conversationId), - taskId: ticketId, - output: { approved: true }, // ← typechecked against outputSchema - run: { model: 'openai/gpt-4o' }, // continue immediately (omit to record-only) -}); -``` - -`legalReview.fail(...)` / `legalReview.cancel(...)` complete the surface; double resolution throws `ToolTaskAlreadySettledError`. The low-level `resumeToolResults()` handles batches. When a run would end with background work in flight, `asyncTools.onRunEnd` decides: `'drain'` (default), `'detach'`, or `'cancel'`. - -> **Security:** `.resolve()` injects a value the model treats as a tool result. Authenticate the webhook before calling it — the SDK cannot do that for you. Outputs are validated against `outputSchema` at runtime as well as compile time. - -### Checking On Long-Running Tasks - -When any long-running tool is registered, the SDK appends **one universal `task` tool** to the request — a single static wire definition regardless of how many async tools exist (per-tool schemas are never augmented, so context cost stays constant). The pending placeholder tells the model to use it: - -```typescript -task({ taskId: "task_7f3" }) // status: state, elapsed, last log -task({ taskId, view: "logs", tail: 5 }) // recent progress entries -task({ taskId, view: "transcript" }) // full detail (agents: child conversation) -task({ taskId, action: "steer", message: "..." }) // send guidance to the running task -task({ taskId, action: "result" }) // final result if settled, else status -task({ taskId, action: "cancel", reason: "..." }) // stop the task -``` - -Calls are engine-intercepted and dispatched to the **owning tool's** `check` config — the wire surface is universal, the handling stays tool-specific: - -```typescript -const renderVideo = tool({ - name: 'render_video', - lifecycle: 'background', - // ... as above ... - check: { // optional — SDK default when absent - schema: z.object({ focus: z.string().optional() }), // validates task({ params }) - execute: async (params, turnContext) => { - // turnContext.toolCallStatus → 'working' | 'completed' | ... - // turnContext.accumulatedYieldedEvents → every run yield so far - // turnContext.task → { statusView, tailLogs, transcript, send, cancel } - if (params.focus) turnContext.task?.send(params.focus); - return turnContext.task?.statusView(); - }, - }, -}); -``` - -Without a custom `check`, the SDK default answers the three views (`status` / `logs` / `transcript`, truncated to `asyncTools.maxTranscriptChars`, default 20k). Task-tool calls are doom-loop-exempt, bypass per-tool concurrency/timeout gates, and never fire Pre/PostToolUse hooks — but a `PermissionRequest` hook denial recorded for the call IS honored, so a policy layer can veto `cancel`/`steer`. Disable entirely with `asyncTools: { checkins: false }` (placeholders then revert to "do not call this tool again"). The name `task` is reserved: `tool()` and `tool.agent()` reject it at definition time; a dynamically-built tool list that bypasses `tool()` and claims the name suppresses the built-in with a warning (and the engine routes `task` calls to that user tool instead of intercepting them). - -After a process restart, deferred tasks answer `status` from persisted state (including a bounded `lastLog`); full logs and transcripts are in-memory only and report an explanatory note instead. - -### Steering Running Tasks - -- **From code:** `result.sendToTask(taskId, message)` delivers into the run body's `ctx.onMessage` handler (queued until one registers). Deferred tasks throw — their work runs in an external system. -- **From the model:** `task({ taskId, action: 'steer', message })` delivers directly, or expose custom `params` handled by `check.execute` with `turnContext.task.send(...)`. -- **Agent tools** forward steering messages into the child conversation automatically (as user messages at the child's next turn boundary). - -### Agent Tools (Subagents) - -`tool.agent()` creates a tool whose work IS a child `callModel` conversation, running as a background task: - -```typescript -const researcher = tool.agent({ - name: 'research_topic', - description: 'Deep-research a topic in the background.', - inputSchema: z.object({ topic: z.string() }), - outputSchema: z.object({ text: z.string() }), - agent: ({ topic }) => ({ - model: 'openai/gpt-4o', - input: `Research: ${topic}`, - tools: [searchTool, fetchTool] as const, - stopWhen: stepCountIs(15), - }), - // default result mapper — Dennis-style last_message outcome: - result: async (child) => ({ text: await child.getText() }), -}); -``` - -The parent keeps working while children run (several can run concurrently under the background pool). The child's conversation is the check-in **transcript**; each child turn is a **log** entry; `status` reports `turnsCompleted` and `currentActivity`. `cancelTask(taskId)` (or parent abort / `timeoutMs`) cancels the child; `sendToTask` steers it mid-run. Children run **in-memory** (no `StateAccessor`) and do not inherit the parent's hooks — pass child hooks explicitly in the `agent` spec if needed. A child that pauses (HITL/manual/approval/deferred tools inside it) fails the task with a clear error. - -### Strict Tool Schemas - -Every client tool kind, including `tool.agent()`, accepts `strict: true` to -request provider-enforced schema adherence for generated tool-call arguments. -The SDK faithfully converts the caller's `inputSchema`; it does not rewrite -the runtime Zod contract. - -OpenAI-style strict function calling requires every declared object property -to appear in JSON Schema's `required` list. Use `.nullable()` for a value that -may be absent conceptually, because `.optional()` omits the property from -`required`: - -```typescript -const weatherTool = tool({ - name: 'get_weather', - inputSchema: z.object({ - location: z.string(), - // The key is required, but the model may return null. - units: z.enum(['celsius', 'fahrenheit']).nullable(), - }), - strict: true, - execute: async ({ location, units }) => getWeather(location, units), -}); -``` - -The SDK sends the generated schema unchanged. Providers validate it according -to their own strict-mode dialect and the SDK propagates any API error. Use -`.nullable()`, or set `strict: false` when omission is part of the tool's -contract. Provider support and strict-schema restrictions can vary. - -### Per-Tool Timeouts & Concurrency - -Every tool kind accepts `timeoutMs` (per-execution deadline; the run-level `toolTimeoutMs` sets a default) and `maxConcurrency` (max simultaneous executions of that tool). On timeout the round stops waiting — the model receives `{ error, code: 'tool_timeout' }` and the tool's `ctx.signal` aborts; the timeout bounds the round's *wait*, not the tool body, so signal-ignoring bodies can't hang the run. `ctx.signal` also fires on run abort (`signal` option) and `ModelResult.cancel()`. - -Round-level parallelism (unbounded by default, matching previous behavior) is capped with `toolConcurrency`: - -```typescript -const result = callModel(client, { - model: 'openai/gpt-4o', - input: 'fan out', - tools: [searchTool] as const, - toolTimeoutMs: 30_000, - toolConcurrency: { round: 4, background: 8 }, // or a bare number for { round: n } -}); -``` - -Execution order may change under a cap; output order never does (results stay in call order for prompt-cache stability). ### Tool Approval @@ -793,7 +544,7 @@ const result = callModel(client, { model, input, tools, hooks }); | `SessionStart` | Once per run, before the initial request. `config` summarizes the session (`hasTools`, `hasApproval`, `hasState`) | none (void) | | `SessionEnd` | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. `reason` is `'complete' \| 'error' \| 'max_turns' \| 'user' \| 'doom_loop'`. When at least one model call completed, `totalUsage` aggregates tokens/cost across all of them (`modelCalls`, `inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, and `cost` when the server reported it) | none (void) | | `PostModelCall` | Once per completed model response, on **every** request the loop makes — initial, each tool-round follow-up, the empty-final retry, the `allowFinalResponse` final turn, and approval-resume requests. Payload: `responseId` (the OpenRouter generation id), `model`, `durationMs` (dispatch → fully materialized response, including stream consumption), `turnType` (`'initial' \| 'resume' \| 'tool_round' \| 'final' \| 'retry'`), `turnNumber`, and `usage` (`inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, `cost?`) when the server reported usage accounting. Purely observational — the telemetry primitive for tracing/benchmark consumers: one span per model call | none (void) | -| `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — *identical* parallel duplicates in one round share the event, but a repeating fan-out of DISTINCT arguments emits one event per member, since each is its own `(tool, fingerprint)` (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` | +| `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — parallel duplicates in one round share the event (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` | Notes on lifecycle pairing: `SessionEnd` only fires when a matching `SessionStart` succeeded, and at most once per run. Pending async hook work is @@ -808,9 +559,6 @@ a materialized response emits no `PostModelCall`; a `response.incomplete` response (e.g. truncated at `max_output_tokens`) **does** emit — it carries a real generation id and consumed tokens. Note `usage.cost` is only present when the request had usage accounting enabled server-side. - -`SessionEnd.totalUsage` is push-based; for the same totals without registering -a hook, await [`getUsage()`](#usage-across-a-multi-round-tool-loop). Every handler receives `(payload, context)` — `context` carries the `sessionId` (the single source of session identity; payloads do not repeat it), the `hookName`, and an `AbortSignal` for cooperative cancellation. The @@ -1050,6 +798,192 @@ const chatMsg = toChatMessage(openRouterMessage); const orMessages2 = fromChatMessages(chatMessages); ``` +### Tool Sets + +Declarative, state-aware activation and deactivation for tools used with `@openrouter/agent`. + +Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Cook), adapted for this SDK's ordered `Tool[]` / `callModel` model. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). + +**What it adds:** + +- **Stable tool-set IDs** for every addressable tool: + - client tools → `function.name` + - server tools → `server:${config.type}` by default (overridable via `serverTool(config, { id })`) +- A **typed three-way partition** of those IDs: definitely enabled, definitely disabled, conditional. +- **Exhaustive runtime snapshots** from `resolve()` / `resolveSituation()` — every ID appears in `statusByTool`. +- **Named declarative situations** with compile-time exact tool tuples when the situation is fully static. +- Integration with `callModel`'s `activeTools` option via the snapshot's spread-safe `.callModel` input. + +```ts +import { OpenRouter, tool, serverTool, callModel } from '@openrouter/agent'; +import { + createToolSet, + type InferEnabledIds, + type InferDisabledIds, + type InferConditionalIds, + type InferAllIds, +} from '@openrouter/agent/tool-set'; +import { z } from 'zod/v4'; + +type AppContext = { + isAuthenticated: boolean; + isAdmin: boolean; +}; + +const listOrders = tool({ + name: 'list_orders', + inputSchema: z.object({}), + execute: async () => ({ orders: [] }), +}); + +const cancelOrder = tool({ + name: 'cancel_order', + inputSchema: z.object({ id: z.string() }), + execute: async () => ({ ok: true }), +}); + +const login = tool({ + name: 'login', + inputSchema: z.object({}), + execute: async () => ({ token: '…' }), +}); + +const webSearch = serverTool({ type: 'web_search_2025_08_26' }); +// id defaults to 'server:web_search_2025_08_26' + +const allTools = [listOrders, cancelOrder, login, webSearch] as const; + +const toolSet = createToolSet({ tools: allTools }) + .deactivate('cancel_order') + .activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true) + .defineSituations({ + guest: { + enabled: ['login', 'server:web_search_2025_08_26'], + disabled: ['list_orders', 'cancel_order'], + }, + authenticated: { + enabled: ['list_orders', 'server:web_search_2025_08_26'], + disabled: ['login'], + conditional: { + cancel_order: ({ context }) => context?.isAdmin === true, + }, + }, + }); + +// Compile-time partition of the *base* set (before a situation overlay): +type All = InferAllIds; +// 'list_orders' | 'cancel_order' | 'login' | 'server:web_search_2025_08_26' +type Enabled = InferEnabledIds; // excludes cancel_order + list_orders (conditional) +type Disabled = InferDisabledIds; // 'cancel_order' +type Conditional = InferConditionalIds; // 'list_orders' + +const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); + +// Named static situation → exact tool tuple at compile time +const guest = toolSet.resolveSituation('guest'); +// guest.tools is exactly [login, webSearch] +// guest.enabled / guest.disabled / guest.statusByTool are exhaustive + +const authenticated = toolSet.resolveSituation('authenticated', { + context: { isAuthenticated: true, isAdmin: false }, +}); + +const result = callModel(client, { + model: 'openai/gpt-4o-mini', + input: 'List my orders.', + ...authenticated.callModel, +}); +``` + +**Identity** + +| Kind | Tool-set ID | +| --- | --- | +| Client `tool({ name: 'x' })` | `'x'` | +| `serverTool({ type: 'web_search_2025_08_26' })` | `'server:web_search_2025_08_26'` | +| `serverTool(config, { id: 'server:public_search' })` | `'server:public_search'` | + +Duplicate IDs throw at `createToolSet` construction. Activation methods accept only known IDs. + +**Compile-time vs runtime exactness** + +| Resolution style | Developer-time knowledge | Runtime knowledge | +| --- | --- | --- | +| Static `activate` / `deactivate` | Exact partition | Exact snapshot | +| Named static situation (`enabled`/`disabled` only) | Exact filtered tool tuple | Exact snapshot | +| `activateWhen` / `deactivateWhen` / situation `conditional` | Upper bound (`enabled ∪ conditional`) | Exact snapshot after predicates | +| Mutable `ToolSet` | Partition types may widen | Exact snapshot | + +The type system cannot execute predicates. Conditional IDs therefore expand the compile-time upper bound of active tools; after `resolve`, the returned arrays and `statusByTool` are always exhaustive and exact. + +**API** + +`createToolSet({ tools, mutable? })` — Build a set from an ordered tool array. Optional `TShared` types the `context` argument on predicates. Defaults to immutable. + +`.tools` — Concrete tools tuple in construction order (client + server), regardless of activation. + +`.activate(id | id[])` / `.deactivate(id | id[])` — Static flip (last-call-wins). Accepts client names **and** server IDs. Updates the compile-time partition. + +`.activateWhen(id, predicate)` / `.activateWhen({ [id]: predicate })` — Conditional activation — defaults inactive, becomes active when predicate returns `true`. Moves the ID into the conditional partition. + +`.deactivateWhen(id, predicate)` / `.deactivateWhen({ [id]: predicate })` — Conditional deactivation — defaults active, becomes inactive when predicate returns `true`. Also moves the ID into the conditional partition. + +Predicate input: `{ state?: ConversationState; context?: TShared }`. + +`.defineSituations({ [name]: config })` — Declarative named situations. Each config may include: + +- `enabled?: readonly Id[]` — statically on +- `disabled?: readonly Id[]` — statically off +- `conditional?: { [id]: predicate | { mode?, predicate } }` — runtime rules + +Situation overlays the base partition for every ID it mentions; unmentioned IDs keep the base state. Unknown, duplicate, or conflicting IDs within one situation throw. + +`.resolve(input?)` → snapshot + +```ts +{ + tools: /* active tools, construction order, concrete types */; + activeTools: /* active *client* names for callModel */; + callModel: { tools, activeTools }; // safe to spread into callModel() + enabled: /* every active ID (client + server) */; + disabled: /* every inactive ID */; + statusByTool: { + [id]: { + enabled: boolean; + reason: 'default' | 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen' | 'situation'; + directive?: 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen'; + predicate?: boolean; // true when a runtime predicate decided the result + }; + }; +} +``` + +`.resolveSituation(name, input?)` → snapshot — Same shape as `resolve`, with the named situation overlay applied first. + +`.inferTools(input?)` — Back-compat alias for `resolve`. Prefer `resolve` in new code. + +`.clone({ mutable? })` — Copy state, optionally flipping mode. + +Inference utilities: + +```ts +type All = InferAllIds; +type Enabled = InferEnabledIds; +type Disabled = InferDisabledIds; +type Conditional = InferConditionalIds; +``` + +`InferToolSet` — Alias of the agent's `CorrelatedToolEventUnion` — name-correlated preliminary/result stream events based on a tools tuple. + +**Notes** + +- Immutable by default (every mutator returns a new `ToolSet` with refined partition types). +- `mutable: true` mutates in place. Partition type parameters may widen for soundness; runtime state is still exact. +- Last-call-wins: each directive on a given ID replaces any prior one for that ID. +- Server tools participate fully in activation once they have an ID. When active they appear in `tools` (and `enabled` / `statusByTool`) but **not** in `activeTools`, which remains the client-name list expected by `callModel`. +- Keep a snapshot's `tools` and `activeTools` together by spreading `.callModel`; `callModel` cannot verify `activeTools` against an unrelated tools array. +- `callModel` ignores names in `activeTools` that are not present in `tools`. Tool-set snapshots avoid stale names by deriving both arrays from the same set. + ## Subpath Exports For tree-shaking or targeted imports, the package provides granular subpath exports: @@ -1066,12 +1000,7 @@ import { toChatMessage } from '@openrouter/agent/chat-compat'; import { ToolContextStore } from '@openrouter/agent/tool-context'; import { ToolEventBroadcaster } from '@openrouter/agent/tool-event-broadcaster'; import { createInitialState } from '@openrouter/agent/conversation-state'; -import { resumeToolResults } from '@openrouter/agent/resume-tool-results'; -import { Semaphore } from '@openrouter/agent/tool-concurrency'; -import { AsyncToolRegistry } from '@openrouter/agent/async-tool-registry'; -import { ToolTask } from '@openrouter/agent/tool-task'; -import { TaskToolInputSchema } from '@openrouter/agent/tool-check'; -import { AgentTranscriptSource } from '@openrouter/agent/agent-tool'; +import { createToolSet } from '@openrouter/agent/tool-set'; ``` ## Development diff --git a/packages/agent-tool-set/THIRD_PARTY_NOTICES.md b/packages/agent/THIRD_PARTY_NOTICES.md similarity index 95% rename from packages/agent-tool-set/THIRD_PARTY_NOTICES.md rename to packages/agent/THIRD_PARTY_NOTICES.md index 417083d7..7771701b 100644 --- a/packages/agent-tool-set/THIRD_PARTY_NOTICES.md +++ b/packages/agent/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-Party Notices -`@openrouter/agent-tool-set` is adapted from [`ai-tool-set` v1.0.0](https://github.com/zirkelc/ai-tool-set/tree/v1.0.0), which is licensed under the MIT License: +`@openrouter/agent/tool-set` is adapted from [`ai-tool-set` v1.0.0](https://github.com/zirkelc/ai-tool-set/tree/v1.0.0), which is licensed under the MIT License: > MIT License > diff --git a/packages/agent/package.json b/packages/agent/package.json index add01ee5..b364a068 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -92,35 +92,35 @@ "types": "./esm/lib/turn-context.d.ts", "default": "./esm/lib/turn-context.js" }, + "./tool-set": { + "types": "./esm/lib/tool-set.d.ts", + "default": "./esm/lib/tool-set.js" + }, "./openrouter": { "types": "./esm/openrouter.d.ts", "default": "./esm/openrouter.js" }, - "./package.json": "./package.json", - "./tool-concurrency": { - "types": "./esm/lib/tool-concurrency.d.ts", - "default": "./esm/lib/tool-concurrency.js" + "./mcp": { + "types": "./esm/mcp/index.d.ts", + "default": "./esm/mcp/index.js" }, - "./async-tool-registry": { - "types": "./esm/lib/async-tool-registry.d.ts", - "default": "./esm/lib/async-tool-registry.js" + "./mcp/create-mcp-tools": { + "types": "./esm/mcp/create-mcp-tools.d.ts", + "default": "./esm/mcp/create-mcp-tools.js" }, - "./resume-tool-results": { - "types": "./esm/inner-loop/resume-tool-results.d.ts", - "default": "./esm/inner-loop/resume-tool-results.js" + "./mcp/types": { + "types": "./esm/mcp/types.d.ts", + "default": "./esm/mcp/types.js" }, - "./tool-task": { - "types": "./esm/lib/tool-task.d.ts", - "default": "./esm/lib/tool-task.js" + "./mcp/schema": { + "types": "./esm/mcp/schema/json-schema-to-zod.d.ts", + "default": "./esm/mcp/schema/json-schema-to-zod.js" }, - "./tool-check": { - "types": "./esm/lib/tool-check.d.ts", - "default": "./esm/lib/tool-check.js" + "./mcp/cache": { + "types": "./esm/mcp/cache/cache-store.d.ts", + "default": "./esm/mcp/cache/cache-store.js" }, - "./agent-tool": { - "types": "./esm/lib/agent-tool.d.ts", - "default": "./esm/lib/agent-tool.js" - } + "./package.json": "./package.json" }, "sideEffects": false, "repository": { @@ -135,7 +135,8 @@ "files": [ "esm", "package.json", - "README.md" + "README.md", + "THIRD_PARTY_NOTICES.md" ], "scripts": { "lint": "biome check src tests", @@ -150,5 +151,13 @@ "dependencies": { "@openrouter/sdk": "^0.13.7", "zod": "^4.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } } } diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index bf5bb7ab..fcbdf1bf 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -17,8 +17,8 @@ import type { // Re-export Tool type for convenience export type { Tool } from './tool-types.js'; -/** Identifies objects produced by `@openrouter/agent-tool-set`. */ -export const TOOL_SET_SNAPSHOT = Symbol.for('@openrouter/agent-tool-set/snapshot'); +/** Identifies objects produced by `@openrouter/agent/tool-set`. */ +export const TOOL_SET_SNAPSHOT = Symbol.for('@openrouter/agent/tool-set/snapshot'); const TOOL_SET_SNAPSHOT_METADATA_KEYS: ReadonlySet = new Set([ 'enabled', @@ -87,7 +87,7 @@ type BaseCallModelInput< * Optional filter restricting which tools are exposed to the model for this * call. Tool names not in this list are removed before the request is sent * and are also not callable by the model. Pairs with - * `@openrouter/agent-tool-set`'s `.inferTools()` output — spreading its + * `@openrouter/agent/tool-set`'s `.inferTools()` output — spreading its * `{ tools, activeTools }` (or a whole marked snapshot from `.inferTools()` / * `.resolve()` / `.resolveSituation()`) into this object is safe: `callModel` * strips metadata introduced by that snapshot before sending the request. diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent/src/lib/tool-set-types.ts similarity index 99% rename from packages/agent-tool-set/src/types.ts rename to packages/agent/src/lib/tool-set-types.ts index 59593f6c..b249b1d0 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent/src/lib/tool-set-types.ts @@ -4,8 +4,8 @@ import type { CorrelatedToolEventUnion, ServerToolBase, Tool, -} from '@openrouter/agent'; -import { TOOL_SET_SNAPSHOT } from '@openrouter/agent'; +} from './tool-types.js'; +import { TOOL_SET_SNAPSHOT } from './async-params.js'; // ─── identity ─────────────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ type KeepIfActive = El extends Tool * * A genuine fixed-length tuple (`T['length']` is a literal number) is * filtered by exact head/tail recursion, preserving order and concrete - * per-element types. A dynamic `readonly Tool[]` (e.g. an `@openrouter/mcp` + * per-element types. A dynamic `readonly Tool[]` (e.g. an `@openrouter/agent/mcp` * tool array not typed as a literal tuple) has `number extends T['length']`, * so it falls back to a distributive per-element filter instead of * recursing — the tuple pattern never matches a general array, and without diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent/src/lib/tool-set.ts similarity index 99% rename from packages/agent-tool-set/src/tool-set.ts rename to packages/agent/src/lib/tool-set.ts index 083d2da5..08c4ed75 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent/src/lib/tool-set.ts @@ -1,5 +1,4 @@ -import type { ServerToolBase, Tool } from '@openrouter/agent'; -import { isServerTool, TOOL_SET_SNAPSHOT } from '@openrouter/agent'; +import { TOOL_SET_SNAPSHOT } from './async-params.js'; import type { ActivatePartition, ActivationInput, @@ -27,7 +26,9 @@ import type { ToolStatusEntry, WidenedPartition, WidenedSituationMap, -} from './types.js'; +} from './tool-set-types.js'; +import type { ServerToolBase, Tool } from './tool-types.js'; +import { isServerTool } from './tool-types.js'; type ActivationEntry> = | { diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index e89d7490..0979c125 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -857,7 +857,7 @@ export interface ServerToolBase { readonly _brand: 'server-tool'; readonly config: ServerToolConfig; /** - * Stable tool-set identity used by `@openrouter/agent-tool-set` activation. + * Stable tool-set identity used by `@openrouter/agent/tool-set` activation. * Defaults to `server:${config.type}` when constructed via {@link serverTool}. * * Optional here for source compatibility with legacy hand-constructed @@ -1438,7 +1438,7 @@ export type CorrelatedToolResultEvent = Omit< /** * Widest backward-compatible shape for {@link CorrelatedToolEventUnion} when * `T` is the generic `readonly Tool[]` (e.g. a tool handle from - * `@openrouter/mcp`, whose concrete tuple isn't known at the type level). + * `@openrouter/agent/mcp`, whose concrete tuple isn't known at the type level). * Mirrors the pre-existing {@link ToolPreliminaryResultEvent} / * {@link ToolResultEvent} default shapes. */ diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index a19717ca..cbd76d1c 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -1017,7 +1017,7 @@ tool.agent = agentToolBuilder; /** * Options for {@link serverTool}. - * @template TId Stable tool-set identity used by `@openrouter/agent-tool-set`. + * @template TId Stable tool-set identity used by `@openrouter/agent/tool-set`. */ export type ServerToolOptions = { /** @@ -1076,7 +1076,7 @@ export function serverTool {})` throws before `.catch()` is + * ever attached. On an unwinding path that is actively harmful in two ways: it + * replaces the real error (a useful "couldn't reach the server" becomes an + * opaque teardown failure), and it skips whatever recovery followed the close — + * a fallback that used to self-heal starts rejecting instead. + * + * Every teardown-on-failure site in this package goes through here, so the + * guarantee holds uniformly rather than depending on each caller remembering + * the distinction. + * + * Deliberately not exported from the package entrypoint: callers close through + * `MCPToolsHandle.close()` / `MCPConnection.close()`, which report failures + * rather than swallowing them. This is only for teardown during error unwinding. + */ +export async function closeQuietly(closeable: { close(): Promise }): Promise { + try { + await closeable.close(); + } catch { + // Nothing actionable: we are already unwinding a failure, and the close + // outcome is never the error the caller needs to see. + } +} diff --git a/packages/agent/src/mcp/create-mcp-tools.ts b/packages/agent/src/mcp/create-mcp-tools.ts new file mode 100644 index 00000000..01c9143f --- /dev/null +++ b/packages/agent/src/mcp/create-mcp-tools.ts @@ -0,0 +1,95 @@ +import type { MCPCacheStore } from './cache/cache-store.js'; +import { defaultCacheKey } from './cache/cache-store.js'; +import type { SerializedMCPServer } from './cache/cache-types.js'; +import { isSerializedMCPServer } from './cache/cache-types.js'; +import { freshConnect, normalizeUrl } from './handle.js'; +import type { RehydrateMCPToolsOptions } from './rehydrate.js'; +import { rehydrateMCPTools } from './rehydrate.js'; +import type { CreateMCPToolsOptions, MCPToolsHandle } from './types.js'; + +/** + * Connect to a remote MCP server, discover its tools, and return a handle whose + * `.tools` can be passed straight into `callModel({ tools })`. Auth is supplied + * once and reused for discovery and every subsequent tool call. + * + * When `cache` is provided, a valid non-stale snapshot is rehydrated instead of + * re-listing; otherwise the fresh result is written back to the cache. + */ +export async function createMCPTools(options: CreateMCPToolsOptions): Promise { + const url = normalizeUrl(options.url); + const cacheKey = options.cache?.key ?? defaultCacheKey(url.href); + + if (options.cache !== undefined) { + const hit = await tryCacheHit(options, options.cache.store, cacheKey); + if (hit !== undefined) { + return hit; + } + } + + return freshConnect(options, url, cacheKey); +} + +// Option keys forwarded verbatim from a cache-hit `createMCPTools` call into +// `rehydrateMCPTools`, so a warm handle applies the same auth, filters, prefix, +// loop identities, and credential-caching behavior as a cold one. Anything +// omitted here is SILENTLY DROPPED on a cache hit — when adding an option to +// `CreateMCPToolsOptions` that rehydrate also honors, add it here too. +const FORWARDED_REHYDRATE_KEYS = [ + 'auth', + 'fetch', + 'clientInfo', + 'onUnconvertibleSchema', + 'onElicitation', + 'signal', + 'toolNamePrefix', + 'includeTools', + 'excludeTools', + 'resources', + 'emitProgress', + 'loopKeys', + 'autoRefreshOnListChanged', + 'cacheCredentials', +] as const satisfies readonly (keyof CreateMCPToolsOptions & keyof RehydrateMCPToolsOptions)[]; + +/** Copy the defined forwarded options from `createMCPTools` into a rehydrate base. */ +function forwardedRehydrateOptions( + options: CreateMCPToolsOptions, +): Partial { + const out: Partial = {}; + for (const key of FORWARDED_REHYDRATE_KEYS) { + const value = options[key]; + if (value !== undefined) { + Object.assign(out, { + [key]: value, + }); + } + } + return out; +} + +async function tryCacheHit( + options: CreateMCPToolsOptions, + store: MCPCacheStore, + cacheKey: string, +): Promise { + const snapshot = await store.get(cacheKey); + if (snapshot === null || snapshot === undefined || !isSerializedMCPServer(snapshot)) { + return undefined; + } + const maxAge = options.staleness?.maxAgeMs; + if (maxAge !== undefined && Date.now() - snapshot.cachedAt > maxAge) { + return undefined; + } + // Defer to rehydrate, which reconnects and falls back to a fresh connect on + // expiry. + return rehydrateMCPTools({ + snapshot, + ...forwardedRehydrateOptions(options), + cache: { + store, + key: cacheKey, + }, + }); +} + +export type { SerializedMCPServer }; diff --git a/packages/mcp/src/elicitation.ts b/packages/agent/src/mcp/elicitation.ts similarity index 100% rename from packages/mcp/src/elicitation.ts rename to packages/agent/src/mcp/elicitation.ts diff --git a/packages/mcp/src/errors.ts b/packages/agent/src/mcp/errors.ts similarity index 98% rename from packages/mcp/src/errors.ts rename to packages/agent/src/mcp/errors.ts index 4d1ec677..eb151dd4 100644 --- a/packages/mcp/src/errors.ts +++ b/packages/agent/src/mcp/errors.ts @@ -1,5 +1,5 @@ /** - * Base error for all @openrouter/mcp failures. + * Base error for all @openrouter/agent/mcp failures. */ export class MCPError extends Error { constructor( diff --git a/packages/mcp/src/handle.ts b/packages/agent/src/mcp/handle.ts similarity index 99% rename from packages/mcp/src/handle.ts rename to packages/agent/src/mcp/handle.ts index b3f71e83..beb83793 100644 --- a/packages/mcp/src/handle.ts +++ b/packages/agent/src/mcp/handle.ts @@ -1,4 +1,4 @@ -import type { Tool } from '@openrouter/agent/tool-types'; +import type { Tool } from '../lib/tool-types.js'; import type { BuildToolsOptions } from './build-tools.js'; import { buildTools } from './build-tools.js'; import type { SerializedMCPServer } from './cache/cache-types.js'; diff --git a/packages/agent/src/mcp/index.ts b/packages/agent/src/mcp/index.ts new file mode 100644 index 00000000..14500017 --- /dev/null +++ b/packages/agent/src/mcp/index.ts @@ -0,0 +1,35 @@ +// Main factory + rehydration + +// Auth +export type { MCPAuth } from './auth/auth-types.js'; +export type { MCPCacheStore } from './cache/cache-store.js'; +// Cache +export { defaultCacheKey, InMemoryMCPCacheStore } from './cache/cache-store.js'; +export type { + SerializedMCPServer, + SerializedMCPToolDef, + SerializedTokenSet, +} from './cache/cache-types.js'; +export { isSerializedMCPServer } from './cache/cache-types.js'; +export { createMCPTools } from './create-mcp-tools.js'; +// Errors +export { + MCPCacheError, + MCPConnectionError, + MCPError, + MCPToolCallError, +} from './errors.js'; +export type { RehydrateMCPToolsOptions } from './rehydrate.js'; +export { rehydrateMCPTools } from './rehydrate.js'; +export type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; +// Schema conversion (exported for testing/reuse) +export { convertMcpInputSchema } from './schema/json-schema-to-zod.js'; +// Public option/handle types +export type { + CreateMCPToolsOptions, + ElicitationHandler, + ElicitationResponse, + MCPToolsHandle, + MCPTransportKind, + ResourcesOption, +} from './types.js'; diff --git a/packages/mcp/src/mcp-connection.ts b/packages/agent/src/mcp/mcp-connection.ts similarity index 100% rename from packages/mcp/src/mcp-connection.ts rename to packages/agent/src/mcp/mcp-connection.ts diff --git a/packages/mcp/src/rehydrate.ts b/packages/agent/src/mcp/rehydrate.ts similarity index 100% rename from packages/mcp/src/rehydrate.ts rename to packages/agent/src/mcp/rehydrate.ts diff --git a/packages/mcp/src/resource-tools.ts b/packages/agent/src/mcp/resource-tools.ts similarity index 100% rename from packages/mcp/src/resource-tools.ts rename to packages/agent/src/mcp/resource-tools.ts diff --git a/packages/mcp/src/result-mapper.ts b/packages/agent/src/mcp/result-mapper.ts similarity index 100% rename from packages/mcp/src/result-mapper.ts rename to packages/agent/src/mcp/result-mapper.ts diff --git a/packages/mcp/src/schema/json-schema-guards.ts b/packages/agent/src/mcp/schema/json-schema-guards.ts similarity index 100% rename from packages/mcp/src/schema/json-schema-guards.ts rename to packages/agent/src/mcp/schema/json-schema-guards.ts diff --git a/packages/mcp/src/schema/json-schema-to-zod.ts b/packages/agent/src/mcp/schema/json-schema-to-zod.ts similarity index 100% rename from packages/mcp/src/schema/json-schema-to-zod.ts rename to packages/agent/src/mcp/schema/json-schema-to-zod.ts diff --git a/packages/mcp/src/tool-wrapper.ts b/packages/agent/src/mcp/tool-wrapper.ts similarity index 100% rename from packages/mcp/src/tool-wrapper.ts rename to packages/agent/src/mcp/tool-wrapper.ts diff --git a/packages/mcp/src/transport-types.ts b/packages/agent/src/mcp/transport-types.ts similarity index 100% rename from packages/mcp/src/transport-types.ts rename to packages/agent/src/mcp/transport-types.ts diff --git a/packages/agent/src/mcp/types.ts b/packages/agent/src/mcp/types.ts new file mode 100644 index 00000000..c7a8fc11 --- /dev/null +++ b/packages/agent/src/mcp/types.ts @@ -0,0 +1,125 @@ +import type { Tool, ToolLoopKey } from '../lib/tool-types.js'; +import type { MCPAuth } from './auth/auth-types.js'; +import type { MCPCacheStore } from './cache/cache-store.js'; +import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; +import type { MCPTransportKind } from './transport-types.js'; + +export type { MCPTransportKind }; + +/** + * Response to a server-initiated elicitation request. `accept` must carry + * `content` matching the server's `requestedSchema`. + */ +export type ElicitationResponse = + | { + action: 'accept'; + content: Record; + } + | { + action: 'decline'; + } + | { + action: 'cancel'; + }; + +/** + * Handler for server-initiated `elicitation/create` requests during a tool + * call. If omitted from options, requests are auto-declined so a tool call + * needing input fails gracefully rather than hanging. + */ +export type ElicitationHandler = (request: { + message: string; + requestedSchema: Record; +}) => Promise | ElicitationResponse; + +/** How MCP resources are exposed to the model. */ +export type ResourcesOption = + | boolean + | { + mode?: 'synthetic-tools'; + }; + +export interface CreateMCPToolsOptions { + /** Remote MCP server endpoint. */ + url: string | URL; + /** Transport to use; defaults to `streamableHttp` with SSE fallback. */ + transport?: MCPTransportKind; + /** Authentication, supplied once and reused for discovery + every call. */ + auth?: MCPAuth; + /** Custom fetch implementation for all network requests. */ + fetch?: typeof fetch; + /** Client identity sent during `initialize`. */ + clientInfo?: { + name: string; + version: string; + }; + /** Prefix applied to every wrapped tool name (e.g. `"github_"`). */ + toolNamePrefix?: string; + /** + * Allow-list of MCP tool names to expose. Applies to discovered MCP tools + * only; synthetic `list_resources`/`read_resource` tools are controlled + * exclusively by `resources`. + */ + includeTools?: readonly string[]; + /** + * Deny-list of MCP tool names to skip. Applies to discovered MCP tools only; + * synthetic `list_resources`/`read_resource` tools are controlled + * exclusively by `resources`. + */ + excludeTools?: readonly string[]; + /** Behavior when a tool's JSON Schema can't be fully represented in Zod. */ + onUnconvertibleSchema?: UnconvertibleSchemaMode; + /** Cache store + key for automatic rehydrate-on-hit / write-on-miss. */ + cache?: { + store: MCPCacheStore; + key?: string; + }; + /** Persist resolved tokens/session into the snapshot. Off by default. */ + cacheCredentials?: boolean; + /** Re-list tools when a cached snapshot is older than this. */ + staleness?: { + maxAgeMs?: number; + }; + /** Expose resources as synthetic `list_resources`/`read_resource` tools. */ + resources?: ResourcesOption; + /** Map MCP progress notifications to generator-tool events. Default true. */ + emitProgress?: boolean; + /** + * Doom-loop identities for wrapped tools (see the `doomLoop` option on + * `callModel`), keyed by the tool's UNPREFIXED MCP name. Any `ToolLoopKey` + * form: a function computing key material, a declarative field-name array + * (e.g. `{ run_command: ['command', 'cwd'] }`), or `false` to exempt a + * tool. Takes precedence over a server-advertised + * `_meta['openrouter/loopKey']` declaration. Function forms are + * client-side only (they cannot be cached or transported); prefer field + * lists where possible. + */ + loopKeys?: Readonly>>>; + /** Auto-refresh tools on `tools/list_changed`. Default true when connected. */ + autoRefreshOnListChanged?: boolean; + /** Handler for server-initiated elicitation; auto-declines when omitted. */ + onElicitation?: ElicitationHandler; + /** Abort signal threaded into every underlying `callTool`. */ + signal?: AbortSignal; +} + +/** + * Handle returned by {@link createMCPTools}/`rehydrateMCPTools`. Holds a live + * connection (unless rehydrated offline) and the wrapped tools. + */ +export interface MCPToolsHandle { + /** Tools ready to pass into `callModel({ tools })`. */ + readonly tools: readonly Tool[]; + readonly serverInfo?: { + name?: string; + version?: string; + }; + /** Snapshot for persistence; omits credentials unless `cacheCredentials`. */ + serialize(): Promise; + /** Force a fresh `listTools()` and rebuild the tool set. */ + refresh(): Promise; + /** Subscribe to auto-refreshes triggered by `tools/list_changed`. */ + onToolsChanged(listener: (tools: readonly Tool[]) => void): () => void; + /** Close the transport and underlying client. */ + close(): Promise; +} diff --git a/packages/agent/src/mcp/version.ts b/packages/agent/src/mcp/version.ts new file mode 100644 index 00000000..b8fe6bd7 --- /dev/null +++ b/packages/agent/src/mcp/version.ts @@ -0,0 +1,5 @@ +// DO NOT EDIT — generated from package.json by scripts/gen-version.mjs. +// Run `pnpm --filter @openrouter/mcp gen:version` after bumping the version. + +/** This package's version, self-reported to MCP servers as `clientInfo`. */ +export const PACKAGE_VERSION = '0.0.1'; diff --git a/packages/mcp/tests/e2e/mcp-tools.e2e.test.ts b/packages/agent/tests/e2e/mcp/mcp-tools.e2e.test.ts similarity index 94% rename from packages/mcp/tests/e2e/mcp-tools.e2e.test.ts rename to packages/agent/tests/e2e/mcp/mcp-tools.e2e.test.ts index b3ffe06a..e665af5b 100644 --- a/packages/mcp/tests/e2e/mcp-tools.e2e.test.ts +++ b/packages/agent/tests/e2e/mcp/mcp-tools.e2e.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { createMCPTools, InMemoryMCPCacheStore, rehydrateMCPTools } from '../../src/index.js'; +import { + createMCPTools, + InMemoryMCPCacheStore, + rehydrateMCPTools, +} from '../../../src/mcp/index.js'; // These tests require a reachable remote MCP server. Set MCP_TEST_URL (and // optionally MCP_TEST_TOKEN) to run them; otherwise they are skipped. diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts index 6d173a55..3ad38568 100644 --- a/packages/agent/tests/unit/call-model-active-tools.test.ts +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -83,7 +83,7 @@ async function captureOutboundTools(options: { /** * Run `callModel` with an arbitrary request object (deliberately typed as * `unknown` so tests can pass shapes that don't type-check, such as a whole - * `@openrouter/agent-tool-set` snapshot spread in) and capture the raw JSON + * `@openrouter/agent/tool-set` snapshot spread in) and capture the raw JSON * body sent to the HTTP client, short-circuiting the actual network call. */ async function captureOutboundRequest(request: unknown): Promise<{ @@ -255,7 +255,7 @@ describe('callModel activeTools filter', () => { }); }); -describe('callModel strips @openrouter/agent-tool-set snapshot metadata', () => { +describe('callModel strips @openrouter/agent/tool-set snapshot metadata', () => { const toolA = tool({ name: 'a', inputSchema: z.object({}), diff --git a/packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts b/packages/agent/tests/unit/filter-tools-by-ids.test-d.ts similarity index 89% rename from packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts rename to packages/agent/tests/unit/filter-tools-by-ids.test-d.ts index b925461f..b91e6d86 100644 --- a/packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts +++ b/packages/agent/tests/unit/filter-tools-by-ids.test-d.ts @@ -4,11 +4,11 @@ * (non-tuple) `readonly Tool[]`. */ -import type { Tool } from '@openrouter/agent'; -import { tool } from '@openrouter/agent'; import { expectTypeOf } from 'vitest'; import { z } from 'zod/v4'; -import type { FilterToolsByIds } from '../../src/index.js'; +import { tool } from '../../src/lib/tool.js'; +import type { FilterToolsByIds } from '../../src/lib/tool-set-types.js'; +import type { Tool } from '../../src/lib/tool-types.js'; const a = tool({ name: 'a', @@ -67,7 +67,7 @@ expectTypeOf().toEqualTypeOf< // --- Dynamic `readonly Tool[]` must not collapse to `readonly []` ---------- // // A tool handle whose concrete tuple isn't known at the type level (e.g. an -// `@openrouter/mcp` tool array typed as `readonly Tool[]`) must still filter +// `@openrouter/agent/mcp` tool array typed as `readonly Tool[]`) must still filter // to a usable, non-empty array shape instead of always bottoming out at the // tuple recursion's `readonly []` base case. `number extends T['length']` // detects this dynamic-array case (true for general arrays, false for diff --git a/packages/mcp/tests/unit/build-tools.test.ts b/packages/agent/tests/unit/mcp/build-tools.test.ts similarity index 93% rename from packages/mcp/tests/unit/build-tools.test.ts rename to packages/agent/tests/unit/mcp/build-tools.test.ts index a8a5e16a..321204e9 100644 --- a/packages/mcp/tests/unit/build-tools.test.ts +++ b/packages/agent/tests/unit/mcp/build-tools.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { buildTools, filterToolDefs } from '../../src/build-tools.js'; -import { MCPError } from '../../src/errors.js'; -import type { McpToolDef } from '../../src/tool-wrapper.js'; +import { buildTools, filterToolDefs } from '../../../src/mcp/build-tools.js'; +import { MCPError } from '../../../src/mcp/errors.js'; +import type { McpToolDef } from '../../../src/mcp/tool-wrapper.js'; // A minimal stand-in for the MCP Client; buildTools only stores the reference // for the wrapped tools' execute closures, which these tests don't invoke. diff --git a/packages/agent/tests/unit/mcp/cache.test.ts b/packages/agent/tests/unit/mcp/cache.test.ts new file mode 100644 index 00000000..440ef59f --- /dev/null +++ b/packages/agent/tests/unit/mcp/cache.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryMCPCacheStore } from '../../../src/mcp/cache/cache-store.js'; +import { isSerializedMCPServer } from '../../../src/mcp/cache/cache-types.js'; +import { serializeServer } from '../../../src/mcp/cache/serialize.js'; +import type { McpToolDef } from '../../../src/mcp/tool-wrapper.js'; + +const toolDefs: McpToolDef[] = [ + { + name: 'search', + description: 'search docs', + inputSchema: { + type: 'object', + properties: { + q: { + type: 'string', + }, + }, + required: [ + 'q', + ], + }, + outputSchema: { + type: 'object', + properties: { + hits: { + type: 'number', + }, + }, + }, + }, +]; + +describe('serializeServer', () => { + it('produces a valid snapshot with structural data', async () => { + const snap = await serializeServer({ + url: 'https://mcp.example.com/mcp', + transport: 'streamableHttp', + toolDefs, + serverInfo: { + name: 'demo', + version: '1.0.0', + }, + cacheCredentials: false, + cachedAt: 1_000, + }); + expect(isSerializedMCPServer(snap)).toBe(true); + expect(snap.tools).toHaveLength(1); + expect(snap.tools[0]?.outputSchema).toBeDefined(); + expect(snap.cachedAt).toBe(1_000); + }); + + it('replaces a negative cachedAt with a fresh timestamp', async () => { + const before = Date.now(); + const snap = await serializeServer({ + url: 'https://mcp.example.com/mcp', + transport: 'streamableHttp', + toolDefs, + cacheCredentials: false, + cachedAt: -1, + }); + // isFiniteEpoch rejects the negative input, so serializeServer falls back to + // Date.now() — assert it's the current time, not just any non-negative value, + // so a regression to a hard-coded sentinel would be caught. + expect(snap.cachedAt).toBeGreaterThanOrEqual(before); + expect(snap.cachedAt).toBeLessThanOrEqual(Date.now()); + expect(isSerializedMCPServer(snap)).toBe(true); + }); + + it('omits credentials when cacheCredentials is false', async () => { + const snap = await serializeServer({ + url: 'https://mcp.example.com/mcp', + transport: 'streamableHttp', + toolDefs, + auth: { + kind: 'bearer', + token: 'secret', + }, + sessionId: 'sess-1', + cacheCredentials: false, + cachedAt: 1_000, + }); + expect(snap.auth).toBeUndefined(); + expect(snap.sessionId).toBeUndefined(); + }); + + it('includes credentials + sessionId when cacheCredentials is true', async () => { + const snap = await serializeServer({ + url: 'https://mcp.example.com/mcp', + transport: 'streamableHttp', + toolDefs, + auth: { + kind: 'bearer', + token: 'secret', + }, + sessionId: 'sess-1', + cacheCredentials: true, + cachedAt: 1_000, + }); + expect(snap.auth?.headers).toEqual({ + Authorization: 'Bearer secret', + }); + expect(snap.sessionId).toBe('sess-1'); + }); +}); + +describe('InMemoryMCPCacheStore', () => { + it('round-trips a snapshot through get/set/delete', async () => { + const store = new InMemoryMCPCacheStore(); + const snap = await serializeServer({ + url: 'https://mcp.example.com/mcp', + transport: 'sse', + toolDefs, + cacheCredentials: false, + cachedAt: 2_000, + }); + expect(store.get('k')).toBeNull(); + store.set('k', snap); + expect(store.get('k')).toEqual(snap); + store.delete('k'); + expect(store.get('k')).toBeNull(); + }); +}); + +describe('isSerializedMCPServer', () => { + it('rejects malformed snapshots', () => { + expect(isSerializedMCPServer(null)).toBe(false); + expect( + isSerializedMCPServer({ + version: 2, + }), + ).toBe(false); + expect( + isSerializedMCPServer({ + version: 1, + url: 'x', + transport: 'bogus', + }), + ).toBe(false); + expect( + isSerializedMCPServer({ + version: 1, + url: 'https://x', + transport: 'sse', + tools: [ + { + name: 'a', + inputSchema: {}, + }, + ], + cachedAt: 1, + }), + ).toBe(true); + }); + + it('rejects snapshots with a non-finite or negative cachedAt', () => { + const base = { + version: 1, + url: 'https://x', + transport: 'sse', + tools: [ + { + name: 'a', + inputSchema: {}, + }, + ], + }; + for (const cachedAt of [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + -1, + ]) { + expect( + isSerializedMCPServer({ + ...base, + cachedAt, + }), + ).toBe(false); + } + }); +}); diff --git a/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts b/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts new file mode 100644 index 00000000..37a05ae4 --- /dev/null +++ b/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConnectOptions, MCPConnection } from '../../../src/mcp/mcp-connection.js'; + +// A controllable fake connection: tests set how `listTools` behaves and inspect +// whether `close()` was called and capture the registered list_changed handler. +interface FakeState { + listTools: () => Promise<{ + tools: { + name: string; + inputSchema: Record; + }[]; + nextCursor?: string; + }>; + closed: number; + listChangedHandler: (() => void) | undefined; +} + +const state: FakeState = { + listTools: () => + Promise.resolve({ + tools: [], + }), + closed: 0, + listChangedHandler: undefined, +}; + +vi.mock('../../../src/mcp/mcp-connection.js', () => ({ + connect: (_options: ConnectOptions): Promise => { + const connection: MCPConnection = { + client: { + getServerVersion: () => undefined, + getServerCapabilities: () => undefined, + listTools: () => state.listTools(), + } as never, + transport: 'streamableHttp', + setToolListChangedHandler: (handler: () => void) => { + state.listChangedHandler = handler; + }, + close: () => { + state.closed += 1; + return Promise.resolve(); + }, + }; + return Promise.resolve(connection); + }, +})); + +const { createMCPTools } = await import('../../../src/mcp/create-mcp-tools.js'); + +describe('createMCPTools setup teardown', () => { + beforeEach(() => { + state.closed = 0; + state.listChangedHandler = undefined; + state.listTools = () => + Promise.resolve({ + tools: [], + }); + }); + + it('closes the connection when tool discovery fails', async () => { + state.listTools = () => Promise.reject(new Error('listTools failed')); + await expect( + createMCPTools({ + url: 'https://mcp.example.com/mcp', + }), + ).rejects.toThrow('listTools failed'); + expect(state.closed).toBe(1); + }); + + it('does not let a failed list_changed refresh escape as an unhandled rejection', async () => { + let calls = 0; + state.listTools = () => { + calls += 1; + // Succeed on initial discovery, reject on the refresh triggered below. + if (calls === 1) { + return Promise.resolve({ + tools: [], + }); + } + return Promise.reject(new Error('refresh failed')); + }; + + const rejections: unknown[] = []; + const onRejection = (err: unknown): void => { + rejections.push(err); + }; + process.on('unhandledRejection', onRejection); + try { + await createMCPTools({ + url: 'https://mcp.example.com/mcp', + }); + expect(state.listChangedHandler).toBeDefined(); + state.listChangedHandler?.(); + // Let the rejected refresh microtask settle and any unhandled-rejection + // detection fire. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(rejections).toHaveLength(0); + } finally { + process.off('unhandledRejection', onRejection); + } + }); +}); diff --git a/packages/mcp/tests/unit/elicitation.test.ts b/packages/agent/tests/unit/mcp/elicitation.test.ts similarity index 96% rename from packages/mcp/tests/unit/elicitation.test.ts rename to packages/agent/tests/unit/mcp/elicitation.test.ts index a4fd5301..d0c560d7 100644 --- a/packages/mcp/tests/unit/elicitation.test.ts +++ b/packages/agent/tests/unit/mcp/elicitation.test.ts @@ -1,6 +1,6 @@ import type { ElicitRequest } from '@modelcontextprotocol/client'; import { describe, expect, it } from 'vitest'; -import { makeElicitationRequestHandler } from '../../src/elicitation.js'; +import { makeElicitationRequestHandler } from '../../../src/mcp/elicitation.js'; function formRequest(): ElicitRequest { return { diff --git a/packages/mcp/tests/unit/json-schema-to-zod.test.ts b/packages/agent/tests/unit/mcp/json-schema-to-zod.test.ts similarity index 96% rename from packages/mcp/tests/unit/json-schema-to-zod.test.ts rename to packages/agent/tests/unit/mcp/json-schema-to-zod.test.ts index cdcf3f64..636c7920 100644 --- a/packages/mcp/tests/unit/json-schema-to-zod.test.ts +++ b/packages/agent/tests/unit/mcp/json-schema-to-zod.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import * as z from 'zod'; -import { MCPError } from '../../src/errors.js'; -import { convertMcpInputSchema } from '../../src/schema/json-schema-to-zod.js'; +import { MCPError } from '../../../src/mcp/errors.js'; +import { convertMcpInputSchema } from '../../../src/mcp/schema/json-schema-to-zod.js'; describe('convertMcpInputSchema', () => { it('converts primitives, enums, and required fields faithfully', () => { diff --git a/packages/mcp/tests/unit/list-tools-pagination.test.ts b/packages/agent/tests/unit/mcp/list-tools-pagination.test.ts similarity index 92% rename from packages/mcp/tests/unit/list-tools-pagination.test.ts rename to packages/agent/tests/unit/mcp/list-tools-pagination.test.ts index 7b5ea8fc..308ba231 100644 --- a/packages/mcp/tests/unit/list-tools-pagination.test.ts +++ b/packages/agent/tests/unit/mcp/list-tools-pagination.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { ConnectOptions, MCPConnection } from '../../src/mcp-connection.js'; +import type { ConnectOptions, MCPConnection } from '../../../src/mcp/mcp-connection.js'; // Pages of tools the fake client serves, one cursor at a time. Captured so the // test can assert which cursors freshConnect requested. @@ -43,7 +43,7 @@ const toolPages: ToolPage[] = [ }, ]; -vi.mock('../../src/mcp-connection.js', () => ({ +vi.mock('../../../src/mcp/mcp-connection.js', () => ({ connect: (_options: ConnectOptions): Promise => { let idx = 0; const connection: MCPConnection = { @@ -67,7 +67,7 @@ vi.mock('../../src/mcp-connection.js', () => ({ }, })); -const { freshConnect } = await import('../../src/handle.js'); +const { freshConnect } = await import('../../../src/mcp/handle.js'); function nameOf(tool: unknown): string | undefined { if ( diff --git a/packages/mcp/tests/unit/loop-key.test.ts b/packages/agent/tests/unit/mcp/loop-key.test.ts similarity index 94% rename from packages/mcp/tests/unit/loop-key.test.ts rename to packages/agent/tests/unit/mcp/loop-key.test.ts index 6fd0f224..a79da87b 100644 --- a/packages/mcp/tests/unit/loop-key.test.ts +++ b/packages/agent/tests/unit/mcp/loop-key.test.ts @@ -12,13 +12,13 @@ */ import type { Client } from '@modelcontextprotocol/client'; import { describe, expect, it } from 'vitest'; -import { buildTools } from '../../src/build-tools.js'; -import { isSerializedMCPServer } from '../../src/cache/cache-types.js'; -import { serializeServer } from '../../src/cache/serialize.js'; -import { listToolDefs } from '../../src/handle.js'; -import type { MCPConnection } from '../../src/mcp-connection.js'; -import type { McpToolDef } from '../../src/tool-wrapper.js'; -import { wrapMcpTool } from '../../src/tool-wrapper.js'; +import { buildTools } from '../../../src/mcp/build-tools.js'; +import { isSerializedMCPServer } from '../../../src/mcp/cache/cache-types.js'; +import { serializeServer } from '../../../src/mcp/cache/serialize.js'; +import { listToolDefs } from '../../../src/mcp/handle.js'; +import type { MCPConnection } from '../../../src/mcp/mcp-connection.js'; +import type { McpToolDef } from '../../../src/mcp/tool-wrapper.js'; +import { wrapMcpTool } from '../../../src/mcp/tool-wrapper.js'; function fakeClient(): Client { return {} as never; diff --git a/packages/mcp/tests/unit/rehydrate.test.ts b/packages/agent/tests/unit/mcp/rehydrate.test.ts similarity index 100% rename from packages/mcp/tests/unit/rehydrate.test.ts rename to packages/agent/tests/unit/mcp/rehydrate.test.ts diff --git a/packages/mcp/tests/unit/resource-tools.test.ts b/packages/agent/tests/unit/mcp/resource-tools.test.ts similarity index 99% rename from packages/mcp/tests/unit/resource-tools.test.ts rename to packages/agent/tests/unit/mcp/resource-tools.test.ts index 93751714..37172389 100644 --- a/packages/mcp/tests/unit/resource-tools.test.ts +++ b/packages/agent/tests/unit/mcp/resource-tools.test.ts @@ -1,6 +1,6 @@ import type { Client } from '@modelcontextprotocol/client'; import { describe, expect, it } from 'vitest'; -import { buildResourceTools } from '../../src/resource-tools.js'; +import { buildResourceTools } from '../../../src/mcp/resource-tools.js'; // Minimal page shapes returned by the fake client's list endpoints. interface ResourcePage { diff --git a/packages/mcp/tests/unit/result-mapper.test.ts b/packages/agent/tests/unit/mcp/result-mapper.test.ts similarity index 92% rename from packages/mcp/tests/unit/result-mapper.test.ts rename to packages/agent/tests/unit/mcp/result-mapper.test.ts index e1acf601..e71bd72d 100644 --- a/packages/mcp/tests/unit/result-mapper.test.ts +++ b/packages/agent/tests/unit/mcp/result-mapper.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MCPToolCallError } from '../../src/errors.js'; -import { mapCallToolResult } from '../../src/result-mapper.js'; +import { MCPToolCallError } from '../../../src/mcp/errors.js'; +import { mapCallToolResult } from '../../../src/mcp/result-mapper.js'; describe('mapCallToolResult', () => { it('prefers structuredContent when present', () => { diff --git a/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts b/packages/agent/tests/unit/resolved-tools.test-d.ts similarity index 94% rename from packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts rename to packages/agent/tests/unit/resolved-tools.test-d.ts index b5e7ed18..edb4cceb 100644 --- a/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts +++ b/packages/agent/tests/unit/resolved-tools.test-d.ts @@ -1,12 +1,12 @@ -import { tool } from '@openrouter/agent'; +import { tool } from '../../src/lib/tool.js'; import { expectTypeOf } from 'vitest'; import { z } from 'zod/v4'; import type { ConditionalPartition, InitialPartition, ResolvedToolSnapshot, -} from '../../src/index.js'; -import { createToolSet } from '../../src/index.js'; +} from '../../src/lib/tool-set-types.js'; +import { createToolSet } from '../../src/lib/tool-set.js'; const a = tool({ name: 'a', diff --git a/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts b/packages/agent/tests/unit/server-tool-id.test-d.ts similarity index 100% rename from packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts rename to packages/agent/tests/unit/server-tool-id.test-d.ts diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index eb6db3ff..47c0aad5 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -292,7 +292,7 @@ void boomErrorEvent; // --- Generic `readonly Tool[]` must not collapse to `never` ----------------- // // A tool handle whose concrete tuple isn't known at the type level (e.g. an -// `@openrouter/mcp` tool array typed as `readonly Tool[]`) must still produce +// `@openrouter/agent/mcp` tool array typed as `readonly Tool[]`) must still produce // a usable, backward-compatible event shape instead of `never`. The mapped // check `T[K] extends ClientTool` doesn't distribute over the indexed access // `T[K]` when `T` is the wide `readonly Tool[]`, so these types fall back to diff --git a/packages/agent-tool-set/tests/unit/tool-set.test.ts b/packages/agent/tests/unit/tool-set.test.ts similarity index 99% rename from packages/agent-tool-set/tests/unit/tool-set.test.ts rename to packages/agent/tests/unit/tool-set.test.ts index d13e012f..e5e42c14 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent/tests/unit/tool-set.test.ts @@ -1,22 +1,22 @@ -import type { - ConversationState, - CorrelatedToolEventUnion, - ServerToolBase, -} from '@openrouter/agent'; -import { serverTool, tool } from '@openrouter/agent'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { z } from 'zod/v4'; +import { serverTool, tool } from '../../src/lib/tool.js'; +import type { ToolSet } from '../../src/lib/tool-set.js'; +import { createToolSet } from '../../src/lib/tool-set.js'; import type { InferAllIds, InferConditionalIds, InferDisabledIds, InferEnabledIds, InferToolSet, - ToolSet, WidenedPartition, WidenedSituationMap, -} from '../../src/index.js'; -import { createToolSet } from '../../src/index.js'; +} from '../../src/lib/tool-set-types.js'; +import type { + ConversationState, + CorrelatedToolEventUnion, + ServerToolBase, +} from '../../src/lib/tool-types.js'; const makeTool = (name: string) => tool({ diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 51bb3edc..344c0b7c 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "esm" + "outDir": "esm", + // Resolve deps via published exports, not the repo-wide "source" condition: + // the optional peer "@modelcontextprotocol/sdk" (used by src/mcp) transitively + // exposes a "source" condition pointing at raw .ts files (eventsource). + "customConditions": [] }, "include": ["src"], "exclude": ["node_modules", "esm"] diff --git a/packages/agent/tsconfig.typecheck.json b/packages/agent/tsconfig.typecheck.json index a40cbeda..82ccc761 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -4,6 +4,7 @@ "include": [ "src/**/*.ts", "tests/unit/context-schema-inference.test-d.ts", + "tests/unit/resolved-tools.test-d.ts", "tests/unit/tool-shared-name.test-d.ts" ], "exclude": ["node_modules", "esm"] diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 7c826875..5ecbe2da 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,5 +1,10 @@ # @openrouter/mcp +> [!NOTE] +> This package is a compatibility facade. New code should import the canonical +> `@openrouter/agent/mcp` subpath. Existing `@openrouter/mcp` root and subpath +> imports continue to work. + Expose the tools of a remote [Model Context Protocol](https://modelcontextprotocol.io) server (Streamable HTTP or SSE) as tools you can pass straight into [`@openrouter/agent`](https://www.npmjs.com/package/@openrouter/agent)'s `callModel`. @@ -9,25 +14,33 @@ Expose the tools of a remote [Model Context Protocol](https://modelcontextprotoc - Faithful JSON Schema → Zod conversion so the model sees real parameters. - Serializable, rehydratable cache so you can skip re-listing (and, opt-in, re-authenticating). - Progress streaming, `tools/list_changed` auto-refresh, cancellation, resources, and elicitation. -- Speaks both MCP protocol revisions (`2025-11-25` and `2026-07-28`), negotiated per server. > stdio servers are intentionally out of scope. ## Install -Requires **Node 20+** (inherited from `@modelcontextprotocol/client@2`, which declares -`engines.node: >=20`; this package declares the same). +For new code, install the agent plus the optional MCP peer: + +```bash +pnpm add @openrouter/agent @modelcontextprotocol/sdk +``` + +Existing applications may continue installing the compatibility package: ```bash -pnpm add @openrouter/mcp @openrouter/agent +pnpm add @openrouter/mcp @openrouter/agent @modelcontextprotocol/sdk ``` +The agent package is marked `sideEffects: false`, and MCP code is exposed only +through explicit `/mcp` exports. Root and `/tool-set` imports do not statically +load MCP modules; the MCP SDK is not installed transitively for base agent users. + ## Quick start ```ts import { OpenRouter } from '@openrouter/agent'; import { callModel } from '@openrouter/agent/call-model'; -import { createMCPTools } from '@openrouter/mcp'; +import { createMCPTools } from '@openrouter/agent/mcp'; const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); @@ -59,15 +72,15 @@ auth: { kind: 'headers', headers: { 'X-API-Key': key } } auth: { kind: 'oauth', provider } ``` -Prefer an OAuth provider over caching static tokens — the transport refreshes through it -automatically. Type yours with `MCPOAuthClientProvider`, re-exported from this package. +Prefer an `OAuthClientProvider` over caching static tokens — the transport refreshes through it +automatically. ## Caching & rehydration Persist a snapshot and rebuild later without a `listTools()` round-trip: ```ts -import { createMCPTools, rehydrateMCPTools } from '@openrouter/mcp'; +import { createMCPTools, rehydrateMCPTools } from '@openrouter/agent/mcp'; const mcp = await createMCPTools({ url, auth, cacheCredentials: true }); const snapshot = await mcp.serialize(); // plain JSON — store anywhere @@ -79,7 +92,7 @@ const mcp2 = await rehydrateMCPTools({ snapshot, auth }); Or let a store manage it (rehydrate on hit, connect + write on miss): ```ts -import { InMemoryMCPCacheStore } from '@openrouter/mcp'; +import { InMemoryMCPCacheStore } from '@openrouter/agent/mcp'; const store = new InMemoryMCPCacheStore(); // or your own Redis/DB-backed MCPCacheStore const mcp = await createMCPTools({ @@ -90,44 +103,9 @@ const mcp = await createMCPTools({ }); ``` -`staleness.maxAgeMs` is honoured by `rehydrateMCPTools()` as well as by `createMCPTools()`'s -cache-hit path, and on every path — including `reconnectOnExpiry: false`, which opts out of -rebuilding the transport, not out of bounded-age tools. An over-age snapshot re-lists over -the replayed connection. If that re-list fails, the call rejects with `MCPStaleSnapshotError` -rather than quietly serving tools you declared too old; catch it to opt back in: - -```ts -import { rehydrateMCPTools, MCPStaleSnapshotError } from '@openrouter/mcp'; - -try { - return await rehydrateMCPTools({ - snapshot, - staleness: { maxAgeMs: 60_000 }, - reconnectOnExpiry: false, - }); -} catch (err) { - if (err instanceof MCPStaleSnapshotError) { - // Connection was fine, only the re-list failed — take the cached tool set. - return await rehydrateMCPTools({ snapshot, reconnectOnExpiry: false }); - } - throw err; -} -``` - -It subclasses `MCPCacheError`, so existing `catch (e instanceof MCPCacheError)` sites keep -working. Note that a successful replay does not write to the store — the snapshot it would -write is the one just read. Seeding a store from a snapshot obtained elsewhere means writing -it yourself or calling `handle.refresh()` after rehydrating. Writing a snapshot back to your store is best-effort: a store outage leaves you with a -working handle and a stale cache entry rather than a failed call. Catch `MCPCacheWriteError` -from `handle.refresh()` if you would rather treat that as fatal. `handle.refresh()` also always reaches the server: SDK v2 caches `tools/list` per -client up to the server's `ttlMs`, and every internal list read bypasses that so a refresh -cannot hand back the previous tool set. - > **Security:** `cacheCredentials` is `false` by default. When enabled, snapshots contain bearer > tokens/headers — treat the store as a secret store and namespace cache keys by principal in -> multi-tenant setups. Session ids are never persisted: an `Mcp-Session-Id` is -> bearer-equivalent to an authenticated server session, and nothing reads it back, so it would -> be attack surface for no functionality. A `sessionId` found in an old snapshot is ignored. +> multi-tenant setups. ## Multiple servers @@ -149,9 +127,7 @@ const result = callModel(client, { | Option | Description | | --- | --- | | `url` | Remote MCP server endpoint. | -| `transport` | `'streamableHttp'` (default, falls back to SSE) or `'sse'` (deprecated upstream). | -| `protocolNegotiation` | `'auto'` (default), `'legacy'`, or `{ pin }`. See Protocol revisions. | -| `probeTimeoutMs` | Ceiling on the `server/discover` probe (default 30000). | +| `transport` | `'streamableHttp'` (default, falls back to SSE) or `'sse'`. | | `auth` | Bearer token, headers, or an `OAuthClientProvider`. | | `toolNamePrefix` | Prefix every wrapped tool name. | | `includeTools` / `excludeTools` | Allow/deny lists by MCP tool name. | @@ -160,117 +136,8 @@ const result = callModel(client, { | `resources` | Expose synthetic `list_resources` / `read_resource` tools (default on). | | `emitProgress` | Stream MCP progress as generator-tool events (default on). | | `autoRefreshOnListChanged` | Re-list on `tools/list_changed` (default on). | -| `onElicitation` | Handle elicitation requests (both revisions); auto-declines when omitted. | -| `signal` | Aborts every tool call and the connection itself (connect, probe, legacy retry, reconnects). | - -## Client identity - -The client identifies itself to every server it connects to via MCP `clientInfo` -(`{ name, version }`). Pass `clientInfo` in options to override it; otherwise the default -is `@openrouter/mcp` at this package's version. - -That version is **generated** into `src/version.ts` from `package.json`, which is the -single source of truth. `build` regenerates it, so a changesets version bump is picked up -automatically before publish. To regenerate by hand: - -```bash -pnpm --filter @openrouter/mcp gen:version -``` - -The generated file is committed rather than gitignored, because CI's lint, typecheck, and -unit-test jobs compile `src` without running a build. `tests/unit/version.test.ts` fails -if the committed constant drifts from `package.json`, so a stale value cannot merge. - -## Protocol revisions - -Both current MCP revisions are supported, and the right one is chosen for you. Point this -at any server and it works: - -```ts -const mcp = await createMCPTools({ url: 'https://mcp.example.com/mcp' }); -``` - -By default (`protocolNegotiation: 'auto'`) the client probes with `server/discover` and -then speaks whichever revision the server offers: - -| Server | What goes on the wire | -| --- | --- | -| **`2026-07-28`** | `server/discover`, then requests carrying the per-request `_meta` envelope and `Mcp-Method` / `Mcp-Name` headers. No `initialize` — the handshake is removed in this revision (SEP-2575). | -| **`2025-11-25`** and earlier | `server/discover`, then a fallback to the classic `initialize` + `notifications/initialized` handshake, byte-equivalent to a 2025-only client. | - -A probe is a new request, and some infrastructure dislikes new requests — a proxy, WAF, or -strict gateway may hang or 5xx on an unknown method. **You don't need to configure anything -for that case.** When you have not set `protocolNegotiation`, a failed connect is retried -once with `'legacy'`, so such a server connects exactly as it did before this package -probed at all. That covers the pinned `transport: 'sse'` path and the Streamable HTTP → SSE -fallback too, since all three share one client factory. - -Override when you need to: - -```ts -// Skip the probe. A performance choice, not a compatibility one: saves the extra -// round trip when you already know the server is 2025-era. -await createMCPTools({ url, protocolNegotiation: 'legacy' }); - -// Require a specific revision; fail loudly rather than falling back. -await createMCPTools({ url, protocolNegotiation: { pin: '2026-07-28' } }); -``` - -> Setting `protocolNegotiation` at all — **including to `'auto'`** — opts out of the -> automatic legacy retry. Naming a mode means you want that mode's failures too, and -> silently overriding a `{ pin }` would defeat the point of pinning. - -The probe is bounded at 30s; pass `probeTimeoutMs` to change it. Without a bound the SDK gives -the probe the full 60s request timeout, so a black-holing gateway takes minutes to fail. The -ceiling is not tighter because a probe timeout is *not* recoverable — on HTTP it counts as an -outage, and the legacy retry speaks a handshake that `2026-07-28` removed — so a modern-only -server slower than the ceiling would fail outright rather than just take longer. Lower it when -you control the server and want to fail fast; raise it for known-slow cold starts. - -`'auto'` costs one extra round trip against legacy servers. When a connect fails, the retry -re-walks the same transport ladder under `'legacy'`, so an unreachable server is dialled up to -four times before erroring — the price of guaranteeing that a legacy server reachable only -over SSE still connects when its probe is refused. - -An auth failure skips the retry: the SDK's `UnauthorizedError`, or — when an OAuth provider is -configured — a **401** status from the probe (which the SDK reports as an `SdkHttpError` -rather than routing through the OAuth flow), from any attempt, not only the last. A **403** -never skips it, even under OAuth: the SDK's PKCE side effects occur only on 401, so a 403 -retry re-drives nothing, while gateways commonly answer unknown methods with 403 — the exact -case the retry exists to rescue. Rejected credentials are not something a different protocol -revision fixes, and retrying a 401 would drive an OAuth authorization flow twice and overwrite -the saved PKCE verifier. - -`MCPConnectionError` exposes every underlying failure on `errors` (like `AggregateError`), flat -and in attempt order across both negotiation passes, so nothing is hidden behind `cause` — -which holds only the last attempt. - -There are no hardcoded protocol version strings in this package — negotiation is delegated to -`@modelcontextprotocol/client`. - -### What differs between the revisions - -Mostly nothing you need to care about, with three exceptions: - -| Surface | Behavior | -| --- | --- | -| `onElicitation` | **Works on both.** On 2025-era servers it handles `elicitation/create`; on 2026-07-28 that request is gone, but the SDK's multi-round-trip driver (SEP-2322) routes `input_required` results through the same handler and retries the call. | -| `sessionId` | 2025-era only. Protocol sessions and `Mcp-Session-Id` are removed in 2026-07-28 (SEP-2567), so it is `undefined` there. Snapshots keep the field so older ones still deserialize. | -| `transport: 'sse'` | Still supported for legacy servers, but HTTP+SSE is reclassified Deprecated (SEP-2596). Prefer `streamableHttp`. | - -Sampling and Roots are deprecated in the new revision and were never implemented here, so -there is nothing to migrate. - -The SDK also keeps its own per-client response cache (24h ceiling), independent of the -`MCPCacheStore` described above. The two are unrelated: `MCPCacheStore` persists a tool -snapshot across processes and, opt-in, credentials. - -### OAuth provider types - -If you pass `{ kind: 'oauth', provider }`, type your provider with -`MCPOAuthClientProvider` from this package rather than importing from -`@modelcontextprotocol/client` — that import path is an implementation detail and has -changed once already. +| `onElicitation` | Handle server elicitation requests; auto-declines when omitted. | +| `signal` | Abort signal threaded into every tool call. | ## License diff --git a/packages/mcp/src/cache.ts b/packages/mcp/src/cache.ts new file mode 100644 index 00000000..d9246147 --- /dev/null +++ b/packages/mcp/src/cache.ts @@ -0,0 +1,4 @@ +// Thin compatibility wrapper: re-exports the "cache" subpath from +// @openrouter/agent/mcp so `@openrouter/mcp/cache` keeps working. +export type { MCPCacheStore } from '@openrouter/agent/mcp/cache'; +export { defaultCacheKey, InMemoryMCPCacheStore } from '@openrouter/agent/mcp/cache'; diff --git a/packages/mcp/src/create-mcp-tools.ts b/packages/mcp/src/create-mcp-tools.ts index 114a1a55..b735ee26 100644 --- a/packages/mcp/src/create-mcp-tools.ts +++ b/packages/mcp/src/create-mcp-tools.ts @@ -1,112 +1,5 @@ -import type { MCPCacheStore } from './cache/cache-store.js'; -import { defaultCacheKey } from './cache/cache-store.js'; -import type { SerializedMCPServer } from './cache/cache-types.js'; -import { isSerializedMCPServer } from './cache/cache-types.js'; -import { freshConnect, normalizeUrl } from './handle.js'; -import type { RehydrateMCPToolsOptions } from './rehydrate.js'; -import { rehydrateMCPTools } from './rehydrate.js'; -import type { CreateMCPToolsOptions, MCPToolsHandle } from './types.js'; +// Thin compatibility wrapper: re-exports the "create-mcp-tools" subpath from +// @openrouter/agent/mcp so `@openrouter/mcp/create-mcp-tools` keeps working. -/** - * Connect to a remote MCP server, discover its tools, and return a handle whose - * `.tools` can be passed straight into `callModel({ tools })`. Auth is supplied - * once and reused for discovery and every subsequent tool call. - * - * When `cache` is provided, a valid non-stale snapshot is rehydrated instead of - * re-listing; otherwise the fresh result is written back to the cache. - */ -export async function createMCPTools(options: CreateMCPToolsOptions): Promise { - const url = normalizeUrl(options.url); - const cacheKey = options.cache?.key ?? defaultCacheKey(url.href); - - if (options.cache !== undefined) { - const hit = await tryCacheHit(options, options.cache.store, cacheKey); - if (hit !== undefined) { - return hit; - } - } - - return freshConnect(options, url, cacheKey); -} - -// Option keys forwarded verbatim from a cache-hit `createMCPTools` call into -// `rehydrateMCPTools`, so a warm handle applies the same auth, filters, prefix, -// loop identities, and credential-caching behavior as a cold one. Anything -// omitted here is SILENTLY DROPPED on a cache hit — when adding an option to -// `CreateMCPToolsOptions` that rehydrate also honors, add it here too. -const FORWARDED_REHYDRATE_KEYS = [ - 'auth', - 'fetch', - 'clientInfo', - 'onUnconvertibleSchema', - 'onElicitation', - 'signal', - 'toolNamePrefix', - 'includeTools', - 'excludeTools', - 'resources', - 'emitProgress', - 'loopKeys', - 'autoRefreshOnListChanged', - 'cacheCredentials', - 'protocolNegotiation', - 'probeTimeoutMs', - 'staleness', -] as const satisfies readonly (keyof CreateMCPToolsOptions & keyof RehydrateMCPToolsOptions)[]; - -/** Copy the defined forwarded options from `createMCPTools` into a rehydrate base. */ -function forwardedRehydrateOptions( - options: CreateMCPToolsOptions, -): Partial { - const out: Partial = {}; - for (const key of FORWARDED_REHYDRATE_KEYS) { - const value = options[key]; - if (value !== undefined) { - Object.assign(out, { - [key]: value, - }); - } - } - return out; -} - -async function tryCacheHit( - options: CreateMCPToolsOptions, - store: MCPCacheStore, - cacheKey: string, -): Promise { - // A failed read is a miss, not a failure. The cache exists to skip a - // listTools() round trip; a store blip on the lookup must not reject a call - // that a plain miss would have served via a fresh connect. This mirrors the - // write side, which is best-effort everywhere — without it, "a store outage - // leaves you with a working handle" only held if the outage arrived after - // the read. Wrapped in try/catch rather than `.catch()`: `MCPCacheStore.get` - // may return synchronously, and a synchronous throw would bypass a - // promise-level handler — the exact hole `closeQuietly` exists to plug on - // the teardown side. - let snapshot: Awaited> | undefined; - try { - snapshot = await store.get(cacheKey); - } catch { - snapshot = undefined; - } - if (snapshot === null || snapshot === undefined || !isSerializedMCPServer(snapshot)) { - return undefined; - } - const maxAge = options.staleness?.maxAgeMs; - if (maxAge !== undefined && Date.now() - snapshot.cachedAt > maxAge) { - return undefined; - } - // Defer to rehydrate, which reconnects and falls back to a fresh connect on - // expiry. - return rehydrateMCPTools({ - snapshot, - ...forwardedRehydrateOptions(options), - cache: { - store, - key: cacheKey, - }, - }); -} - -export type { SerializedMCPServer }; +export type { SerializedMCPServer } from '@openrouter/agent/mcp/create-mcp-tools'; +export { createMCPTools } from '@openrouter/agent/mcp/create-mcp-tools'; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e2a13373..858833e4 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,39 +1,30 @@ -// Main factory + rehydration - -// Auth -export type { MCPAuth, MCPOAuthClientProvider } from './auth/auth-types.js'; -export type { MCPCacheStore } from './cache/cache-store.js'; -// Cache -export { defaultCacheKey, InMemoryMCPCacheStore } from './cache/cache-store.js'; +// Thin compatibility wrapper: @openrouter/mcp re-exports the canonical +// implementation that now lives in @openrouter/agent/mcp. This file exists so +// existing consumers importing from "@openrouter/mcp" keep working unchanged. export type { + CreateMCPToolsOptions, + ElicitationHandler, + ElicitationResponse, + MCPAuth, + MCPCacheStore, + MCPToolsHandle, + MCPTransportKind, + RehydrateMCPToolsOptions, + ResourcesOption, SerializedMCPServer, SerializedMCPToolDef, SerializedTokenSet, -} from './cache/cache-types.js'; -export { isSerializedMCPServer } from './cache/cache-types.js'; -export { createMCPTools } from './create-mcp-tools.js'; -// Errors + UnconvertibleSchemaMode, +} from '@openrouter/agent/mcp'; export { + convertMcpInputSchema, + createMCPTools, + defaultCacheKey, + InMemoryMCPCacheStore, + isSerializedMCPServer, MCPCacheError, - MCPCacheWriteError, MCPConnectionError, MCPError, - MCPStaleSnapshotError, MCPToolCallError, -} from './errors.js'; -export type { RehydrateMCPToolsOptions } from './rehydrate.js'; -export { rehydrateMCPTools } from './rehydrate.js'; -export type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; -// Schema conversion (exported for testing/reuse) -export { convertMcpInputSchema } from './schema/json-schema-to-zod.js'; -// Public option/handle types -export type { - CreateMCPToolsOptions, - ElicitationHandler, - ElicitationResponse, - MCPProtocolNegotiation, - MCPProtocolRevision, - MCPToolsHandle, - MCPTransportKind, - ResourcesOption, -} from './types.js'; + rehydrateMCPTools, +} from '@openrouter/agent/mcp'; diff --git a/packages/mcp/src/schema.ts b/packages/mcp/src/schema.ts new file mode 100644 index 00000000..164b1dc2 --- /dev/null +++ b/packages/mcp/src/schema.ts @@ -0,0 +1,4 @@ +// Thin compatibility wrapper: re-exports the "schema" subpath from +// @openrouter/agent/mcp so `@openrouter/mcp/schema` keeps working. +export type { UnconvertibleSchemaMode } from '@openrouter/agent/mcp/schema'; +export { convertMcpInputSchema } from '@openrouter/agent/mcp/schema'; diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 6adfc30d..619c5bff 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -1,167 +1,10 @@ -import type { Tool, ToolLoopKey } from '@openrouter/agent/tool-types'; -import type { MCPAuth } from './auth/auth-types.js'; -import type { MCPCacheStore } from './cache/cache-store.js'; -import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; -import type { - MCPProtocolNegotiation, - MCPProtocolRevision, +// Thin compatibility wrapper: re-exports the "types" subpath from +// @openrouter/agent/mcp so `@openrouter/mcp/types` keeps working. +export type { + CreateMCPToolsOptions, + ElicitationHandler, + ElicitationResponse, + MCPToolsHandle, MCPTransportKind, -} from './transport-types.js'; - -export type { MCPProtocolNegotiation, MCPProtocolRevision, MCPTransportKind }; - -/** - * Response to a server-initiated elicitation request. `accept` must carry - * `content` matching the server's `requestedSchema`. - */ -export type ElicitationResponse = - | { - action: 'accept'; - content: Record; - } - | { - action: 'decline'; - } - | { - action: 'cancel'; - }; - -/** - * Handler for server-initiated `elicitation/create` requests during a tool - * call. If omitted from options, requests are auto-declined so a tool call - * needing input fails gracefully rather than hanging. - */ -export type ElicitationHandler = (request: { - message: string; - requestedSchema: Record; -}) => Promise | ElicitationResponse; - -/** How MCP resources are exposed to the model. */ -export type ResourcesOption = - | boolean - | { - mode?: 'synthetic-tools'; - }; - -export interface CreateMCPToolsOptions { - /** Remote MCP server endpoint. */ - url: string | URL; - /** Transport to use; defaults to `streamableHttp` with SSE fallback. */ - transport?: MCPTransportKind; - /** - * Protocol-revision negotiation policy. Defaults to `'auto'`, which probes - * the server and speaks either 2025-11-25 or 2026-07-28 as appropriate. - */ - protocolNegotiation?: MCPProtocolNegotiation; - /** - * Ceiling on the `server/discover` probe, in ms. Defaults to 30000 — - * `DEFAULT_PROBE_TIMEOUT_MS` in `mcp-connection.ts` is the source of truth. - * - * Raise it for a server slow to answer its first request. The SDK's own default - * is the full request timeout, which makes a hanging gateway far slower to fail - * than it needs to be. - */ - probeTimeoutMs?: number; - /** Authentication, supplied once and reused for discovery + every call. */ - auth?: MCPAuth; - /** Custom fetch implementation for all network requests. */ - fetch?: typeof fetch; - /** - * Client identity self-reported to the server while connecting — via the - * `initialize` handshake on revision 2025-11-25, or the per-request `_meta` - * envelope on 2026-07-28 (which has no `initialize`). Honoured on both. - */ - clientInfo?: { - name: string; - version: string; - }; - /** Prefix applied to every wrapped tool name (e.g. `"github_"`). */ - toolNamePrefix?: string; - /** - * Allow-list of MCP tool names to expose. Applies to discovered MCP tools - * only; synthetic `list_resources`/`read_resource` tools are controlled - * exclusively by `resources`. - */ - includeTools?: readonly string[]; - /** - * Deny-list of MCP tool names to skip. Applies to discovered MCP tools only; - * synthetic `list_resources`/`read_resource` tools are controlled - * exclusively by `resources`. - */ - excludeTools?: readonly string[]; - /** Behavior when a tool's JSON Schema can't be fully represented in Zod. */ - onUnconvertibleSchema?: UnconvertibleSchemaMode; - /** Cache store + key for automatic rehydrate-on-hit / write-on-miss. */ - cache?: { - store: MCPCacheStore; - key?: string; - }; - /** - * Persist resolved credentials (bearer/header values, or the OAuth provider's - * current tokens) into the snapshot. Off by default. Session ids are never - * serialized — protocol sessions are removed in revision 2026-07-28 - * (SEP-2567), and a rehydrate always performs a fresh handshake. - */ - cacheCredentials?: boolean; - /** Re-list tools when a cached snapshot is older than this. */ - staleness?: { - maxAgeMs?: number; - }; - /** Expose resources as synthetic `list_resources`/`read_resource` tools. */ - resources?: ResourcesOption; - /** Map MCP progress notifications to generator-tool events. Default true. */ - emitProgress?: boolean; - /** - * Doom-loop identities for wrapped tools (see the `doomLoop` option on - * `callModel`), keyed by the tool's UNPREFIXED MCP name. Any `ToolLoopKey` - * form: a function computing key material, a declarative field-name array - * (e.g. `{ run_command: ['command', 'cwd'] }`), or `false` to exempt a - * tool. Takes precedence over a server-advertised - * `_meta['openrouter/loopKey']` declaration. Function forms are - * client-side only (they cannot be cached or transported); prefer field - * lists where possible. - */ - loopKeys?: Readonly>>>; - /** Auto-refresh tools on `tools/list_changed`. Default true when connected. */ - autoRefreshOnListChanged?: boolean; - /** - * Handler for elicitation requests; auto-declines when omitted. - * - * Works against both protocol revisions. On 2025-11-25 the server sends - * `elicitation/create` directly. On 2026-07-28 that request no longer - * exists — the server answers with an `input_required` result instead - * (SEP-2322) — but the SDK's multi-round-trip driver dispatches it through - * this same handler and then retries the original call, so callers see - * identical behavior either way. - */ - onElicitation?: ElicitationHandler; - /** - * Abort signal threaded into every underlying `callTool` — and, new in this - * release, into the connection itself: connecting, the negotiation probe, the - * implicit legacy retry, and any reconnect all abort with it. An early - * cancellation can therefore surface as a connection failure, not only as a - * cancelled tool call. - */ - signal?: AbortSignal; -} - -/** - * Handle returned by {@link createMCPTools}/`rehydrateMCPTools`. Holds a live - * connection (unless rehydrated offline) and the wrapped tools. - */ -export interface MCPToolsHandle { - /** Tools ready to pass into `callModel({ tools })`. */ - readonly tools: readonly Tool[]; - readonly serverInfo?: { - name?: string; - version?: string; - }; - /** Snapshot for persistence; omits credentials unless `cacheCredentials`. */ - serialize(): Promise; - /** Force a fresh `listTools()` and rebuild the tool set. */ - refresh(): Promise; - /** Subscribe to auto-refreshes triggered by `tools/list_changed`. */ - onToolsChanged(listener: (tools: readonly Tool[]) => void): () => void; - /** Close the transport and underlying client. */ - close(): Promise; -} + ResourcesOption, +} from '@openrouter/agent/mcp/types'; diff --git a/packages/mcp/tests/unit/cache.test.ts b/packages/mcp/tests/unit/cache.test.ts index 3299adc8..e00043f9 100644 --- a/packages/mcp/tests/unit/cache.test.ts +++ b/packages/mcp/tests/unit/cache.test.ts @@ -1,204 +1,27 @@ +import * as agentCache from '@openrouter/agent/mcp/cache'; +import * as wrapperCache from '@openrouter/mcp/cache'; import { describe, expect, it } from 'vitest'; -import { InMemoryMCPCacheStore } from '../../src/cache/cache-store.js'; -import { isSerializedMCPServer } from '../../src/cache/cache-types.js'; -import { serializeServer } from '../../src/cache/serialize.js'; -import type { McpToolDef } from '../../src/tool-wrapper.js'; -const toolDefs: McpToolDef[] = [ - { - name: 'search', - description: 'search docs', - inputSchema: { - type: 'object', - properties: { - q: { - type: 'string', - }, - }, - required: [ - 'q', - ], - }, - outputSchema: { - type: 'object', - properties: { - hits: { - type: 'number', - }, - }, - }, - }, -]; - -describe('serializeServer', () => { - it('produces a valid snapshot with structural data', async () => { - const snap = await serializeServer({ - url: 'https://mcp.example.com/mcp', - transport: 'streamableHttp', - toolDefs, - serverInfo: { - name: 'demo', - version: '1.0.0', - }, - cacheCredentials: false, - cachedAt: 1_000, - }); - expect(isSerializedMCPServer(snap)).toBe(true); - expect(snap.tools).toHaveLength(1); - expect(snap.tools[0]?.outputSchema).toBeDefined(); - expect(snap.cachedAt).toBe(1_000); - }); - - it('replaces a negative cachedAt with a fresh timestamp', async () => { - const before = Date.now(); - const snap = await serializeServer({ - url: 'https://mcp.example.com/mcp', - transport: 'streamableHttp', - toolDefs, - cacheCredentials: false, - cachedAt: -1, - }); - // isFiniteEpoch rejects the negative input, so serializeServer falls back to - // Date.now() — assert it's the current time, not just any non-negative value, - // so a regression to a hard-coded sentinel would be caught. - expect(snap.cachedAt).toBeGreaterThanOrEqual(before); - expect(snap.cachedAt).toBeLessThanOrEqual(Date.now()); - expect(isSerializedMCPServer(snap)).toBe(true); - }); - - it('omits credentials when cacheCredentials is false', async () => { - const snap = await serializeServer({ - url: 'https://mcp.example.com/mcp', - transport: 'streamableHttp', - toolDefs, - auth: { - kind: 'bearer', - token: 'secret', - }, - cacheCredentials: false, - cachedAt: 1_000, - }); - expect(snap.auth).toBeUndefined(); - }); - - it('includes credentials when cacheCredentials is true', async () => { - const snap = await serializeServer({ - url: 'https://mcp.example.com/mcp', - transport: 'streamableHttp', - toolDefs, - auth: { - kind: 'bearer', - token: 'secret', - }, - cacheCredentials: true, - cachedAt: 1_000, - }); - expect(snap.auth?.headers).toEqual({ - Authorization: 'Bearer secret', - }); +describe('@openrouter/mcp/cache export parity', () => { + it('exports exactly the same runtime binding names as @openrouter/agent/mcp/cache', () => { + const agentKeys = Object.keys(agentCache).sort(); + const wrapperKeys = Object.keys(wrapperCache).sort(); + expect(wrapperKeys).toEqual(agentKeys); }); - /** - * A Streamable HTTP `Mcp-Session-Id` is bearer-equivalent to an authenticated - * server session. Nothing reads it back — the replay path stopped forwarding it - * once we found a transport reporting one makes SDK v2 skip negotiation and - * silently lose server capabilities — so writing it to an external store - * (Redis, a database, a file) is attack surface for no functionality. - * - * It is no longer accepted as `serializeServer` input at all, so this asserts - * the snapshot stays clean even under `cacheCredentials: true`, which is where - * it used to be written. - */ - it('never persists a session id, even with cacheCredentials on', async () => { - const snap = await serializeServer({ - url: 'https://mcp.example.com/mcp', - transport: 'streamableHttp', - toolDefs, - auth: { - kind: 'bearer', - token: 'secret', - }, - cacheCredentials: true, - cachedAt: 1_000, - }); - expect(snap.sessionId).toBeUndefined(); - expect(Object.hasOwn(snap, 'sessionId')).toBe(false); - }); -}); - -describe('InMemoryMCPCacheStore', () => { - it('round-trips a snapshot through get/set/delete', async () => { - const store = new InMemoryMCPCacheStore(); - const snap = await serializeServer({ - url: 'https://mcp.example.com/mcp', - transport: 'sse', - toolDefs, - cacheCredentials: false, - cachedAt: 2_000, - }); - expect(store.get('k')).toBeNull(); - store.set('k', snap); - expect(store.get('k')).toEqual(snap); - store.delete('k'); - expect(store.get('k')).toBeNull(); - }); -}); - -describe('isSerializedMCPServer', () => { - it('rejects malformed snapshots', () => { - expect(isSerializedMCPServer(null)).toBe(false); - expect( - isSerializedMCPServer({ - version: 2, - }), - ).toBe(false); - expect( - isSerializedMCPServer({ - version: 1, - url: 'x', - transport: 'bogus', - }), - ).toBe(false); - expect( - isSerializedMCPServer({ - version: 1, - url: 'https://x', - transport: 'sse', - tools: [ - { - name: 'a', - inputSchema: {}, - }, - ], - cachedAt: 1, - }), - ).toBe(true); + it('re-exports the exact same bindings by reference', () => { + for (const key of Object.keys(wrapperCache)) { + expect((wrapperCache as Record)[key]).toBe( + (agentCache as Record)[key], + ); + } }); - it('rejects snapshots with a non-finite or negative cachedAt', () => { - const base = { - version: 1, - url: 'https://x', - transport: 'sse', - tools: [ - { - name: 'a', - inputSchema: {}, - }, - ], - }; - for (const cachedAt of [ - Number.NaN, - Number.POSITIVE_INFINITY, - Number.NEGATIVE_INFINITY, - -1, - ]) { - expect( - isSerializedMCPServer({ - ...base, - cachedAt, - }), - ).toBe(false); - } + it('defaultCacheKey and InMemoryMCPCacheStore behave as expected', () => { + expect(wrapperCache.defaultCacheKey('https://example.com')).toBe( + 'openrouter-mcp:https://example.com', + ); + const store = new wrapperCache.InMemoryMCPCacheStore(); + expect(store.get('missing')).toBeNull(); }); }); diff --git a/packages/mcp/tests/unit/create-mcp-tools.test.ts b/packages/mcp/tests/unit/create-mcp-tools.test.ts index 046d2e04..e1771658 100644 --- a/packages/mcp/tests/unit/create-mcp-tools.test.ts +++ b/packages/mcp/tests/unit/create-mcp-tools.test.ts @@ -1,342 +1,23 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ConnectOptions, MCPConnection } from '../../src/mcp-connection.js'; - -// A controllable fake connection: tests set how `listTools` behaves and inspect -// whether `close()` was called and capture the registered list_changed handler. -interface FakeState { - listTools: () => Promise<{ - tools: { - name: string; - inputSchema: Record; - }[]; - nextCursor?: string; - }>; - closed: number; - listChangedHandler: (() => void) | undefined; -} - -const state: FakeState = { - listTools: () => - Promise.resolve({ - tools: [], - }), - closed: 0, - listChangedHandler: undefined, -}; - -vi.mock('../../src/mcp-connection.js', () => ({ - connect: (_options: ConnectOptions): Promise => { - const connection: MCPConnection = { - client: { - getServerVersion: () => undefined, - getServerCapabilities: () => undefined, - listTools: () => state.listTools(), - } as never, - transport: 'streamableHttp', - setToolListChangedHandler: (handler: () => void) => { - state.listChangedHandler = handler; - }, - close: () => { - state.closed += 1; - return Promise.resolve(); - }, - }; - return Promise.resolve(connection); - }, -})); - -const { createMCPTools } = await import('../../src/create-mcp-tools.js'); - -describe('createMCPTools setup teardown', () => { - beforeEach(() => { - state.closed = 0; - state.listChangedHandler = undefined; - state.listTools = () => - Promise.resolve({ - tools: [], - }); - }); - - it('closes the connection when tool discovery fails', async () => { - state.listTools = () => Promise.reject(new Error('listTools failed')); - await expect( - createMCPTools({ - url: 'https://mcp.example.com/mcp', - }), - ).rejects.toThrow('listTools failed'); - expect(state.closed).toBe(1); - }); - - it('does not let a failed list_changed refresh escape as an unhandled rejection', async () => { - let calls = 0; - state.listTools = () => { - calls += 1; - // Succeed on initial discovery, reject on the refresh triggered below. - if (calls === 1) { - return Promise.resolve({ - tools: [], - }); - } - return Promise.reject(new Error('refresh failed')); - }; - - const rejections: unknown[] = []; - const onRejection = (err: unknown): void => { - rejections.push(err); - }; - process.on('unhandledRejection', onRejection); - try { - await createMCPTools({ - url: 'https://mcp.example.com/mcp', - }); - expect(state.listChangedHandler).toBeDefined(); - state.listChangedHandler?.(); - // Let the rejected refresh microtask settle and any unhandled-rejection - // detection fire. - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(rejections).toHaveLength(0); - } finally { - process.off('unhandledRejection', onRejection); - } - }); - - /** - * A failed cache WRITE must not silence the list_changed announcement. - * - * `refresh()` adopts the new tools before it writes the snapshot back, so by - * the time a store outage surfaces as `MCPCacheWriteError`, `handle.tools` - * already returns the new set. Skipping notification there left subscribers - * permanently out of sync with the handle — and contradicted the best-effort - * write policy every other path follows. - */ - it('notifies subscribers of new tools even when the cache write fails', async () => { - let calls = 0; - state.listTools = () => { - calls += 1; - return Promise.resolve({ - tools: - calls === 1 - ? [] - : [ - { - name: 'brand_new', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - ], - }); - }; - - const failingStore = { - get: () => Promise.resolve(null), - set: () => Promise.reject(new Error('store unavailable')), - delete: () => Promise.resolve(), - }; - const handle = await createMCPTools({ - url: 'https://mcp.example.com/mcp', - cache: { - store: failingStore, - key: 'k', - }, - }); - - const seen: number[] = []; - handle.onToolsChanged((next) => { - seen.push(next.length); - }); - - state.listChangedHandler?.(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - // The write failed, the re-list did not: subscribers hear about the new set. - expect(seen).toEqual([ - 1, - ]); - expect(handle.tools).toHaveLength(1); - }); - - /** - * A failure while BUILDING the snapshot must not wear the cache-write tag. - * - * `snapshot()` awaits the caller's own OAuth `provider.tokens()`, so a - * provider rejection is a credential problem, not a store outage. Wrapping - * the build inside the same `try` as `store.set` relabelled it - * `MCPCacheWriteError` — the one class every path is documented to treat as - * harmless — so callers following that pattern silently swallowed genuine - * credential failures. `refresh()` must surface it under its own identity. - */ - it('does not relabel a snapshot-build failure as MCPCacheWriteError', async () => { - const { MCPCacheWriteError } = await import('../../src/errors.js'); - const providerFailure = new Error('provider token refresh failed'); - const sets: unknown[] = []; - const store = { - get: () => Promise.resolve(null), - set: (_key: string, value: unknown) => { - sets.push(value); - return Promise.resolve(); - }, - delete: () => Promise.resolve(), - }; - - const handle = await createMCPTools({ - url: 'https://mcp.example.com/mcp', - cacheCredentials: true, - cache: { - store, - key: 'k', - }, - auth: { - kind: 'oauth', - provider: { - // Fails on the refresh() below. The construction write is - // best-effort, so the first failure is swallowed by design; the - // explicit refresh must then report the provider's own error. - tokens: () => Promise.reject(providerFailure), - } as never, - }, - }); - - let caught: unknown; - try { - await handle.refresh(); - } catch (err) { - caught = err; +import * as agentCreateMcpTools from '@openrouter/agent/mcp/create-mcp-tools'; +import * as wrapperCreateMcpTools from '@openrouter/mcp/create-mcp-tools'; +import { describe, expect, it } from 'vitest'; + +describe('@openrouter/mcp/create-mcp-tools export parity', () => { + it('exports exactly the same runtime binding names as @openrouter/agent/mcp/create-mcp-tools', () => { + const agentKeys = Object.keys(agentCreateMcpTools).sort(); + const wrapperKeys = Object.keys(wrapperCreateMcpTools).sort(); + expect(wrapperKeys).toEqual(agentKeys); + }); + + it('re-exports the exact same bindings by reference', () => { + for (const key of Object.keys(wrapperCreateMcpTools)) { + expect((wrapperCreateMcpTools as Record)[key]).toBe( + (agentCreateMcpTools as Record)[key], + ); } - expect(caught).toBe(providerFailure); - expect(caught).not.toBeInstanceOf(MCPCacheWriteError); - // And nothing was written with a half-built payload. - expect(sets).toHaveLength(0); }); - /** - * Announcement is keyed on ADOPTION, not on error class. - * - * `refresh()` can fail after swapping `tools` for reasons other than the - * store op — `snapshot()` awaits the caller's OAuth `provider.tokens()`, - * which propagates untagged (deliberately: it is a credential failure, not a - * store outage). Subscribers must still hear about the adopted set, or they - * are permanently out of sync with `handle.tools`. - */ - it('notifies subscribers when a post-re-list snapshot build fails untagged', async () => { - let calls = 0; - state.listTools = () => { - calls += 1; - return Promise.resolve({ - tools: - calls === 1 - ? [] - : [ - { - name: 'brand_new', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - ], - }); - }; - - let tokenCalls = 0; - const handle = await createMCPTools({ - url: 'https://mcp.example.com/mcp', - cacheCredentials: true, - cache: { - store: { - get: () => Promise.resolve(null), - set: () => Promise.resolve(), - delete: () => Promise.resolve(), - }, - key: 'k', - }, - auth: { - kind: 'oauth', - provider: { - // Succeeds for the construction write, fails during the - // list_changed-triggered refresh — after the new tools were adopted. - tokens: () => { - tokenCalls += 1; - return tokenCalls === 1 - ? Promise.resolve({ - access_token: 't1', - token_type: 'bearer', - }) - : Promise.reject(new Error('provider outage')); - }, - } as never, - }, - }); - - const seen: number[] = []; - handle.onToolsChanged((next) => { - seen.push(next.length); - }); - - state.listChangedHandler?.(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - // The snapshot build failed untagged, but the re-list succeeded and the - // handle adopted the new set — subscribers hear about it. - expect(seen).toEqual([ - 1, - ]); - expect(handle.tools).toHaveLength(1); - }); - - /** - * A store outage on the READ is a miss, not a failure. - * - * The write side became best-effort everywhere in this PR; leaving the read - * fatal meant "a store outage leaves you with a working handle" only held if - * the outage arrived after the lookup. A failing `store.get` now falls through - * to a fresh connect, exactly as a plain miss would. - */ - it('treats a failing cache read as a miss and connects fresh', async () => { - const brokenStore = { - get: () => Promise.reject(new Error('store unavailable')), - set: () => Promise.resolve(), - delete: () => Promise.resolve(), - }; - - const handle = await createMCPTools({ - url: 'https://mcp.example.com/mcp', - cache: { - store: brokenStore, - key: 'k', - }, - }); - - // Fresh connect succeeded despite the unreadable cache. - expect(handle.tools).toHaveLength(0); - }); - - it('does not notify subscribers when the re-list itself fails', async () => { - let calls = 0; - state.listTools = () => { - calls += 1; - if (calls === 1) { - return Promise.resolve({ - tools: [], - }); - } - return Promise.reject(new Error('server gone')); - }; - - const handle = await createMCPTools({ - url: 'https://mcp.example.com/mcp', - }); - const seen: number[] = []; - handle.onToolsChanged((next) => { - seen.push(next.length); - }); - - state.listChangedHandler?.(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Tools were never swapped, so silence is correct here. - expect(seen).toEqual([]); - expect(handle.tools).toHaveLength(0); + it('exposes createMCPTools as a function', () => { + expect(typeof wrapperCreateMcpTools.createMCPTools).toBe('function'); }); }); diff --git a/packages/mcp/tests/unit/index.test.ts b/packages/mcp/tests/unit/index.test.ts new file mode 100644 index 00000000..c8e0ff37 --- /dev/null +++ b/packages/mcp/tests/unit/index.test.ts @@ -0,0 +1,25 @@ +import * as agentMcp from '@openrouter/agent/mcp'; +import * as wrapperMcp from '@openrouter/mcp'; +import { describe, expect, it } from 'vitest'; + +// Compatibility/export-parity tests: @openrouter/mcp is a thin wrapper that +// re-exports @openrouter/agent/mcp. These tests verify the wrapper's runtime +// exports are the SAME bindings as the canonical implementation (not a +// reimplementation) — the underlying MCP logic itself is tested exhaustively +// in packages/agent's own test suite. + +describe('@openrouter/mcp root export parity with @openrouter/agent/mcp', () => { + it('exports exactly the same runtime binding names', () => { + const agentKeys = Object.keys(agentMcp).sort(); + const wrapperKeys = Object.keys(wrapperMcp).sort(); + expect(wrapperKeys).toEqual(agentKeys); + }); + + it('re-exports the exact same bindings by reference (not reimplementations)', () => { + for (const key of Object.keys(wrapperMcp)) { + expect((wrapperMcp as Record)[key]).toBe( + (agentMcp as Record)[key], + ); + } + }); +}); diff --git a/packages/mcp/tests/unit/schema.test.ts b/packages/mcp/tests/unit/schema.test.ts new file mode 100644 index 00000000..8067583c --- /dev/null +++ b/packages/mcp/tests/unit/schema.test.ts @@ -0,0 +1,23 @@ +import * as agentSchema from '@openrouter/agent/mcp/schema'; +import * as wrapperSchema from '@openrouter/mcp/schema'; +import { describe, expect, it } from 'vitest'; + +describe('@openrouter/mcp/schema export parity', () => { + it('exports exactly the same runtime binding names as @openrouter/agent/mcp/schema', () => { + const agentKeys = Object.keys(agentSchema).sort(); + const wrapperKeys = Object.keys(wrapperSchema).sort(); + expect(wrapperKeys).toEqual(agentKeys); + }); + + it('re-exports the exact same bindings by reference', () => { + for (const key of Object.keys(wrapperSchema)) { + expect((wrapperSchema as Record)[key]).toBe( + (agentSchema as Record)[key], + ); + } + }); + + it('exposes convertMcpInputSchema as a function', () => { + expect(typeof wrapperSchema.convertMcpInputSchema).toBe('function'); + }); +}); diff --git a/packages/mcp/tests/unit/types.test.ts b/packages/mcp/tests/unit/types.test.ts new file mode 100644 index 00000000..bd4a5e2b --- /dev/null +++ b/packages/mcp/tests/unit/types.test.ts @@ -0,0 +1,32 @@ +import type * as AgentTypes from '@openrouter/agent/mcp/types'; +import type * as WrapperTypes from '@openrouter/mcp/types'; +import { describe, expectTypeOf, it } from 'vitest'; + +// `@openrouter/mcp/types` is type-only, so parity is verified structurally at +// the type level rather than via runtime `Object.keys` (there is nothing to +// inspect at runtime for a type-only module). +describe('@openrouter/mcp/types export parity (type-level)', () => { + it('CreateMCPToolsOptions is structurally identical', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('MCPToolsHandle is structurally identical', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('ElicitationHandler is structurally identical', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('ElicitationResponse is structurally identical', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('ResourcesOption is structurally identical', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('MCPTransportKind is structurally identical', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 924c9783..63350042 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: packages/agent: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@openrouter/sdk': specifier: ^0.13.7 version: 0.13.7 @@ -48,15 +51,6 @@ importers: specifier: ^4.0.0 version: 4.3.6 - packages/agent-tool-set: - dependencies: - '@openrouter/agent': - specifier: workspace:* - version: link:../agent - zod: - specifier: ^4.0.0 - version: 4.3.6 - packages/mcp: dependencies: '@modelcontextprotocol/client': @@ -65,9 +59,6 @@ importers: '@openrouter/agent': specifier: workspace:* version: link:../agent - zod: - specifier: ^4.0.0 - version: 4.3.6 packages: From 293bdb1647f266e96b8207ac5bfe02a5b41a88f8 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:44:28 -0500 Subject: [PATCH 02/14] fix(agent): harden optional package boundaries Load the optional MCP SDK only when a connection is requested and surface an actionable missing-peer error without pulling MCP into root imports. Mark the legacy MCP package as a migration facade and document the complete subpath surface. Add packed-install, export-map, dependency-version, resolution-condition, and architecture boundary checks. Also preserve widened ToolSet elements in dynamic FilterToolsByIds arrays and activate the maintained declaration fixtures. Co-Authored-By: Claude --- .changeset/agent-mcp-subpath.md | 4 +- .github/workflows/ci.yaml | 19 ++ .sentrux/rules.toml | 20 ++ package.json | 1 + packages/agent/README.md | 48 ++++- packages/agent/src/lib/tool-set-types.ts | 6 +- packages/agent/src/mcp/errors.ts | 18 ++ packages/agent/src/mcp/index.ts | 1 + packages/agent/src/mcp/mcp-sdk.ts | 57 +++++ packages/agent/tests/unit/mcp/mcp-sdk.test.ts | 51 +++++ .../tests/unit/package-boundaries.test.ts | 28 +++ packages/agent/tsconfig.typecheck.json | 3 + packages/mcp/README.md | 20 +- packages/mcp/src/cache.ts | 6 +- packages/mcp/src/create-mcp-tools.ts | 6 +- packages/mcp/src/index.ts | 10 +- packages/mcp/src/schema.ts | 6 +- packages/mcp/src/types.ts | 6 +- scripts/verify-package-boundaries.mjs | 201 ++++++++++++++++++ 19 files changed, 483 insertions(+), 28 deletions(-) create mode 100644 packages/agent/src/mcp/mcp-sdk.ts create mode 100644 packages/agent/tests/unit/mcp/mcp-sdk.test.ts create mode 100644 packages/agent/tests/unit/package-boundaries.test.ts create mode 100644 scripts/verify-package-boundaries.mjs diff --git a/.changeset/agent-mcp-subpath.md b/.changeset/agent-mcp-subpath.md index 858cde38..d7bc81a1 100644 --- a/.changeset/agent-mcp-subpath.md +++ b/.changeset/agent-mcp-subpath.md @@ -17,4 +17,6 @@ const result = callModel(new OpenRouter(), { }); ``` -Install `@modelcontextprotocol/sdk` alongside `@openrouter/agent` when using `/mcp`. Existing `@openrouter/mcp` imports continue to work, but new code should prefer `@openrouter/agent/mcp`. +Install `@modelcontextprotocol/sdk` alongside `@openrouter/agent` when using `/mcp`. The SDK is loaded lazily, so importing the base agent or the MCP entry point does not require the peer; the first MCP connection attempt without it throws an actionable `MCPMissingPeerDependencyError`. + +Existing `@openrouter/mcp` imports continue to work as tooling-visible deprecated migration facades, but new code should prefer `@openrouter/agent/mcp`. The facade would only be removed in a future breaking release after migration notice. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b885192b..3334a693 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -94,6 +94,25 @@ jobs: if-no-files-found: ignore retention-days: 14 + package-boundaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Verify packed package boundaries + run: pnpm verify:packages + e2e-tests: runs-on: ubuntu-latest steps: diff --git a/.sentrux/rules.toml b/.sentrux/rules.toml index 9e261c1a..4592bb4c 100644 --- a/.sentrux/rules.toml +++ b/.sentrux/rules.toml @@ -43,3 +43,23 @@ reason = "api-shape-helpers is a leaf layer; it must not depend on anything abov from = "packages/agent/src/lib/*" to = "packages/agent/src/inner-loop/*" reason = "lib is a utility layer; it must not depend on the inner-loop entry point." + +[[boundaries]] +from = "packages/agent/src/inner-loop/*" +to = "packages/agent/src/mcp/*" +reason = "The core agent loop must not load the optional MCP integration." + +[[boundaries]] +from = "packages/agent/src/lib/*" +to = "packages/agent/src/mcp/*" +reason = "Core agent utilities must not load the optional MCP integration." + +[[boundaries]] +from = "packages/agent/src/api-shape-helpers/*" +to = "packages/agent/src/mcp/*" +reason = "Leaf API-shape definitions must remain independent of optional MCP support." + +[[boundaries]] +from = "packages/agent/src/mcp/*" +to = "packages/agent/src/inner-loop/*" +reason = "MCP wraps public tool primitives and must not depend on the model loop." diff --git a/package.json b/package.json index dde830ee..ade19350 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "lint": "turbo run lint", "lint:fix": "biome check --write packages/*/src packages/*/tests", "typecheck": "turbo run typecheck", + "verify:packages": "node scripts/verify-package-boundaries.mjs", "changeset": "changeset", "version": "changeset version && turbo run gen:version", "prepare": "husky" diff --git a/packages/agent/README.md b/packages/agent/README.md index 3e03529c..b7e9b4a6 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -986,23 +986,49 @@ type Conditional = InferConditionalIds; ## Subpath Exports -For tree-shaking or targeted imports, the package provides granular subpath exports: +The root export is the primary API. Feature subpaths isolate optional integrations, +and advanced subpaths support targeted imports without exposing undeclared filesystem +paths. All of the following are public, semver-governed entry points: + +| Entry point | Purpose | +| --- | --- | +| `@openrouter/agent` | Primary agent API and SDK-facing types. | +| `@openrouter/agent/tool-set` | Declarative, state-aware tool activation. | +| `@openrouter/agent/mcp` | Canonical optional MCP integration. | +| `@openrouter/agent/mcp/create-mcp-tools` | Focused MCP factory import. | +| `@openrouter/agent/mcp/types` | MCP option and handle types. | +| `@openrouter/agent/mcp/schema` | MCP JSON Schema conversion. | +| `@openrouter/agent/mcp/cache` | MCP cache contracts and in-memory store. | +| `@openrouter/agent/call-model` | Model-loop entry point. | +| `@openrouter/agent/openrouter` | OpenRouter client export. | +| `@openrouter/agent/tool` | Tool creation helpers. | +| `@openrouter/agent/tool-types` | Tool and correlated-event types. | +| `@openrouter/agent/model-result` | Model result consumption. | +| `@openrouter/agent/hooks-manager` | Lifecycle hook manager. | +| `@openrouter/agent/async-params` | Async request-parameter resolution. | +| `@openrouter/agent/stop-conditions` | Agent-loop stop conditions. | +| `@openrouter/agent/doom-loop` | Repetition detection and escalation. | +| `@openrouter/agent/anthropic-compat` | Anthropic message conversion. | +| `@openrouter/agent/chat-compat` | Chat message conversion. | +| `@openrouter/agent/claude-constants` | Claude compatibility constants. | +| `@openrouter/agent/claude-type-guards` | Claude message type guards. | +| `@openrouter/agent/conversation-state` | Serializable conversation state. | +| `@openrouter/agent/next-turn-params` | Next-turn request helpers. | +| `@openrouter/agent/stream-transformers` | Response stream transformation. | +| `@openrouter/agent/tool-context` | Tool execution context. | +| `@openrouter/agent/tool-event-broadcaster` | Real-time tool events. | +| `@openrouter/agent/turn-context` | Turn-scoped context types. | ```typescript import { callModel } from '@openrouter/agent/call-model'; -import { tool } from '@openrouter/agent/tool'; -import { ModelResult } from '@openrouter/agent/model-result'; -import { HooksManager } from '@openrouter/agent/hooks-manager'; -import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions'; -import { DoomLoopMonitor, fingerprintToolCall } from '@openrouter/agent/doom-loop'; -import { toClaudeMessage } from '@openrouter/agent/anthropic-compat'; -import { toChatMessage } from '@openrouter/agent/chat-compat'; -import { ToolContextStore } from '@openrouter/agent/tool-context'; -import { ToolEventBroadcaster } from '@openrouter/agent/tool-event-broadcaster'; -import { createInitialState } from '@openrouter/agent/conversation-state'; import { createToolSet } from '@openrouter/agent/tool-set'; +import { createMCPTools } from '@openrouter/agent/mcp'; ``` +`@openrouter/mcp` and its matching subpaths remain migration facades for existing +applications. Prefer the canonical `@openrouter/agent/mcp` paths in new code; the +facade would only be removed in a future breaking release after migration notice. + ## Development ```bash diff --git a/packages/agent/src/lib/tool-set-types.ts b/packages/agent/src/lib/tool-set-types.ts index b249b1d0..e991041b 100644 --- a/packages/agent/src/lib/tool-set-types.ts +++ b/packages/agent/src/lib/tool-set-types.ts @@ -82,9 +82,11 @@ export type ToolById = Extract< * to a single non-distributive check instead. */ type KeepIfActive = El extends Tool - ? ToolIdOf extends Active + ? string extends ToolIdOf ? El - : never + : ToolIdOf extends Active + ? El + : never : never; /** diff --git a/packages/agent/src/mcp/errors.ts b/packages/agent/src/mcp/errors.ts index eb151dd4..6d469f76 100644 --- a/packages/agent/src/mcp/errors.ts +++ b/packages/agent/src/mcp/errors.ts @@ -133,3 +133,21 @@ export class MCPConnectionError extends MCPError { : []); } } + +/** + * Raised when MCP support is used without its optional SDK peer installed. + */ +export class MCPMissingPeerDependencyError extends MCPConnectionError { + readonly packageName = '@modelcontextprotocol/sdk'; + + constructor(options?: { + cause?: unknown; + }) { + super( + 'MCP support requires the optional peer "@modelcontextprotocol/sdk". ' + + 'Install it alongside @openrouter/agent (for example: pnpm add @modelcontextprotocol/sdk).', + options, + ); + this.name = 'MCPMissingPeerDependencyError'; + } +} diff --git a/packages/agent/src/mcp/index.ts b/packages/agent/src/mcp/index.ts index 14500017..019b3114 100644 --- a/packages/agent/src/mcp/index.ts +++ b/packages/agent/src/mcp/index.ts @@ -17,6 +17,7 @@ export { MCPCacheError, MCPConnectionError, MCPError, + MCPMissingPeerDependencyError, MCPToolCallError, } from './errors.js'; export type { RehydrateMCPToolsOptions } from './rehydrate.js'; diff --git a/packages/agent/src/mcp/mcp-sdk.ts b/packages/agent/src/mcp/mcp-sdk.ts new file mode 100644 index 00000000..f5db0e31 --- /dev/null +++ b/packages/agent/src/mcp/mcp-sdk.ts @@ -0,0 +1,57 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { + ElicitRequestSchema, + ToolListChangedNotificationSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { MCPMissingPeerDependencyError } from './errors.js'; + +export interface MCPSdk { + Client: typeof Client; + SSEClientTransport: typeof SSEClientTransport; + StreamableHTTPClientTransport: typeof StreamableHTTPClientTransport; + ElicitRequestSchema: typeof ElicitRequestSchema; + ToolListChangedNotificationSchema: typeof ToolListChangedNotificationSchema; +} + +let sdkPromise: Promise | undefined; + +function isMissingSdk(error: unknown): boolean { + let current = error; + while (current instanceof Error) { + const code = 'code' in current ? current.code : undefined; + if (code === 'ERR_MODULE_NOT_FOUND' && current.message.includes('@modelcontextprotocol/sdk')) { + return true; + } + current = current.cause; + } + return false; +} + +/** Load the optional MCP SDK only when a connection is actually requested. */ +export function loadMcpSdk(): Promise { + sdkPromise ??= Promise.all([ + import('@modelcontextprotocol/sdk/client/index.js'), + import('@modelcontextprotocol/sdk/client/sse.js'), + import('@modelcontextprotocol/sdk/client/streamableHttp.js'), + import('@modelcontextprotocol/sdk/types.js'), + ]) + .then(([client, sse, streamableHttp, types]) => ({ + Client: client.Client, + SSEClientTransport: sse.SSEClientTransport, + StreamableHTTPClientTransport: streamableHttp.StreamableHTTPClientTransport, + ElicitRequestSchema: types.ElicitRequestSchema, + ToolListChangedNotificationSchema: types.ToolListChangedNotificationSchema, + })) + .catch((cause: unknown) => { + sdkPromise = undefined; + if (isMissingSdk(cause)) { + throw new MCPMissingPeerDependencyError({ + cause, + }); + } + throw cause; + }); + return sdkPromise; +} diff --git a/packages/agent/tests/unit/mcp/mcp-sdk.test.ts b/packages/agent/tests/unit/mcp/mcp-sdk.test.ts new file mode 100644 index 00000000..64410e1c --- /dev/null +++ b/packages/agent/tests/unit/mcp/mcp-sdk.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => { + throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { + code: 'ERR_MODULE_NOT_FOUND', + }); +}); +vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => { + throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { + code: 'ERR_MODULE_NOT_FOUND', + }); +}); +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => { + throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { + code: 'ERR_MODULE_NOT_FOUND', + }); +}); +vi.mock('@modelcontextprotocol/sdk/types.js', () => { + throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { + code: 'ERR_MODULE_NOT_FOUND', + }); +}); + +describe('optional MCP SDK loading', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('does not load the SDK for root, tool-set, or MCP entry-point imports', async () => { + await expect(import('@openrouter/agent')).resolves.toBeDefined(); + await expect(import('@openrouter/agent/tool-set')).resolves.toBeDefined(); + await expect(import('@openrouter/agent/mcp')).resolves.toBeDefined(); + }); + + it('reports an actionable error when the first connection needs the missing peer', async () => { + const { connect } = await import('../../../src/mcp/mcp-connection.js'); + const { MCPMissingPeerDependencyError } = await import('../../../src/mcp/errors.js'); + + const error = await connect({ + url: new URL('https://mcp.example.com/mcp'), + }).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(MCPMissingPeerDependencyError); + expect(error).toMatchObject({ + name: 'MCPMissingPeerDependencyError', + packageName: '@modelcontextprotocol/sdk', + }); + expect((error as Error).cause).toBeInstanceOf(Error); + expect((error as Error).message).toContain('pnpm add @modelcontextprotocol/sdk'); + }); +}); diff --git a/packages/agent/tests/unit/package-boundaries.test.ts b/packages/agent/tests/unit/package-boundaries.test.ts new file mode 100644 index 00000000..24f1a1b1 --- /dev/null +++ b/packages/agent/tests/unit/package-boundaries.test.ts @@ -0,0 +1,28 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; + +function customConditions(config: string): string[] | undefined { + const match = /"customConditions"\s*:\s*\[([^\]]*)\]/u.exec(config); + if (match?.[1] === undefined) { + return undefined; + } + return [ + ...match[1].matchAll(/"([^"]+)"/gu), + ].map((entry) => entry[1] ?? ''); +} + +describe('package build boundaries', () => { + it.each([ + [ + 'agent', + new URL('../../tsconfig.json', import.meta.url), + ], + [ + 'mcp facade', + new URL('../../../mcp/tsconfig.json', import.meta.url), + ], + ])('%s resolves dependencies through published exports', async (_name, configUrl) => { + const config = await readFile(configUrl, 'utf8'); + expect(customConditions(config)).toEqual([]); + }); +}); diff --git a/packages/agent/tsconfig.typecheck.json b/packages/agent/tsconfig.typecheck.json index 82ccc761..94c4c5d7 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -4,6 +4,9 @@ "include": [ "src/**/*.ts", "tests/unit/context-schema-inference.test-d.ts", + "tests/unit/filter-tools-by-ids.test-d.ts", + "tests/unit/has-approval-tools.test-d.ts", + "tests/unit/mcp-result-discrimination.test-d.ts", "tests/unit/resolved-tools.test-d.ts", "tests/unit/tool-shared-name.test-d.ts" ], diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5ecbe2da..6be7ebf3 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,9 +1,10 @@ # @openrouter/mcp > [!NOTE] -> This package is a compatibility facade. New code should import the canonical -> `@openrouter/agent/mcp` subpath. Existing `@openrouter/mcp` root and subpath -> imports continue to work. +> This package is a migration-only compatibility facade. New code should import +> the canonical `@openrouter/agent/mcp` subpath. Existing `@openrouter/mcp` root +> and subpath imports remain functional and would only be removed in a future +> breaking release after migration notice. Expose the tools of a remote [Model Context Protocol](https://modelcontextprotocol.io) server (Streamable HTTP or SSE) as tools you can pass straight into @@ -34,6 +35,19 @@ pnpm add @openrouter/mcp @openrouter/agent @modelcontextprotocol/sdk The agent package is marked `sideEffects: false`, and MCP code is exposed only through explicit `/mcp` exports. Root and `/tool-set` imports do not statically load MCP modules; the MCP SDK is not installed transitively for base agent users. +The `/mcp` entry point also loads the optional SDK lazily: importing it is safe +without the peer, while the first connection attempt throws an actionable +`MCPMissingPeerDependencyError` when the SDK has not been installed. + +## Compatibility subpaths + +| Existing facade | Canonical replacement | +| --- | --- | +| `@openrouter/mcp` | `@openrouter/agent/mcp` | +| `@openrouter/mcp/create-mcp-tools` | `@openrouter/agent/mcp/create-mcp-tools` | +| `@openrouter/mcp/types` | `@openrouter/agent/mcp/types` | +| `@openrouter/mcp/schema` | `@openrouter/agent/mcp/schema` | +| `@openrouter/mcp/cache` | `@openrouter/agent/mcp/cache` | ## Quick start diff --git a/packages/mcp/src/cache.ts b/packages/mcp/src/cache.ts index d9246147..ec7a78ae 100644 --- a/packages/mcp/src/cache.ts +++ b/packages/mcp/src/cache.ts @@ -1,4 +1,6 @@ -// Thin compatibility wrapper: re-exports the "cache" subpath from -// @openrouter/agent/mcp so `@openrouter/mcp/cache` keeps working. +/** + * @deprecated Import from `@openrouter/agent/mcp/cache` instead. + * This compatibility subpath remains available for migration. + */ export type { MCPCacheStore } from '@openrouter/agent/mcp/cache'; export { defaultCacheKey, InMemoryMCPCacheStore } from '@openrouter/agent/mcp/cache'; diff --git a/packages/mcp/src/create-mcp-tools.ts b/packages/mcp/src/create-mcp-tools.ts index b735ee26..0b2ce91f 100644 --- a/packages/mcp/src/create-mcp-tools.ts +++ b/packages/mcp/src/create-mcp-tools.ts @@ -1,5 +1,7 @@ -// Thin compatibility wrapper: re-exports the "create-mcp-tools" subpath from -// @openrouter/agent/mcp so `@openrouter/mcp/create-mcp-tools` keeps working. +/** + * @deprecated Import from `@openrouter/agent/mcp/create-mcp-tools` instead. + * This compatibility subpath remains available for migration. + */ export type { SerializedMCPServer } from '@openrouter/agent/mcp/create-mcp-tools'; export { createMCPTools } from '@openrouter/agent/mcp/create-mcp-tools'; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 858833e4..e8b2417a 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,6 +1,9 @@ -// Thin compatibility wrapper: @openrouter/mcp re-exports the canonical -// implementation that now lives in @openrouter/agent/mcp. This file exists so -// existing consumers importing from "@openrouter/mcp" keep working unchanged. +/** + * Compatibility facade for the canonical `@openrouter/agent/mcp` integration. + * + * @deprecated Import from `@openrouter/agent/mcp` instead. This facade remains + * available for migration and may be removed only in a future breaking release. + */ export type { CreateMCPToolsOptions, ElicitationHandler, @@ -25,6 +28,7 @@ export { MCPCacheError, MCPConnectionError, MCPError, + MCPMissingPeerDependencyError, MCPToolCallError, rehydrateMCPTools, } from '@openrouter/agent/mcp'; diff --git a/packages/mcp/src/schema.ts b/packages/mcp/src/schema.ts index 164b1dc2..8990fa55 100644 --- a/packages/mcp/src/schema.ts +++ b/packages/mcp/src/schema.ts @@ -1,4 +1,6 @@ -// Thin compatibility wrapper: re-exports the "schema" subpath from -// @openrouter/agent/mcp so `@openrouter/mcp/schema` keeps working. +/** + * @deprecated Import from `@openrouter/agent/mcp/schema` instead. + * This compatibility subpath remains available for migration. + */ export type { UnconvertibleSchemaMode } from '@openrouter/agent/mcp/schema'; export { convertMcpInputSchema } from '@openrouter/agent/mcp/schema'; diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 619c5bff..6bd81908 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -1,5 +1,7 @@ -// Thin compatibility wrapper: re-exports the "types" subpath from -// @openrouter/agent/mcp so `@openrouter/mcp/types` keeps working. +/** + * @deprecated Import from `@openrouter/agent/mcp/types` instead. + * This compatibility subpath remains available for migration. + */ export type { CreateMCPToolsOptions, ElicitationHandler, diff --git a/scripts/verify-package-boundaries.mjs b/scripts/verify-package-boundaries.mjs new file mode 100644 index 00000000..74478e16 --- /dev/null +++ b/scripts/verify-package-boundaries.mjs @@ -0,0 +1,201 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const scratch = mkdtempSync(join(tmpdir(), 'openrouter-package-boundaries-')); +const packDir = join(scratch, 'packs'); +const consumerDir = join(scratch, 'consumer'); +const packages = [ + 'packages/agent', + 'packages/mcp', +]; + +function run({ command, args, cwd = root, capture = false }) { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: capture ? 'pipe' : 'inherit', + }); +} + +function pack(packageDir) { + const output = run({ + command: 'pnpm', + args: [ + 'pack', + '--pack-destination', + packDir, + '--json', + ], + cwd: join(root, packageDir), + capture: true, + }); + const result = JSON.parse(output); + const filename = Array.isArray(result) ? result[0]?.filename : result.filename; + if (typeof filename !== 'string') { + throw new Error(`Could not determine tarball name for ${packageDir}`); + } + return resolve(join(root, packageDir), filename); +} + +function tarEntries(tarball) { + return run({ + command: 'tar', + args: [ + '-tzf', + tarball, + ], + capture: true, + }) + .split('\n') + .filter(Boolean) + .map((entry) => entry.replace(/^package\//u, '')); +} + +function verifyExports(packageDir, entries) { + const manifest = JSON.parse(readFileSync(join(root, packageDir, 'package.json'), 'utf8')); + for (const [subpath, target] of Object.entries(manifest.exports)) { + if (typeof target === 'string') { + if (!entries.includes(target.replace(/^\.\//u, ''))) { + throw new Error(`${manifest.name} ${subpath} target is missing: ${target}`); + } + continue; + } + for (const kind of [ + 'types', + 'default', + ]) { + const path = target[kind]; + if (typeof path === 'string' && !entries.includes(path.replace(/^\.\//u, ''))) { + throw new Error(`${manifest.name} ${subpath} ${kind} target is missing: ${path}`); + } + } + } + const leaked = entries.filter( + (entry) => /^(src|tests)\//u.test(entry) || /(^|\/)tsconfig(?:\.[^/]*)?\.json$/u.test(entry), + ); + if (leaked.length > 0) { + throw new Error(`${manifest.name} tarball leaks development files:\n${leaked.join('\n')}`); + } + return manifest; +} + +try { + run({ + command: 'pnpm', + args: [ + '--filter', + '@openrouter/agent', + 'build', + ], + }); + run({ + command: 'pnpm', + args: [ + '--filter', + '@openrouter/mcp', + 'build', + ], + }); + mkdirSync(packDir, { + recursive: true, + }); + mkdirSync(consumerDir, { + recursive: true, + }); + + const tarballs = packages.map(pack); + const manifests = packages.map((packageDir, index) => + verifyExports(packageDir, tarEntries(tarballs[index])), + ); + + run({ + command: 'npm', + args: [ + 'init', + '-y', + ], + cwd: consumerDir, + capture: true, + }); + run({ + command: 'npm', + args: [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + ...tarballs, + ], + cwd: consumerDir, + }); + + const smoke = ` +const entries = [ + '@openrouter/agent', + '@openrouter/agent/tool-set', + '@openrouter/agent/mcp', + '@openrouter/agent/mcp/create-mcp-tools', + '@openrouter/agent/mcp/types', + '@openrouter/agent/mcp/schema', + '@openrouter/agent/mcp/cache', + '@openrouter/mcp', + '@openrouter/mcp/create-mcp-tools', + '@openrouter/mcp/types', + '@openrouter/mcp/schema', + '@openrouter/mcp/cache', +]; +for (const entry of entries) await import(entry); +const { createMCPTools, MCPMissingPeerDependencyError } = await import('@openrouter/agent/mcp'); +try { + await createMCPTools({ url: 'https://mcp.example.com/mcp' }); + throw new Error('Expected the optional MCP peer to be absent'); +} catch (error) { + if (!(error instanceof MCPMissingPeerDependencyError)) throw error; + if (!error.message.includes('pnpm add @modelcontextprotocol/sdk')) throw error; +} +`; + run({ + command: 'node', + args: [ + '--input-type=module', + '--eval', + smoke, + ], + cwd: consumerDir, + }); + + const tree = JSON.parse( + run({ + command: 'npm', + args: [ + 'ls', + '@openrouter/agent', + '--all', + '--json', + ], + cwd: consumerDir, + capture: true, + }), + ); + const direct = tree.dependencies?.['@openrouter/agent']; + if (direct?.version !== manifests[0].version) { + throw new Error( + `Packed @openrouter/mcp did not resolve packed @openrouter/agent ${manifests[0].version}`, + ); + } + const nested = tree.dependencies?.['@openrouter/mcp']?.dependencies?.['@openrouter/agent']; + if (nested !== undefined && nested.version !== manifests[0].version) { + throw new Error(`Packed facade resolved a second @openrouter/agent version: ${nested.version}`); + } + + console.log('Package exports, tarballs, optional-peer isolation, and packed installs verified.'); +} finally { + rmSync(scratch, { + recursive: true, + force: true, + }); +} From 95eeb3b399d5c6aceae8e21cd3487b6f5bb0b84f Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:40:07 -0500 Subject: [PATCH 03/14] fix(stack): resolve main and parent integration Preserve current main's async-agent behavior while moving ToolSet and the MCP v2 client implementation under agent subpaths. Keep the MCP client as a lazily loaded optional peer, retain protocol negotiation behavior, and relocate implementation tests to the canonical package. Co-Authored-By: Claude --- packages/agent/package.json | 9 +- packages/agent/src/lib/tool-types.ts | 4 +- packages/agent/src/lib/tool.ts | 4 +- packages/agent/src/mcp/mcp-connection.ts | 103 +- packages/agent/src/mcp/mcp-sdk.ts | 37 +- packages/agent/src/mcp/rehydrate.ts | 6 +- packages/agent/src/mcp/types.ts | 8 +- packages/agent/tests/unit/mcp/cache.test.ts | 4 +- packages/agent/tests/unit/mcp/mcp-sdk.test.ts | 51 - .../agent/tests/unit/mcp/rehydrate.test.ts | 20 +- packages/agent/tsconfig.json | 1 + packages/mcp/package.json | 3 +- .../mcp/tests/unit/call-tool-shape.test.ts | 115 -- .../mcp/tests/unit/mcp-connection.test.ts | 1251 ----------------- packages/mcp/tests/unit/protocol-era.test.ts | 661 --------- packages/mcp/tests/unit/version.test.ts | 35 - pnpm-lock.yaml | 7 +- 17 files changed, 130 insertions(+), 2189 deletions(-) delete mode 100644 packages/agent/tests/unit/mcp/mcp-sdk.test.ts delete mode 100644 packages/mcp/tests/unit/call-tool-shape.test.ts delete mode 100644 packages/mcp/tests/unit/mcp-connection.test.ts delete mode 100644 packages/mcp/tests/unit/protocol-era.test.ts delete mode 100644 packages/mcp/tests/unit/version.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index b364a068..047c7553 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -2,7 +2,7 @@ "name": "@openrouter/agent", "version": "0.9.0", "author": "OpenRouter", - "description": "Agent toolkit for building AI applications with OpenRouter — tool orchestration, streaming, multi-turn conversations, and format compatibility.", + "description": "Agent toolkit for building AI applications with OpenRouter \u2014 tool orchestration, streaming, multi-turn conversations, and format compatibility.", "keywords": [ "openrouter", "agent", @@ -153,11 +153,14 @@ "zod": "^4.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/client": "^2.0.0" }, "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { + "@modelcontextprotocol/client": { "optional": true } + }, + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0" } } diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 0979c125..e89d7490 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -857,7 +857,7 @@ export interface ServerToolBase { readonly _brand: 'server-tool'; readonly config: ServerToolConfig; /** - * Stable tool-set identity used by `@openrouter/agent/tool-set` activation. + * Stable tool-set identity used by `@openrouter/agent-tool-set` activation. * Defaults to `server:${config.type}` when constructed via {@link serverTool}. * * Optional here for source compatibility with legacy hand-constructed @@ -1438,7 +1438,7 @@ export type CorrelatedToolResultEvent = Omit< /** * Widest backward-compatible shape for {@link CorrelatedToolEventUnion} when * `T` is the generic `readonly Tool[]` (e.g. a tool handle from - * `@openrouter/agent/mcp`, whose concrete tuple isn't known at the type level). + * `@openrouter/mcp`, whose concrete tuple isn't known at the type level). * Mirrors the pre-existing {@link ToolPreliminaryResultEvent} / * {@link ToolResultEvent} default shapes. */ diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index cbd76d1c..a19717ca 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -1017,7 +1017,7 @@ tool.agent = agentToolBuilder; /** * Options for {@link serverTool}. - * @template TId Stable tool-set identity used by `@openrouter/agent/tool-set`. + * @template TId Stable tool-set identity used by `@openrouter/agent-tool-set`. */ export type ServerToolOptions = { /** @@ -1076,7 +1076,7 @@ export function serverTool; } -function buildStreamableHttp(options: ConnectOptions): StreamableHTTPClientTransport { +function buildStreamableHttp(sdk: MCPSdk, options: ConnectOptions): StreamableHTTPClientTransport { const { headers, authProvider } = resolveAuth(options.auth); - return new StreamableHTTPClientTransport(options.url, { + return new sdk.StreamableHTTPClientTransport(options.url, { requestInit: { headers, }, @@ -127,9 +126,9 @@ function buildStreamableHttp(options: ConnectOptions): StreamableHTTPClientTrans }); } -function buildSse(options: ConnectOptions): SSEClientTransport { +function buildSse(sdk: MCPSdk, options: ConnectOptions): SSEClientTransport { const { headers, authProvider } = resolveAuth(options.auth); - return new SSEClientTransport(options.url, { + return new sdk.SSEClientTransport(options.url, { requestInit: { headers, }, @@ -173,14 +172,18 @@ interface MutableListChanged { * * @internal */ -export function makeClientForTest(options: ConnectOptions, onListChanged: () => void): Client { - return makeClient(options, { +export async function makeClientForTest( + options: ConnectOptions, + onListChanged: () => void, +): Promise { + const sdk = await loadMcpSdk(); + return makeClient(sdk, options, { handler: onListChanged, }); } -function makeClient(options: ConnectOptions, listChanged: MutableListChanged): Client { - const client = new Client(options.clientInfo ?? DEFAULT_CLIENT_INFO, { +function makeClient(sdk: MCPSdk, options: ConnectOptions, listChanged: MutableListChanged): Client { + const client = new sdk.Client(options.clientInfo ?? DEFAULT_CLIENT_INFO, { capabilities: { elicitation: {}, }, @@ -230,16 +233,19 @@ function makeClient(options: ConnectOptions, listChanged: MutableListChanged): C * retried once with `'legacy'` — see {@link connect} below, which owns that * policy; this function performs exactly one negotiation mode. */ -async function connectWithNegotiation(options: ConnectOptions): Promise { +async function connectWithNegotiation( + sdk: MCPSdk, + options: ConnectOptions, +): Promise { const preferred = options.transport ?? 'streamableHttp'; const listChanged: MutableListChanged = { handler: undefined, }; if (preferred === 'sse') { - const client = makeClient(options, listChanged); + const client = makeClient(sdk, options, listChanged); try { - await client.connect(buildSse(options), connectRequestOptions(options)); + await client.connect(buildSse(sdk, options), connectRequestOptions(options)); } catch (sseErr) { // Same transport-release reason as the Streamable HTTP path below. await closeQuietly(client); @@ -259,9 +265,9 @@ async function connectWithNegotiation(options: ConnectOptions): Promise= 8) { return false; } // `UnauthorizedError` is unconditional: the SDK only throws it from the // provider-wrapped fetch and the authorization flow itself, so it inherently // means an OAuth provider is in play and its flow would be re-driven. - if (err instanceof UnauthorizedError) { + if (UnauthorizedErrorType !== undefined && err instanceof UnauthorizedErrorType) { return true; } // A bare 401 status counts only when the caller configured OAuth — the one @@ -449,12 +474,27 @@ export function isAuthFailure(err: unknown, auth: MCPAuth | undefined, depth = 0 }; if (Array.isArray(errors)) { for (const nested of errors) { - if (isAuthFailure(nested, auth, depth + 1)) { + if ( + isAuthFailure({ + err: nested, + auth, + UnauthorizedErrorType, + depth: depth + 1, + }) + ) { return true; } } } - return err.cause !== undefined && isAuthFailure(err.cause, auth, depth + 1); + return ( + err.cause !== undefined && + isAuthFailure({ + err: err.cause, + auth, + UnauthorizedErrorType, + depth: depth + 1, + }) + ); } /** @@ -507,11 +547,12 @@ export function isAuthFailure(err: unknown, auth: MCPAuth | undefined, depth = 0 * ignoring a caller's `{ pin }` would defeat the point of pinning. */ export async function connect(options: ConnectOptions): Promise { + const sdk = await loadMcpSdk(); if (options.protocolNegotiation !== undefined) { - return connectWithNegotiation(options); + return connectWithNegotiation(sdk, options); } try { - return await connectWithNegotiation(options); + return await connectWithNegotiation(sdk, options); } catch (autoErr) { // A caller-initiated abort is not a server problem: retrying under // `'legacy'` would immediately re-abort (or worse, outlive the caller's @@ -519,7 +560,13 @@ export async function connect(options: ConnectOptions): Promise { if (options.signal?.aborted === true) { throw autoErr; } - if (isAuthFailure(autoErr, options.auth)) { + if ( + isAuthFailure({ + err: autoErr, + auth: options.auth, + UnauthorizedErrorType: sdk.UnauthorizedError, + }) + ) { throw autoErr; } // Not inspecting the error for *whether* the probe was at fault: that would @@ -529,7 +576,7 @@ export async function connect(options: ConnectOptions): Promise { // a wasted dial — and if that check ever stops matching we merely retry, // which is the pre-existing behavior rather than a silent loss of function. try { - return await connectWithNegotiation({ + return await connectWithNegotiation(sdk, { ...options, protocolNegotiation: 'legacy', }); diff --git a/packages/agent/src/mcp/mcp-sdk.ts b/packages/agent/src/mcp/mcp-sdk.ts index f5db0e31..72fcb09e 100644 --- a/packages/agent/src/mcp/mcp-sdk.ts +++ b/packages/agent/src/mcp/mcp-sdk.ts @@ -1,18 +1,16 @@ -import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { - ElicitRequestSchema, - ToolListChangedNotificationSchema, -} from '@modelcontextprotocol/sdk/types.js'; + Client, + SSEClientTransport, + StreamableHTTPClientTransport, + UnauthorizedError, +} from '@modelcontextprotocol/client'; import { MCPMissingPeerDependencyError } from './errors.js'; export interface MCPSdk { Client: typeof Client; SSEClientTransport: typeof SSEClientTransport; StreamableHTTPClientTransport: typeof StreamableHTTPClientTransport; - ElicitRequestSchema: typeof ElicitRequestSchema; - ToolListChangedNotificationSchema: typeof ToolListChangedNotificationSchema; + UnauthorizedError: typeof UnauthorizedError; } let sdkPromise: Promise | undefined; @@ -21,7 +19,10 @@ function isMissingSdk(error: unknown): boolean { let current = error; while (current instanceof Error) { const code = 'code' in current ? current.code : undefined; - if (code === 'ERR_MODULE_NOT_FOUND' && current.message.includes('@modelcontextprotocol/sdk')) { + if ( + code === 'ERR_MODULE_NOT_FOUND' && + current.message.includes('@modelcontextprotocol/client') + ) { return true; } current = current.cause; @@ -29,20 +30,14 @@ function isMissingSdk(error: unknown): boolean { return false; } -/** Load the optional MCP SDK only when a connection is actually requested. */ +/** Load the optional MCP client only when a connection is actually requested. */ export function loadMcpSdk(): Promise { - sdkPromise ??= Promise.all([ - import('@modelcontextprotocol/sdk/client/index.js'), - import('@modelcontextprotocol/sdk/client/sse.js'), - import('@modelcontextprotocol/sdk/client/streamableHttp.js'), - import('@modelcontextprotocol/sdk/types.js'), - ]) - .then(([client, sse, streamableHttp, types]) => ({ + sdkPromise ??= import('@modelcontextprotocol/client') + .then((client) => ({ Client: client.Client, - SSEClientTransport: sse.SSEClientTransport, - StreamableHTTPClientTransport: streamableHttp.StreamableHTTPClientTransport, - ElicitRequestSchema: types.ElicitRequestSchema, - ToolListChangedNotificationSchema: types.ToolListChangedNotificationSchema, + SSEClientTransport: client.SSEClientTransport, + StreamableHTTPClientTransport: client.StreamableHTTPClientTransport, + UnauthorizedError: client.UnauthorizedError, })) .catch((cause: unknown) => { sdkPromise = undefined; diff --git a/packages/agent/src/mcp/rehydrate.ts b/packages/agent/src/mcp/rehydrate.ts index 452688b8..c7947ad7 100644 --- a/packages/agent/src/mcp/rehydrate.ts +++ b/packages/agent/src/mcp/rehydrate.ts @@ -619,7 +619,11 @@ export async function rehydrateMCPTools( if ( reconnectOnExpiry && options.signal?.aborted !== true && - !isAuthFailure(err, effectiveAuth) + !isAuthFailure({ + err, + auth: effectiveAuth, + UnauthorizedErrorType: undefined, + }) ) { return freshConnect(createOptions, url, cacheKey); } diff --git a/packages/agent/src/mcp/types.ts b/packages/agent/src/mcp/types.ts index c7a8fc11..c7e47e84 100644 --- a/packages/agent/src/mcp/types.ts +++ b/packages/agent/src/mcp/types.ts @@ -2,9 +2,9 @@ import type { Tool, ToolLoopKey } from '../lib/tool-types.js'; import type { MCPAuth } from './auth/auth-types.js'; import type { MCPCacheStore } from './cache/cache-store.js'; import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; -import type { MCPTransportKind } from './transport-types.js'; +import type { MCPProtocolNegotiation, MCPTransportKind } from './transport-types.js'; -export type { MCPTransportKind }; +export type { MCPProtocolNegotiation, MCPTransportKind }; /** * Response to a server-initiated elicitation request. `accept` must carry @@ -99,6 +99,10 @@ export interface CreateMCPToolsOptions { autoRefreshOnListChanged?: boolean; /** Handler for server-initiated elicitation; auto-declines when omitted. */ onElicitation?: ElicitationHandler; + /** Protocol-revision negotiation policy; defaults to `auto`. */ + protocolNegotiation?: MCPProtocolNegotiation; + /** Ceiling on the `server/discover` probe, in ms; defaults to 30000. */ + probeTimeoutMs?: number; /** Abort signal threaded into every underlying `callTool`. */ signal?: AbortSignal; } diff --git a/packages/agent/tests/unit/mcp/cache.test.ts b/packages/agent/tests/unit/mcp/cache.test.ts index 440ef59f..597e24b0 100644 --- a/packages/agent/tests/unit/mcp/cache.test.ts +++ b/packages/agent/tests/unit/mcp/cache.test.ts @@ -83,7 +83,7 @@ describe('serializeServer', () => { expect(snap.sessionId).toBeUndefined(); }); - it('includes credentials + sessionId when cacheCredentials is true', async () => { + it('includes credentials but not the removed protocol session when cacheCredentials is true', async () => { const snap = await serializeServer({ url: 'https://mcp.example.com/mcp', transport: 'streamableHttp', @@ -99,7 +99,7 @@ describe('serializeServer', () => { expect(snap.auth?.headers).toEqual({ Authorization: 'Bearer secret', }); - expect(snap.sessionId).toBe('sess-1'); + expect(snap.sessionId).toBeUndefined(); }); }); diff --git a/packages/agent/tests/unit/mcp/mcp-sdk.test.ts b/packages/agent/tests/unit/mcp/mcp-sdk.test.ts deleted file mode 100644 index 64410e1c..00000000 --- a/packages/agent/tests/unit/mcp/mcp-sdk.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@modelcontextprotocol/sdk/client/index.js', () => { - throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { - code: 'ERR_MODULE_NOT_FOUND', - }); -}); -vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => { - throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { - code: 'ERR_MODULE_NOT_FOUND', - }); -}); -vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => { - throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { - code: 'ERR_MODULE_NOT_FOUND', - }); -}); -vi.mock('@modelcontextprotocol/sdk/types.js', () => { - throw Object.assign(new Error("Cannot find package '@modelcontextprotocol/sdk'"), { - code: 'ERR_MODULE_NOT_FOUND', - }); -}); - -describe('optional MCP SDK loading', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('does not load the SDK for root, tool-set, or MCP entry-point imports', async () => { - await expect(import('@openrouter/agent')).resolves.toBeDefined(); - await expect(import('@openrouter/agent/tool-set')).resolves.toBeDefined(); - await expect(import('@openrouter/agent/mcp')).resolves.toBeDefined(); - }); - - it('reports an actionable error when the first connection needs the missing peer', async () => { - const { connect } = await import('../../../src/mcp/mcp-connection.js'); - const { MCPMissingPeerDependencyError } = await import('../../../src/mcp/errors.js'); - - const error = await connect({ - url: new URL('https://mcp.example.com/mcp'), - }).catch((cause: unknown) => cause); - - expect(error).toBeInstanceOf(MCPMissingPeerDependencyError); - expect(error).toMatchObject({ - name: 'MCPMissingPeerDependencyError', - packageName: '@modelcontextprotocol/sdk', - }); - expect((error as Error).cause).toBeInstanceOf(Error); - expect((error as Error).message).toContain('pnpm add @modelcontextprotocol/sdk'); - }); -}); diff --git a/packages/agent/tests/unit/mcp/rehydrate.test.ts b/packages/agent/tests/unit/mcp/rehydrate.test.ts index e2042eb2..aef001fe 100644 --- a/packages/agent/tests/unit/mcp/rehydrate.test.ts +++ b/packages/agent/tests/unit/mcp/rehydrate.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MCPCacheError } from '../../src/errors.js'; -import type { ConnectOptions, MCPConnection } from '../../src/mcp-connection.js'; +import { MCPCacheError } from '../../../src/mcp/errors.js'; +import type { ConnectOptions, MCPConnection } from '../../../src/mcp/mcp-connection.js'; // Capture the options every `connect` call receives so we can assert on the auth // that rehydrate forwards into the transport. @@ -25,7 +25,7 @@ let connectRejects: Error | undefined; // returning a rejected promise — the case a bare `.catch()` cannot intercept. let closeThrowsSync = false; -vi.mock('../../src/mcp-connection.js', () => ({ +vi.mock('../../../src/mcp/mcp-connection.js', () => ({ // Minimal stand-in for the real guard: these tests drive auth failures with a // `FakeUnauthorized` whose marker the walk below recognises. isAuthFailure: (err: unknown): boolean => @@ -71,9 +71,9 @@ vi.mock('../../src/mcp-connection.js', () => ({ }, })); -const { rehydrateMCPTools } = await import('../../src/rehydrate.js'); -const { isSerializedMCPServer } = await import('../../src/cache/cache-types.js'); -type SerializedMCPServer = import('../../src/cache/cache-types.js').SerializedMCPServer; +const { rehydrateMCPTools } = await import('../../../src/mcp/rehydrate.js'); +const { isSerializedMCPServer } = await import('../../../src/mcp/cache/cache-types.js'); +type SerializedMCPServer = import('../../../src/mcp/cache/cache-types.js').SerializedMCPServer; function snapshotWithHeaders(): SerializedMCPServer { const snap: SerializedMCPServer = { @@ -568,7 +568,7 @@ describe('replay preserves snapshot age', () => { }); it('does not restamp cachedAt when tool defs come from a snapshot', async () => { - const { InMemoryMCPCacheStore } = await import('../../src/cache/cache-store.js'); + const { InMemoryMCPCacheStore } = await import('../../../src/mcp/cache/cache-store.js'); const store = new InMemoryMCPCacheStore(); const snap = snapshotWithHeaders(); const originalCachedAt = snap.cachedAt - 60_000; // a minute-old snapshot @@ -1146,7 +1146,7 @@ describe('replay preserves snapshot age', () => { * take the `freshConnect` path — the cache would never warm again. */ it('restamps cachedAt once a replayed handle genuinely re-lists', async () => { - const { InMemoryMCPCacheStore } = await import('../../src/cache/cache-store.js'); + const { InMemoryMCPCacheStore } = await import('../../../src/mcp/cache/cache-store.js'); const store = new InMemoryMCPCacheStore(); const snap = snapshotWithHeaders(); const originalCachedAt = snap.cachedAt - 60_000; @@ -1193,8 +1193,8 @@ describe('createMCPTools cache-hit option forwarding', () => { }); it('forwards client-configured loopKeys into the rehydrated handle', async () => { - const { createMCPTools } = await import('../../src/create-mcp-tools.js'); - const { InMemoryMCPCacheStore } = await import('../../src/cache/cache-store.js'); + const { createMCPTools } = await import('../../../src/mcp/create-mcp-tools.js'); + const { InMemoryMCPCacheStore } = await import('../../../src/mcp/cache/cache-store.js'); const store = new InMemoryMCPCacheStore(); store.set('warm', snapshotWithHeaders()); diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 344c0b7c..966c5303 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "esm", + "rootDir": "src", // Resolve deps via published exports, not the repo-wide "source" condition: // the optional peer "@modelcontextprotocol/sdk" (used by src/mcp) transitively // exposes a "source" condition pointing at raw .ts files (eventsource). diff --git a/packages/mcp/package.json b/packages/mcp/package.json index dc759dba..137d2616 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -70,7 +70,6 @@ }, "dependencies": { "@modelcontextprotocol/client": "^2.0.0", - "@openrouter/agent": "workspace:*", - "zod": "^4.0.0" + "@openrouter/agent": "workspace:*" } } diff --git a/packages/mcp/tests/unit/call-tool-shape.test.ts b/packages/mcp/tests/unit/call-tool-shape.test.ts deleted file mode 100644 index fe6a74d4..00000000 --- a/packages/mcp/tests/unit/call-tool-shape.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Client } from '@modelcontextprotocol/client'; -import { describe, expect, it } from 'vitest'; -import { wrapMcpTool } from '../../src/tool-wrapper.js'; - -// Regression guard for the SDK v2 `callTool` signature change. -// -// v1 was `callTool(params, resultSchema, options)`; v2 is -// `callTool(params, options)`. The failure mode is silent rather than loud: if -// the old three-argument form survives, `signal` and `onprogress` land in a -// third parameter the SDK does not read, so cancellation and progress -// streaming stop working while every other test still passes. These tests -// assert the options object arrives in the SECOND argument. - -interface RecordedCall { - args: unknown[]; -} - -function fakeClient(recorded: RecordedCall[]): Client { - return { - callTool: (...args: unknown[]) => { - recorded.push({ - args, - }); - return Promise.resolve({ - content: [ - { - type: 'text', - text: 'ok', - }, - ], - }); - }, - } as never; -} - -/** - * The wrapped tool is an OpenRouter tool envelope — the callable lives at - * `.function.execute`, not on the object itself. - */ -function asExecute(t: unknown): (args: Record) => never { - return ( - t as { - function: { - execute: (args: Record) => never; - }; - } - ).function.execute; -} - -const DEF = { - name: 'do_thing', - inputSchema: { - type: 'object', - properties: {}, - }, -}; - -describe('callTool argument shape', () => { - it('passes exactly two arguments — params then options', async () => { - const recorded: RecordedCall[] = []; - const t = wrapMcpTool(DEF, { - client: fakeClient(recorded), - emitProgress: false, - }); - - await asExecute(t)({}); - - expect(recorded).toHaveLength(1); - expect(recorded[0]?.args).toHaveLength(2); - expect(recorded[0]?.args[0]).toEqual({ - name: 'do_thing', - arguments: {}, - }); - }); - - it('threads the abort signal into the second argument', async () => { - const recorded: RecordedCall[] = []; - const controller = new AbortController(); - const t = wrapMcpTool(DEF, { - client: fakeClient(recorded), - emitProgress: false, - signal: controller.signal, - }); - - await asExecute(t)({}); - - const options = recorded[0]?.args[1] as - | { - signal?: AbortSignal; - } - | undefined; - expect(options?.signal).toBe(controller.signal); - }); - - it('threads onprogress into the second argument for generator tools', async () => { - const recorded: RecordedCall[] = []; - const t = wrapMcpTool(DEF, { - client: fakeClient(recorded), - emitProgress: true, - }); - - // Drain the generator so the underlying callTool actually runs. - const gen = asExecute(t)({}) as AsyncGenerator; - while (!(await gen.next()).done) { - // discard progress events - } - - const options = recorded[0]?.args[1] as - | { - onprogress?: unknown; - } - | undefined; - expect(typeof options?.onprogress).toBe('function'); - }); -}); diff --git a/packages/mcp/tests/unit/mcp-connection.test.ts b/packages/mcp/tests/unit/mcp-connection.test.ts deleted file mode 100644 index e71627cf..00000000 --- a/packages/mcp/tests/unit/mcp-connection.test.ts +++ /dev/null @@ -1,1251 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MCPConnectionError } from '../../src/errors.js'; - -// These tests exercise the real `connect()` — the transport selection and the -// Streamable HTTP -> SSE fallback — by faking the SDK's transports and client -// rather than mocking `mcp-connection.js` itself (which every other unit test -// does, leaving this path uncovered). - -interface Attempt { - kind: 'streamableHttp' | 'sse'; - sessionId?: string; -} - -interface SdkState { - attempts: Attempt[]; - /** Transport kinds whose `start()` should reject. */ - failing: Set<'streamableHttp' | 'sse'>; - /** - * When true, a connect rejects only while `versionNegotiation.mode` is - * `'auto'` — the probe-hostile gateway the legacy degradation exists for. - * Under `'legacy'` the same server connects fine. - */ - probeHostile: boolean; - /** When true, the fake Client's `close()` throws synchronously. */ - closeThrows: boolean; - /** When true, every connect rejects with the SDK's `UnauthorizedError`. */ - authFailure: boolean; - /** When set, only that transport rejects with `UnauthorizedError`. */ - authFailOn: 'streamableHttp' | 'sse' | undefined; - /** - * When set, every connect rejects with an error carrying this `status` — the - * shape the SDK's probe uses for HTTP failures instead of `UnauthorizedError`. - */ - httpErrorStatus: number | undefined; - /** When set, every connect rejects with exactly this error. */ - connectError: unknown; - /** sessionId the fake Streamable HTTP transport reports after connecting. */ - httpSessionId: string | undefined; - clientsCreated: number; - /** `versionNegotiation.mode` seen by each constructed Client, in order. */ - negotiationModes: unknown[]; - /** `versionNegotiation.probe.timeoutMs` seen by each Client, in order. */ - probeTimeouts: unknown[]; - /** `clientInfo` seen by each constructed Client, in order. */ - clientInfos: unknown[]; - /** `signal` passed to each `client.connect`, in order (undefined when none). */ - connectSignals: (AbortSignal | undefined)[]; - /** - * Transport kinds whose Client had `close()` called on it. Guards the - * release of a client whose `connect()` rejected — the SDK does not close a - * transport whose `start()` threw, so `connect()` has to. - */ - closedClients: ('streamableHttp' | 'sse' | 'unattached')[]; -} - -const state: SdkState = { - attempts: [], - failing: new Set(), - probeHostile: false, - closeThrows: false, - authFailure: false, - authFailOn: undefined, - httpErrorStatus: undefined, - connectError: undefined, - httpSessionId: undefined, - clientsCreated: 0, - negotiationModes: [], - probeTimeouts: [], - clientInfos: [], - connectSignals: [], - closedClients: [], -}; - -/** Explicit marker so the fake client can tell the two transports apart. */ -const KIND = Symbol('transport-kind'); - -interface Marked { - [KIND]: 'streamableHttp' | 'sse'; - sessionId: string | undefined; -} - -// SDK v2 ships Client and both transports from one package, so the three -// separate v1 module mocks collapse into this single factory. -// The real one is brand-based rather than prototype-based; a plain Error -// subclass is enough for the `cause`-chain walk under test, and keeps the fake -// self-contained. Safe to declare after `vi.mock` despite vitest hoisting that -// call, because the factory body only runs on first import of the mocked module. -class FakeUnauthorizedError extends Error {} - -vi.mock('@modelcontextprotocol/client', () => ({ - UnauthorizedError: FakeUnauthorizedError, - StreamableHTTPClientTransport: class { - [KIND] = 'streamableHttp' as const; - sessionId: string | undefined; - constructor( - _url: URL, - opts?: { - sessionId?: string; - }, - ) { - this.sessionId = opts?.sessionId; - } - start(): Promise { - return Promise.resolve(); - } - send(): Promise { - return Promise.resolve(); - } - close(): Promise { - return Promise.resolve(); - } - }, - SSEClientTransport: class { - [KIND] = 'sse' as const; - sessionId: string | undefined = undefined; - start(): Promise { - return Promise.resolve(); - } - send(): Promise { - return Promise.resolve(); - } - close(): Promise { - return Promise.resolve(); - } - }, - Client: class { - constructor( - info: unknown, - opts?: { - versionNegotiation?: { - mode?: unknown; - probe?: { - timeoutMs?: unknown; - }; - }; - }, - ) { - state.clientsCreated += 1; - state.negotiationModes.push(opts?.versionNegotiation?.mode); - state.probeTimeouts.push(opts?.versionNegotiation?.probe?.timeoutMs); - state.clientInfos.push(info); - this.mode = opts?.versionNegotiation?.mode; - } - // v2 registration is method-name-first; the fakes accept and ignore both args. - setRequestHandler(_method: string, _handler: unknown): void {} - setNotificationHandler(_method: string, _handler: unknown): void {} - /** This client's `versionNegotiation.mode`, for probe-hostile simulation. */ - mode: unknown; - /** Set by `connect()` so `close()` can report which transport it released. */ - attached: 'streamableHttp' | 'sse' | undefined; - connect( - transport: Marked, - requestOptions?: { - signal?: AbortSignal; - }, - ): Promise { - state.connectSignals.push(requestOptions?.signal); - const kind = transport[KIND]; - this.attached = kind; - const attempt: Attempt = { - kind, - }; - if (transport.sessionId !== undefined) { - attempt.sessionId = transport.sessionId; - } - state.attempts.push(attempt); - if (state.authFailure || state.authFailOn === kind) { - return Promise.reject(new FakeUnauthorizedError('unauthorized')); - } - if (state.connectError !== undefined) { - return Promise.reject(state.connectError); - } - if (state.httpErrorStatus !== undefined) { - return Promise.reject( - Object.assign(new Error(`http ${state.httpErrorStatus}`), { - status: state.httpErrorStatus, - }), - ); - } - if (state.failing.has(kind)) { - return Promise.reject(new Error(`${kind} refused`)); - } - // A probe-hostile server breaks any mode that probes. `'auto'` probes, and - // so does `{ pin }` (it demands a specific revision via `server/discover`); - // only `'legacy'` skips it and uses the classic `initialize` handshake. - if (state.probeHostile && this.mode !== 'legacy') { - return Promise.reject(new Error('server/discover probe timed out')); - } - if (kind === 'streamableHttp' && state.httpSessionId !== undefined) { - transport.sessionId = state.httpSessionId; - } - return Promise.resolve(); - } - close(): Promise { - state.closedClients.push(this.attached ?? 'unattached'); - if (state.closeThrows) { - // Synchronous throw, not a rejected promise — the case a bare - // `.catch()` on the call would fail to intercept. - throw new Error('close exploded'); - } - return Promise.resolve(); - } - }, -})); - -const { connect } = await import('../../src/mcp-connection.js'); - -const URL_UNDER_TEST = new URL('https://example.invalid/mcp'); - -// Minimal OAuth auth: the 401/403 status check only counts when a provider is -// configured, since that is the one auth kind where a retry replays a side -// effect. The provider itself is never invoked by the fake transports. -const OAUTH_AUTH = { - kind: 'oauth', - provider: {} as never, -} as const; - -beforeEach(() => { - state.attempts = []; - state.failing = new Set(); - state.probeHostile = false; - state.closeThrows = false; - state.authFailure = false; - state.authFailOn = undefined; - state.httpErrorStatus = undefined; - state.connectError = undefined; - state.httpSessionId = undefined; - state.clientsCreated = 0; - state.negotiationModes = []; - state.probeTimeouts = []; - state.clientInfos = []; - state.connectSignals = []; - state.closedClients = []; -}); - -describe('connect transport selection', () => { - it('defaults to Streamable HTTP and does not touch SSE when it succeeds', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(conn.transport).toBe('streamableHttp'); - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - ]); - await conn.close(); - }); - - it('uses SSE directly when pinned, without trying Streamable HTTP first', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - transport: 'sse', - }); - - expect(conn.transport).toBe('sse'); - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'sse', - ]); - await conn.close(); - }); - - it('falls back to SSE on a fresh client when Streamable HTTP fails and no transport is pinned', async () => { - state.failing.add('streamableHttp'); - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(conn.transport).toBe('sse'); - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - 'sse', - ]); - // A fresh Client is built for the fallback: the failed one may be - // half-initialized, so reusing it would be unsound. - expect(state.clientsCreated).toBe(2); - await conn.close(); - }); - - it('does not fall back when Streamable HTTP was pinned explicitly', async () => { - state.failing.add('streamableHttp'); - - await expect( - connect({ - url: URL_UNDER_TEST, - transport: 'streamableHttp', - // Explicit, so the legacy-degradation retry stays out of the way: this - // test is about transport selection, not negotiation. - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(MCPConnectionError); - - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - ]); - }); - - it('throws MCPConnectionError naming both transports when both fail', async () => { - state.failing.add('streamableHttp'); - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(/Streamable HTTP and SSE/); - - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - 'sse', - ]); - }); - - /** - * Pinned SSE wraps like every other path. - * - * It used to rethrow the raw transport error, which made the type a caller sees - * depend on an unrelated option: with `protocolNegotiation` unset the outer - * retry aggregated it into an `MCPConnectionError`, with it set the raw error - * escaped. Same server, same failure, different `catch`. - */ - it('wraps a pinned SSE failure in MCPConnectionError', async () => { - state.failing.add('sse'); - - const err = await connect({ - url: URL_UNDER_TEST, - transport: 'sse', - protocolNegotiation: 'auto', - }).catch((e: unknown) => e); - - expect(err).toBeInstanceOf(MCPConnectionError); - expect((err as Error).cause).toBeInstanceOf(Error); - expect(((err as Error).cause as Error).message).toMatch(/sse refused/); - }); - - it('propagates the SSE failure when SSE is pinned and fails', async () => { - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - transport: 'sse', - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(); - - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'sse', - ]); - }); -}); - -/** - * A client whose `connect()` rejected still holds its transport: the SDK stores - * the transport before calling `start()`, and when `start()` itself throws it - * returns without teardown — so nothing closes the socket. `connect()` releases - * it explicitly on every failure path. Without that, a probe timeout against a - * strict gateway leaks a keep-alive connection, and the `'auto'` default makes - * that the expected failure mode rather than a rare one. - */ -describe('connect releases failed clients', () => { - it('closes the failed Streamable HTTP client before falling back to SSE', async () => { - state.failing.add('streamableHttp'); - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(conn.transport).toBe('sse'); - // The failed HTTP client is released; the successful SSE one is left open - // for the caller, who closes it through the returned connection. - expect(state.closedClients).toEqual([ - 'streamableHttp', - ]); - await conn.close(); - }); - - it('closes the failed client when Streamable HTTP was pinned', async () => { - state.failing.add('streamableHttp'); - - await expect( - connect({ - url: URL_UNDER_TEST, - transport: 'streamableHttp', - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(MCPConnectionError); - - expect(state.closedClients).toEqual([ - 'streamableHttp', - ]); - }); - - it('closes both clients when Streamable HTTP and SSE fall through', async () => { - state.failing.add('streamableHttp'); - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(/Streamable HTTP and SSE/); - - expect(state.closedClients).toEqual([ - 'streamableHttp', - 'sse', - ]); - }); - - it('closes the failed client when SSE was pinned', async () => { - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - transport: 'sse', - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(); - - expect(state.closedClients).toEqual([ - 'sse', - ]); - }); - - /** - * The release must never become the error the caller sees. A `close()` that - * throws synchronously produces no rejected promise, so a bare - * `.catch(() => {})` on the call would not intercept it — the teardown failure - * would escape and replace the useful "couldn't reach the server" diagnosis - * with a misleading one, on the path where the diagnosis matters most. - */ - it('surfaces the connect error even when close() throws synchronously', async () => { - state.failing.add('streamableHttp'); - state.closeThrows = true; - - await expect( - connect({ - url: URL_UNDER_TEST, - transport: 'streamableHttp', - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(MCPConnectionError); - - // The close was attempted; its explosion was swallowed. - expect(state.closedClients).toEqual([ - 'streamableHttp', - ]); - }); - - it('still falls back to SSE when the failed client close() throws', async () => { - state.failing.add('streamableHttp'); - state.closeThrows = true; - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - // A teardown failure on the HTTP client must not prevent the fallback. - expect(conn.transport).toBe('sse'); - expect(state.closedClients).toEqual([ - 'streamableHttp', - ]); - // Not closing `conn` here: the flag would make the caller-facing close throw - // too, which is that method's contract (it does not swallow) and not what - // this test is about. - }); - - it('does not close the client on a successful connect', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(state.closedClients).toEqual([]); - await conn.close(); - }); -}); - -describe('connect session handling', () => { - it('surfaces the session id reported by the Streamable HTTP transport', async () => { - state.httpSessionId = 'session-from-server'; - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(conn.sessionId).toBe('session-from-server'); - await conn.close(); - }); - - it('replays a caller-supplied session id onto the transport', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - sessionId: 'resumed-session', - }); - - expect(state.attempts[0]?.sessionId).toBe('resumed-session'); - await conn.close(); - }); - - it('leaves sessionId absent for SSE, which has no protocol-level session', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - transport: 'sse', - }); - - expect(conn.sessionId).toBeUndefined(); - await conn.close(); - }); -}); - -describe('protocol negotiation', () => { - it("defaults to 'auto' so both protocol revisions work without configuration", async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(state.negotiationModes).toEqual([ - 'auto', - ]); - await conn.close(); - }); - - it("honours an explicit 'legacy' policy", async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - protocolNegotiation: 'legacy', - }); - - expect(state.negotiationModes).toEqual([ - 'legacy', - ]); - await conn.close(); - }); - - it('passes a pinned revision through untouched', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - protocolNegotiation: { - pin: '2026-07-28', - }, - }); - - expect(state.negotiationModes).toEqual([ - { - pin: '2026-07-28', - }, - ]); - await conn.close(); - }); - - it('applies the policy to the SSE fallback client too', async () => { - state.failing.add('streamableHttp'); - - const conn = await connect({ - url: URL_UNDER_TEST, - protocolNegotiation: 'legacy', - }); - - // Both the failed Streamable HTTP client and the fresh SSE one. - expect(state.negotiationModes).toEqual([ - 'legacy', - 'legacy', - ]); - await conn.close(); - }); -}); - -/** - * `clientInfo` is what every MCP server sees us as. The version half is - * generated from package.json and guarded by version.test.ts, but nothing - * asserted that the constant actually reaches the SDK constructor — so a - * refactor could drop it, or send a hardcoded value, with that guard still - * green. These close the loop between the generated constant and the wire. - */ -describe('connect clientInfo', () => { - it('self-reports the package name and generated version by default', async () => { - const { PACKAGE_VERSION } = await import('../../src/version.js'); - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(state.clientInfos).toEqual([ - { - name: '@openrouter/mcp', - version: PACKAGE_VERSION, - }, - ]); - await conn.close(); - }); - - it('lets an explicit clientInfo override the default', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - clientInfo: { - name: 'my-app', - version: '9.9.9', - }, - }); - - expect(state.clientInfos).toEqual([ - { - name: 'my-app', - version: '9.9.9', - }, - ]); - await conn.close(); - }); - - it('carries the same clientInfo onto the SSE fallback client', async () => { - state.failing.add('streamableHttp'); - const { PACKAGE_VERSION } = await import('../../src/version.js'); - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - // Both clients identify identically: a server that sees the fallback must - // not see a different client than the one that just probed it. - expect(state.clientInfos).toEqual([ - { - name: '@openrouter/mcp', - version: PACKAGE_VERSION, - }, - { - name: '@openrouter/mcp', - version: PACKAGE_VERSION, - }, - ]); - await conn.close(); - }); -}); - -/** - * `'auto'` degrades to the 2025-era handshake rather than failing. - * - * We default `protocolNegotiation` to `'auto'` where the SDK defaults to - * `'legacy'`, so every connection's first request is a `server/discover` probe. - * Alone that is a connectivity regression: a proxy, WAF, or strict gateway that - * hangs or 5xx's on an unknown method takes a working server to failing, and the - * SSE fallback re-probes and fails identically — so the two-transport fallback - * collapses to a single point of failure against exactly the infrastructure it - * should rescue. - * - * Retrying once with `'legacy'` makes `'auto'` strictly additive: modern servers - * get the new revision, everything else lands where it did before this package - * probed at all. - * - * The `probeHostile` fake models the real case precisely — it rejects only while - * `mode === 'auto'`, so a test that passes here would fail if the retry did not - * actually switch modes. - */ -describe('legacy degradation under an implicit auto default', () => { - it('retries with legacy and connects against a probe-hostile server', async () => { - state.probeHostile = true; - - // No `protocolNegotiation` — the implicit default, which is what a consumer - // who never configured negotiation gets. - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(conn.transport).toBe('streamableHttp'); - // Modes in order: the 'auto' attempt, its SSE re-probe, then the legacy retry. - expect(state.negotiationModes).toEqual([ - 'auto', - 'auto', - 'legacy', - ]); - }); - - it('does not retry when the first attempt succeeds', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(state.negotiationModes).toEqual([ - 'auto', - ]); - await conn.close(); - }); - - it('honours an explicit auto without degrading', async () => { - state.probeHostile = true; - - await expect( - connect({ - url: URL_UNDER_TEST, - protocolNegotiation: 'auto', - }), - // Asking for a mode means asking for its failures too. - ).rejects.toThrow(MCPConnectionError); - - expect(state.negotiationModes).not.toContain('legacy'); - }); - - it('honours an explicit pin without degrading', async () => { - state.probeHostile = true; - - await expect( - connect({ - url: URL_UNDER_TEST, - protocolNegotiation: { - pin: '2026-07-28', - }, - }), - // Silently falling back would defeat the entire point of pinning. - ).rejects.toThrow(MCPConnectionError); - - expect(state.negotiationModes).not.toContain('legacy'); - }); - - it('degrades on a pinned SSE transport too', async () => { - state.probeHostile = true; - - // Someone who pinned SSE did so because they have a legacy server — the most - // likely person to sit behind probe-hostile infrastructure, and the least - // likely to expect a probe. - const conn = await connect({ - url: URL_UNDER_TEST, - transport: 'sse', - }); - - expect(conn.transport).toBe('sse'); - expect(state.negotiationModes).toEqual([ - 'auto', - 'legacy', - ]); - await conn.close(); - }); - - /** - * The case the whole mechanism exists for, and the one an earlier revision of - * it broke: a legacy server reachable **only** over SSE, behind infrastructure - * that chokes on the probe. - * - * Under `'auto'` both transports fail — HTTP because the server doesn't speak - * it, SSE because the probe is refused. The retry therefore has to re-walk the - * ladder; pinning it to Streamable HTTP (which I did briefly, to cap the - * attempt count) means SSE is never offered again and a server that connected - * before this PR stops connecting. - */ - it('reaches an SSE-only server whose probe is refused', async () => { - state.probeHostile = true; - state.failing.add('streamableHttp'); - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - expect(conn.transport).toBe('sse'); - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - 'sse', - 'streamableHttp', - 'sse', - ]); - }); - - /** - * The cost of that guarantee: a genuinely dead server is dialled four times, - * two per negotiation mode. Asserted so the number is a decision rather than an - * accident — the bound that matters is that it is a fixed multiple, not a retry - * loop. - */ - it('caps a dead server at four attempts — two per mode', async () => { - // Not probe-hostile — the transport itself refuses, so the retry fails too. - state.failing.add('streamableHttp'); - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(MCPConnectionError); - - expect(state.negotiationModes).toEqual([ - 'auto', - 'auto', - 'legacy', - 'legacy', - ]); - }); - - it('releases every failed client across both attempts', async () => { - state.failing.add('streamableHttp'); - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(MCPConnectionError); - - // Four clients built, four released — the retry must not leak the transports - // of the attempt that preceded it. - expect(state.clientsCreated).toBe(4); - expect(state.closedClients).toHaveLength(4); - }); - - /** - * A hanging gateway must not cost four full request timeouts. - * - * The SDK falls back to the whole request timeout (60s) for the probe when - * `probe.timeoutMs` is unset. Under `'auto'` the probe is the first request of - * every connection, and with the legacy retry re-walking the ladder that is up - * to four attempts — roughly four minutes before `createMCPTools()` rejects, on - * the path a caller gets with no configuration at all. - */ - it('bounds the probe below the SDK request timeout', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - }); - - // Half the SDK's 60s default rather than a tight cap: a probe timeout is not - // recoverable (HTTP treats it as an outage, and the legacy retry sends an - // `initialize` that 2026-07-28 removed), so a value tight enough to trip a - // cold start would make a modern-only server unreachable rather than slow. - expect(state.probeTimeouts).toEqual([ - 30_000, - ]); - await conn.close(); - }); - - /** - * The default is stated in four places — the constant, two JSDoc sites, the - * README, and the changeset — and it has already drifted once: it moved 5s → 30s - * while three JSDoc comments kept saying 5000. This pins the source files - * against the constant so the next change to it fails here rather than shipping - * hover text that is six times wrong. - */ - it('documents the same probe default that the code applies', async () => { - const { readFileSync } = await import('node:fs'); - const { dirname, join } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src'); - - const conn = await connect({ - url: URL_UNDER_TEST, - }); - const applied = state.probeTimeouts[0]; - await conn.close(); - - expect(typeof applied).toBe('number'); - for (const file of [ - 'types.ts', - 'rehydrate.ts', - 'mcp-connection.ts', - ]) { - const text = readFileSync(join(srcDir, file), 'utf8'); - // Every number stated near a probe-default sentence must be the value the - // code actually passes. The pattern tolerates a symbol name and parens - // between "efaults to" and the digits — `mcp-connection.ts` writes - // "Defaults to `DEFAULT_PROBE_TIMEOUT_MS` (30000)". - const matches = [ - ...text.matchAll(/probe[\s\S]{0,120}?efaults to[^\d]{0,80}(\d[\d_]*)/gi), - ]; - // Vacuous passes defeat the point: each of these files documents the - // default, so zero matches means the regex has drifted from the prose, not - // that the file went silent. - expect(matches.length, `no probe-default sentence matched in ${file}`).toBeGreaterThan(0); - for (const match of matches) { - expect(Number(match[1]?.replaceAll('_', '')), `stale probe default in ${file}`).toBe( - applied, - ); - } - } - }); - - it('lets a caller raise the probe timeout for a slow server', async () => { - const conn = await connect({ - url: URL_UNDER_TEST, - probeTimeoutMs: 30_000, - }); - - expect(state.probeTimeouts).toEqual([ - 30_000, - ]); - await conn.close(); - }); - - /** - * `errors` entries must be real failures, not wrappers. - * - * A single-transport pass wraps its one failure with only `cause` set, so - * without unwrapping that case the aggregated list becomes two opaque - * `MCPConnectionError`s — and a caller scanning for a rejected token would have - * to dig through `cause` on some entries but not others. - */ - it('unwraps single-transport passes so errors holds real failures', async () => { - // Pinned Streamable HTTP: each pass wraps its single failure in an - // `MCPConnectionError` whose own `errors` is empty, so this is the case where - // the aggregated list would otherwise be two opaque wrappers. (Pinned SSE - // rethrows the raw error, so it never had the problem.) - state.failing.add('streamableHttp'); - - const err = await connect({ - url: URL_UNDER_TEST, - transport: 'streamableHttp', - }).catch((e: unknown) => e); - - expect(err).toBeInstanceOf(MCPConnectionError); - const { errors } = err as MCPConnectionError; - // One attempt per pass, both unwrapped to the underlying transport error. - expect(errors).toHaveLength(2); - for (const nested of errors) { - expect(nested).not.toBeInstanceOf(MCPConnectionError); - expect((nested as Error).message).toMatch(/streamableHttp refused/); - } - }); - - /** - * Node's happy-eyeballs path and some fetch implementations report a 401 as an - * `AggregateError` member rather than as a `cause`. A spine-only walk misses it - * and re-drives the OAuth flow. - */ - it('finds an auth failure inside an AggregateError', async () => { - state.connectError = new AggregateError( - [ - new Error('ipv6 refused'), - Object.assign(new Error('http 401'), { - status: 401, - }), - ], - 'all addresses failed', - ); - - await expect( - connect({ - url: URL_UNDER_TEST, - auth: OAUTH_AUTH, - }), - ).rejects.toThrow(); - - expect(state.negotiationModes).not.toContain('legacy'); - }); - - /** - * `errors` has to span both negotiation passes, not just the last. - * - * `MCPConnectionError.errors` documents itself as every failure in attempt - * order. If the retry's rejection propagated untouched, the `'auto'` pass's - * failures would vanish — half the attempts, plus any auth-shaped rejection - * `isAuthFailure` didn't match — and someone debugging an unreachable server - * would be reading a partial record while the docs promised a complete one. - */ - /** - * `errors` is never empty — a single-attempt failure carries its own cause. - * - * The auth short-circuit rethrows the first pass's error untouched, and for a - * single-transport pass that error used to have `errors: []` while the docs - * promised "every underlying failure". A caller iterating `errors` alone saw - * nothing precisely in the auth case, where the rejection is the one thing - * worth finding. - */ - it('populates errors even on the single-attempt auth short-circuit', async () => { - state.authFailure = true; - - const err = await connect({ - url: URL_UNDER_TEST, - }).catch((e: unknown) => e); - - expect(err).toBeInstanceOf(MCPConnectionError); - const { errors } = err as MCPConnectionError; - expect(errors).toHaveLength(1); - expect(errors[0]).toBeInstanceOf(FakeUnauthorizedError); - }); - - it('reports every attempt across both negotiation passes', async () => { - state.failing.add('streamableHttp'); - state.failing.add('sse'); - - const err = await connect({ - url: URL_UNDER_TEST, - }).catch((e: unknown) => e); - - expect(err).toBeInstanceOf(MCPConnectionError); - // Four attempts: two transports × two negotiation modes, flat rather than a - // tree of wrappers so a caller can iterate without recursing. - expect((err as MCPConnectionError).errors).toHaveLength(4); - for (const nested of (err as MCPConnectionError).errors) { - expect(nested).not.toBeInstanceOf(MCPConnectionError); - } - // `cause` still points at the last thing tried. - expect((err as MCPConnectionError).cause).toBeInstanceOf(Error); - }); - - /** - * An auth rejection does not always arrive as `UnauthorizedError`. - * - * The version-negotiation probe doesn't route 401/403 through the OAuth flow — - * `classifyHttpError` turns them into an `SdkHttpError` with - * `ClientHttpAuthentication` / `ClientHttpForbidden`. So a probe rejected for - * auth reasons is a different type entirely, and a guard keyed only on - * `UnauthorizedError` would retry it and re-drive the flow. - */ - it('suppresses the retry for a 401 status under OAuth', async () => { - state.httpErrorStatus = 401; - - await expect( - connect({ - url: URL_UNDER_TEST, - auth: OAUTH_AUTH, - }), - ).rejects.toThrow(); - - expect(state.negotiationModes).not.toContain('legacy'); - }); - - /** - * A 403 degrades even under OAuth. The SDK's PKCE side effects - * (`saveCodeVerifier`, `redirectToAuthorization`) live exclusively behind its - * `status === 401 && authProvider` branch — a 403 never enters the OAuth flow, - * so a retry after one re-drives nothing. Suppressing on 403 made OAuth - * deployments behind WAFs that 403 unknown methods permanently unreachable — - * the exact scenario the legacy retry exists to rescue. - */ - it('still degrades on a 403 under OAuth', async () => { - state.httpErrorStatus = 403; - - await expect( - connect({ - url: URL_UNDER_TEST, - auth: OAUTH_AUTH, - }), - ).rejects.toThrow(); - - expect(state.negotiationModes).toContain('legacy'); - }); - - /** - * Without OAuth, a 401/403 must NOT suppress the degradation. - * - * Proxies and WAFs commonly answer an unknown method like `server/discover` - * with 403 — the probe-hostile infrastructure the legacy retry exists to - * rescue. With bearer, headers, or no auth there is no side effect to replay, - * so suppressing there turns the retry's own target scenario into a hard - * failure: the guard would cancel the recovery precisely where it was needed. - */ - it('still degrades on a 403 when no OAuth provider is configured', async () => { - state.httpErrorStatus = 403; - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(); - - // The retry ran: the gateway rejects every mode in this fake, but the - // degradation was attempted rather than suppressed. - expect(state.negotiationModes).toContain('legacy'); - }); - - it('still degrades on a 403 under bearer auth', async () => { - state.httpErrorStatus = 403; - - await expect( - connect({ - url: URL_UNDER_TEST, - auth: { - kind: 'bearer', - token: 'tok', - }, - }), - ).rejects.toThrow(); - - expect(state.negotiationModes).toContain('legacy'); - }); - - /** - * A non-Error payload carrying `status: 401` must not suppress the retry. - * - * The guard is duck-typed on `status`, which is the stable half of the SDK's - * contract — but a plain object with that field is far more likely to be a - * response or a log record riding along in a `cause` than an authoritative - * rejection (the SDK builds log entries with `status: 0`). Treating one as a - * credential failure would silently suppress the retry and make a - * probe-hostile-but-authenticated server unreachable — the regression the - * retry exists to prevent. - */ - it('ignores a status on a non-Error payload', async () => { - state.connectError = Object.assign(new Error('gateway barfed'), { - cause: { - status: 401, - note: 'upstream response, not our rejection', - }, - }); - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(); - - expect(state.negotiationModes).toContain('legacy'); - }); - - it('still retries a non-auth HTTP status', async () => { - // 404 is the SSE-endpoint-doesn't-exist case, not a credentials problem — - // guards against the status check over-matching and disabling degradation. - state.httpErrorStatus = 404; - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(); - - expect(state.negotiationModes).toContain('legacy'); - }); - - /** - * The auth guard has to see *both* transport attempts, not just the last. - * - * When Streamable HTTP 401s (OAuth flow driven once) and the SSE fallback then - * fails for an unrelated reason — the same URL answering 404 to an SSE GET, - * which never reaches the auth path — the `cause` spine holds only the SSE - * error. A guard reading `cause` alone would miss the `UnauthorizedError` and - * retry, re-driving `redirectToAuthorization` and overwriting the stored PKCE - * verifier. - */ - it('suppresses the retry when only the HTTP attempt was an auth failure', async () => { - state.authFailOn = 'streamableHttp'; - state.failing.add('sse'); - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(MCPConnectionError); - - // Stops at the HTTP auth failure: no SSE fallback (which would re-drive the - // OAuth flow) and no legacy retry behind it. - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - ]); - expect(state.negotiationModes).not.toContain('legacy'); - }); - - /** - * An auth rejection means the transport reached the server and the credentials - * were refused — nothing a different protocol revision changes. Retrying would - * re-drive an OAuth provider's authorization flow: a second - * `redirectToAuthorization`, a second saved PKCE verifier overwriting the - * first. Replaying a failure that had side effects is worse than not retrying. - */ - /** - * `signal` reaches every SDK connect, and an aborted caller is not retried. - * - * Without threading, a caller with its own deadline had no way to bound the - * ladder — whose worst case on the default path is ~3 minutes across four - * attempts. And retrying after the caller aborted would immediately re-abort - * or outlive the deadline the signal expressed. - */ - it('threads the abort signal into the SDK connect', async () => { - const controller = new AbortController(); - - const conn = await connect({ - url: URL_UNDER_TEST, - signal: controller.signal, - }); - - expect(state.connectSignals).toEqual([ - controller.signal, - ]); - await conn.close(); - }); - - /** - * An abort arriving mid-attempt stops the ladder inside the pass, too — not - * only the legacy retry. The same signal rides into the SSE attempt, but - * whether the SDK interrupts `transport.start()` promptly is its business; - * the explicit check makes "abort means stop dialling" deterministic. - */ - it('does not fall back to SSE when the caller aborted mid-attempt', async () => { - const controller = new AbortController(); - // The HTTP attempt fails *because* the abort landed during it. - state.failing.add('streamableHttp'); - controller.abort(); - - await expect( - connect({ - url: URL_UNDER_TEST, - signal: controller.signal, - // Pinned negotiation isolates the in-pass ladder from the outer retry. - protocolNegotiation: 'auto', - }), - ).rejects.toThrow(MCPConnectionError); - - // One dial: no SSE attempt behind an aborted caller. - expect(state.attempts.map((a) => a.kind)).toEqual([ - 'streamableHttp', - ]); - }); - - it('does not run the legacy retry when the caller aborted', async () => { - const controller = new AbortController(); - state.failing.add('streamableHttp'); - state.failing.add('sse'); - // Simulate the abort arriving during the first pass. - controller.abort(); - - await expect( - connect({ - url: URL_UNDER_TEST, - signal: controller.signal, - }), - ).rejects.toThrow(); - - // Only the 'auto' pass ran; no 'legacy' behind an aborted caller. - expect(state.negotiationModes).not.toContain('legacy'); - }); - - it('does not retry an auth failure', async () => { - state.authFailure = true; - - await expect( - connect({ - url: URL_UNDER_TEST, - }), - ).rejects.toThrow(MCPConnectionError); - - // One attempt only. The ladder short-circuits too: an SSE fallback would - // carry the same `authProvider` into the SDK's auth path and drive a second - // `redirectToAuthorization`, which is the duplicated side effect this guard - // exists to prevent — inside a single pass the caller didn't even opt into. - expect(state.negotiationModes).toEqual([ - 'auto', - ]); - expect(state.attempts).toHaveLength(1); - }); - - it('detects an auth failure nested inside the wrapper error', async () => { - // Guards the `cause`-chain walk specifically: `connectWithNegotiation` wraps - // transport errors in `MCPConnectionError`, so a bare top-level `instanceof` - // check would miss the nested `UnauthorizedError` and retry anyway — - // re-driving the authorization flow these tests exist to prevent. - state.authFailure = true; - - const err = await connect({ - url: URL_UNDER_TEST, - }).catch((e: unknown) => e); - - // Wrapped, not bare — which is exactly why the walk is needed. - expect(err).toBeInstanceOf(MCPConnectionError); - expect((err as Error).cause).toBeInstanceOf(FakeUnauthorizedError); - }); -}); diff --git a/packages/mcp/tests/unit/protocol-era.test.ts b/packages/mcp/tests/unit/protocol-era.test.ts deleted file mode 100644 index fdb7b45a..00000000 --- a/packages/mcp/tests/unit/protocol-era.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import type { Transport } from '@modelcontextprotocol/client'; -import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; -import { describe, expect, it } from 'vitest'; -import type { MCPConnection } from '../../src/mcp-connection.js'; - -// Proves the SDK negotiates BOTH protocol revisions — 2025-11-25 ("legacy", -// `initialize` handshake) and 2026-07-28 ("modern", per-request `_meta` -// envelope with no handshake). Everything here runs over InMemoryTransport, so -// there is no network, no fixture process, and no MCP_TEST_URL gate. -// -// These tests are what protect the assumptions the rest of the package leans -// on: that `getServerVersion()` / `getServerCapabilities()` stay populated in -// the modern era (handle.ts reads both synchronously), that `sessionId` simply -// goes undefined rather than erroring, and that one elicitation handler serves -// both eras. - -type JsonRpc = { - jsonrpc: '2.0'; - id?: string | number; - method?: string; - params?: Record; - result?: unknown; - error?: unknown; -}; - -interface ServerBehavior { - /** Answer `server/discover` as a 2026-07-28 server. */ - modern: boolean; - /** Methods the fake server saw, in order. */ - seen: string[]; - /** When set, `tools/call` demands input once via an input_required result. */ - demandInput?: boolean; - /** `ttlMs` the fake reports on `tools/list`; defaults to 0 (uncacheable). */ - toolsListTtlMs?: number; -} - -/** - * Minimal hand-rolled MCP server over one end of a linked transport pair. - * Responds only to what these tests exercise. - */ -function startFakeServer(serverSide: Transport, behavior: ServerBehavior): void { - let callRound = 0; - - serverSide.onmessage = (raw: unknown) => { - const msg = raw as JsonRpc; - const method = msg.method; - if (method === undefined) { - return; - } - behavior.seen.push(method); - - // Notifications carry no id and expect no reply. - if (msg.id === undefined) { - return; - } - const id = msg.id; - const reply = (result: unknown): void => { - void serverSide.send({ - jsonrpc: '2.0', - id, - result, - } as never); - }; - const replyError = (code: number, message: string): void => { - void serverSide.send({ - jsonrpc: '2.0', - id, - error: { - code, - message, - }, - } as never); - }; - - if (method === 'server/discover') { - if (!behavior.modern) { - // A legacy server has never heard of this method. - replyError(-32601, 'Method not found'); - return; - } - reply({ - resultType: 'complete', - // NOTE: the field is `supportedVersions`, not `protocolVersions`. - supportedVersions: [ - '2026-07-28', - ], - capabilities: { - tools: {}, - resources: {}, - }, - _meta: { - 'io.modelcontextprotocol/serverInfo': { - name: 'fake-modern', - version: '9.9.9', - }, - }, - }); - return; - } - - if (method === 'initialize') { - reply({ - protocolVersion: '2025-11-25', - serverInfo: { - name: 'fake-legacy', - version: '1.2.3', - }, - capabilities: { - tools: {}, - resources: {}, - }, - }); - return; - } - - if (method === 'tools/list') { - reply({ - resultType: 'complete', - // Non-zero for the cache-invalidation test below, which needs the SDK to - // actually cache the response so we can prove the notification evicts it. - ttlMs: behavior.toolsListTtlMs ?? 0, - cacheScope: 'public', - tools: [ - { - name: 'needs_input', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - ], - }); - return; - } - - if (method === 'tools/call') { - callRound += 1; - if (behavior.demandInput === true && callRound === 1) { - reply({ - resultType: 'input_required', - requestState: 'opaque-state-blob', - // `inputRequests` is an object keyed by request id, not an array. - inputRequests: { - r1: { - method: 'elicitation/create', - params: { - message: 'Your name?', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - }, - }, - required: [ - 'name', - ], - }, - }, - }, - }, - }); - return; - } - reply({ - resultType: 'complete', - content: [ - { - type: 'text', - text: 'done', - }, - ], - }); - return; - } - - reply({ - resultType: 'complete', - }); - }; - void serverSide.start(); -} - -interface Harness { - client: Client; - behavior: ServerBehavior; - clientSide: Transport; -} - -async function connectTo(options: { - modern: boolean; - mode: - | 'legacy' - | 'auto' - | { - pin: string; - }; - demandInput?: boolean; - onElicit?: () => { - action: 'accept'; - content: Record; - }; -}): Promise { - const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); - const behavior: ServerBehavior = { - modern: options.modern, - seen: [], - ...(options.demandInput !== undefined && { - demandInput: options.demandInput, - }), - }; - startFakeServer(serverSide, behavior); - - const client = new Client( - { - name: 'era-test', - version: '0.0.0', - }, - { - capabilities: { - elicitation: {}, - }, - versionNegotiation: { - mode: options.mode, - probe: { - timeoutMs: 2000, - }, - }, - }, - ); - if (options.onElicit !== undefined) { - const handler = options.onElicit; - client.setRequestHandler('elicitation/create', () => handler()); - } - await client.connect(clientSide); - return { - client, - behavior, - clientSide, - }; -} - -describe("mode: 'auto' against a legacy (2025-11-25) server", () => { - it('probes server/discover, then falls back to the initialize handshake', async () => { - const { client, behavior } = await connectTo({ - modern: false, - mode: 'auto', - }); - - expect(behavior.seen[0]).toBe('server/discover'); - expect(behavior.seen).toContain('initialize'); - expect(client.getProtocolEra()).toBe('legacy'); - await client.close(); - }); - - it('still reports server identity and capabilities', async () => { - const { client } = await connectTo({ - modern: false, - mode: 'auto', - }); - - expect(client.getServerVersion()?.name).toBe('fake-legacy'); - expect(client.getServerCapabilities()?.resources).toBeDefined(); - await client.close(); - }); -}); - -describe("mode: 'auto' against a modern (2026-07-28) server", () => { - it('never sends initialize — the handshake is removed in this revision', async () => { - const { client, behavior } = await connectTo({ - modern: true, - mode: 'auto', - }); - - expect(behavior.seen[0]).toBe('server/discover'); - expect(behavior.seen).not.toContain('initialize'); - expect(client.getProtocolEra()).toBe('modern'); - await client.close(); - }); - - it('populates serverInfo and capabilities from server/discover', async () => { - // handle.ts reads both of these synchronously; if the modern era left them - // empty, resource tools would silently disappear and snapshots would lose - // their serverInfo. - const { client } = await connectTo({ - modern: true, - mode: 'auto', - }); - - expect(client.getServerVersion()).toEqual({ - name: 'fake-modern', - version: '9.9.9', - }); - expect(client.getServerCapabilities()?.resources).toBeDefined(); - await client.close(); - }); - - it('leaves sessionId undefined — protocol sessions are removed (SEP-2567)', async () => { - const { client, clientSide } = await connectTo({ - modern: true, - mode: 'auto', - }); - - expect(clientSide.sessionId).toBeUndefined(); - await client.close(); - }); - - it('still lists tools', async () => { - const { client } = await connectTo({ - modern: true, - mode: 'auto', - }); - - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toEqual([ - 'needs_input', - ]); - await client.close(); - }); -}); - -describe('multi-round-trip input (SEP-2322)', () => { - it('fulfils input_required through the same elicitation handler and retries', async () => { - // This is what justifies keeping `onElicitation` rather than deprecating - // it: the 2026-07-28 era has no server-initiated elicitation/create, but - // the MRTR driver dispatches through the very same registered handler. - let calls = 0; - const { client, behavior } = await connectTo({ - modern: true, - mode: 'auto', - demandInput: true, - onElicit: () => { - calls += 1; - return { - action: 'accept', - content: { - name: 'Luke', - }, - }; - }, - }); - - const result = await client.callTool({ - name: 'needs_input', - arguments: {}, - }); - - expect(calls).toBe(1); - // Two tools/call round trips: the input_required answer, then the retry. - expect(behavior.seen.filter((m) => m === 'tools/call')).toHaveLength(2); - expect(result.content).toEqual([ - { - type: 'text', - text: 'done', - }, - ]); - await client.close(); - }); -}); - -describe('pinning', () => { - it('fails loudly when a pinned revision is not offered', async () => { - await expect( - connectTo({ - modern: false, - mode: { - pin: '2026-07-28', - }, - }), - ).rejects.toThrow(); - }); - - it('connects in the modern era when the pin is offered', async () => { - const { client } = await connectTo({ - modern: true, - mode: { - pin: '2026-07-28', - }, - }); - - expect(client.getProtocolEra()).toBe('modern'); - await client.close(); - }); -}); - -/** - * The `tools/list_changed` wiring, end to end over a real transport. - * - * `mcp-connection.ts` registers the subscription with a bare string method name - * rather than an SDK schema value. The name itself is compile-checked — the - * parameter is a `NotificationMethod` literal union, so a typo fails `tsc` - * (verified: mutating it to `'notifications/tools/list_changedX'` produces - * TS2345). What the type cannot check is *dispatch*: that an inbound - * notification actually reaches the callback registered via - * `setToolListChangedHandler`. - * - * That gap matters because `autoRefreshOnListChanged` defaults to on, so a - * broken dispatch path means tool-list refreshes silently never happen — the - * same failure shape as the `callTool` arity bug this PR fixes, which was also - * invisible to a green test suite. - * - * These drive the real `makeClient` from `mcp-connection.ts` (via the - * `makeClientForTest` seam), not a locally-built `Client`, so the assertion - * covers our registration rather than a restatement of the SDK's. - */ -describe('tools/list_changed dispatch', () => { - async function connectRealClient( - modern: boolean, - toolsListTtlMs?: number, - ): Promise<{ - client: Client; - serverSide: Transport; - fired: () => number; - seen: string[]; - }> { - const { makeClientForTest } = await import('../../src/mcp-connection.js'); - const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); - const seen: string[] = []; - startFakeServer(serverSide, { - modern, - seen, - ...(toolsListTtlMs !== undefined && { - toolsListTtlMs, - }), - }); - - let count = 0; - const client = makeClientForTest( - { - url: new URL('https://example.invalid/mcp'), - }, - () => { - count += 1; - }, - ); - await client.connect(clientSide); - return { - client, - serverSide, - fired: () => count, - seen, - }; - } - - async function emitListChanged(serverSide: Transport): Promise { - await serverSide.send({ - jsonrpc: '2.0', - method: 'notifications/tools/list_changed', - } as never); - // Notifications are fire-and-forget in both directions; yield so the - // client's handler runs before we assert. - await new Promise((resolve) => setImmediate(resolve)); - } - - it('routes the notification to the registered handler in the modern era', async () => { - const { client, serverSide, fired } = await connectRealClient(true); - - expect(client.getProtocolEra()).toBe('modern'); - expect(fired()).toBe(0); - - await emitListChanged(serverSide); - - expect(fired()).toBe(1); - await client.close(); - }); - - it('routes the notification to the registered handler in the legacy era', async () => { - const { client, serverSide, fired } = await connectRealClient(false); - - expect(client.getProtocolEra()).toBe('legacy'); - - await emitListChanged(serverSide); - - // One handler serves both revisions — the same guarantee the elicitation - // tests above establish for requests. - expect(fired()).toBe(1); - await client.close(); - }); - - /** - * The SDK keeps a per-client response cache (24h ceiling), and `makeClient` - * deliberately opts out of `ClientOptions.listChanged` so the handle's own - * `refresh()` owns the re-list. Devin raised the question that follows: if the - * cache were invalidated by the SDK's `listChanged` machinery — the machinery - * we opt out of — then `refresh()`'s `listTools()` would be served from cache - * and `autoRefreshOnListChanged` would silently do nothing. - * - * It isn't. Eviction lives in the base `_onnotification` dispatcher, keyed off - * the notification method (`notifications/tools/list_changed` → evict - * `tools/list`), so it fires for any inbound notification regardless of how the - * handler was registered. This test is the executable form of that claim, - * because reading the SDK proves it today and a test proves it after the next - * version bump. - * - * `ttlMs` must be non-zero here: with the default 0 the response is - * uncacheable, so a cache bug would be invisible. - */ - it('evicts the SDK response cache so a post-notification re-list hits the wire', async () => { - const { client, serverSide, seen } = await connectRealClient(true, 60_000); - - await client.listTools(); - const afterFirst = seen.filter((m) => m === 'tools/list').length; - expect(afterFirst).toBe(1); - - // Second call with no notification in between: served from the SDK cache. - await client.listTools(); - expect(seen.filter((m) => m === 'tools/list')).toHaveLength(1); - - await emitListChanged(serverSide); - - // Now it must reach the server again — otherwise `refresh()` would return - // the stale tool set and auto-refresh would be a silent no-op. - await client.listTools(); - expect(seen.filter((m) => m === 'tools/list')).toHaveLength(2); - - await client.close(); - }); - - it('fires once per notification', async () => { - const { client, serverSide, fired } = await connectRealClient(true); - - await emitListChanged(serverSide); - await emitListChanged(serverSide); - - expect(fired()).toBe(2); - await client.close(); - }); -}); - -/** - * `listToolDefs` must reach the server every time, even inside the SDK's - * response-cache TTL. - * - * SDK v2 caches `tools/list` per client, honouring the server's `ttlMs` up to a - * 24h ceiling. Under the default `cacheMode: 'use'` that makes - * `MCPToolsHandle.refresh()` a liar — it documents a forced re-read but would - * return the cached list, so an app calling `refresh()` to pick up newly added - * server tools could keep the old set for as long as the server allows reuse. - * A behavior change introduced by the v1 → v2 migration, since v1 had no - * response cache. - * - * The `tools/list_changed` path was already safe (the SDK evicts in its - * notification dispatcher), so this covers the consumer-initiated path that has - * no notification to trigger eviction. - */ -describe('listToolDefs bypasses the SDK response cache', () => { - it('hits the wire on every call despite a live cache entry', async () => { - const { makeClientForTest } = await import('../../src/mcp-connection.js'); - const { listToolDefs } = await import('../../src/handle.js'); - const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); - const seen: string[] = []; - startFakeServer(serverSide, { - modern: true, - seen, - // Long enough that a cached read would definitely be served. - toolsListTtlMs: 60_000, - }); - - const client = makeClientForTest( - { - url: new URL('https://example.invalid/mcp'), - }, - () => {}, - ); - await client.connect(clientSide); - const connection: MCPConnection = { - client, - transport: 'streamableHttp', - setToolListChangedHandler: () => {}, - close: () => client.close(), - }; - - await listToolDefs(connection, undefined); - expect(seen.filter((m) => m === 'tools/list')).toHaveLength(1); - - // The assertion that matters: a second read inside the TTL. A plain - // `client.listTools()` here would still be 1 (proven by the eviction test - // above), so this only passes because `listToolDefs` sends - // `cacheMode: 'refresh'`. - await listToolDefs(connection, undefined); - expect(seen.filter((m) => m === 'tools/list')).toHaveLength(2); - - await listToolDefs(connection, undefined); - expect(seen.filter((m) => m === 'tools/list')).toHaveLength(3); - - await client.close(); - }); -}); - -/** - * The probe timeout on the production client. - * - * `makeClient` sets only `versionNegotiation.mode`, so with `'auto'` now the - * default every connection's first request is a `server/discover` probe governed - * by the SDK's *default* timeout. Devin flagged that no test exercised that - * default — the tests above build their own `Client` with an explicit - * `probe: { timeoutMs: 2000 }`, and `mcp-connection.test.ts` fakes the `Client` - * entirely — so a change to an unbounded default upstream would land silently on - * the critical path of every `createMCPTools()` call. - * - * It is bounded: `negotiateEra` resolves `negotiation.probe.timeoutMs ?? - * deps.defaultTimeoutMs`, which `_connectNegotiated` fills from `options?.timeout - * ?? DEFAULT_REQUEST_TIMEOUT_MSEC` (60s). So a gateway that black-holes - * `server/discover` rejects rather than hanging forever. This pins that. - */ -/** - * The probe block is passed unconditionally — including under `'legacy'`, where - * no `server/discover` is sent and the field should be inert. This pins that the - * real SDK constructor tolerates the combination rather than validating it away: - * both the implicit legacy retry and an explicit `protocolNegotiation: 'legacy'` - * construct exactly this shape. - */ -describe('probe options under legacy mode', () => { - it('connects with a probe block alongside mode legacy', async () => { - const { makeClientForTest } = await import('../../src/mcp-connection.js'); - const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); - startFakeServer(serverSide, { - modern: false, - seen: [], - }); - - const client = makeClientForTest( - { - url: new URL('https://example.invalid/mcp'), - protocolNegotiation: 'legacy', - }, - () => {}, - ); - await expect(client.connect(clientSide)).resolves.toBeUndefined(); - expect(client.getProtocolEra()).toBe('legacy'); - await client.close(); - }); -}); - -describe('probe timeout default', () => { - it('bounds the probe on a client built the way production builds it', async () => { - const { makeClientForTest } = await import('../../src/mcp-connection.js'); - const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); - - // A server that accepts the probe and never answers it — the black-hole - // gateway case. No fake server: nothing is wired to `serverSide.onmessage`. - serverSide.onmessage = () => {}; - - const client = makeClientForTest( - { - url: new URL('https://example.invalid/mcp'), - probeTimeoutMs: 150, - }, - () => {}, - ); - - // The bound is asserted via `probeTimeoutMs` rather than `connect`'s - // `timeout`: this package now sets `probe.timeoutMs` explicitly, which takes - // precedence over the per-request timeout, so passing the latter would leave - // the test waiting out the real 30s default. A short override keeps it fast - // while still proving the probe honours the ceiling instead of hanging. - await expect(client.connect(clientSide)).rejects.toThrow(/timed out|timeout/i); - - await client.close().catch(() => {}); - }); -}); diff --git a/packages/mcp/tests/unit/version.test.ts b/packages/mcp/tests/unit/version.test.ts deleted file mode 100644 index e6df457a..00000000 --- a/packages/mcp/tests/unit/version.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { PACKAGE_VERSION } from '../../src/version.js'; - -// src/version.ts is generated from package.json but committed, because CI's -// lint/typecheck/unit-test jobs run without a build step. This test is what -// makes that safe: a stale generated file fails here rather than silently -// shipping a wrong `clientInfo` version to every MCP server we connect to. - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); - -function packageJsonVersion(): string { - const raw = readFileSync(join(packageRoot, 'package.json'), 'utf8'); - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('package.json did not parse to an object'); - } - const version = (parsed as Record)['version']; - if (typeof version !== 'string') { - throw new Error('package.json has no string "version"'); - } - return version; -} - -describe('PACKAGE_VERSION', () => { - it('matches package.json — regenerate with `pnpm --filter @openrouter/mcp gen:version`', () => { - expect(PACKAGE_VERSION).toBe(packageJsonVersion()); - }); - - it('is a non-empty semver-shaped string', () => { - expect(PACKAGE_VERSION).toMatch(/^\d+\.\d+\.\d+/); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63350042..6c5c086d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,15 +41,16 @@ importers: packages/agent: dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) '@openrouter/sdk': specifier: ^0.13.7 version: 0.13.7 zod: specifier: ^4.0.0 version: 4.3.6 + devDependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 packages/mcp: dependencies: From 5d0f1d51225404952eba4fde740ed4ee44551c89 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:25:05 -0500 Subject: [PATCH 04/14] fix(agent): restore subpath compatibility --- .changeset/agent-mcp-subpath.md | 6 +- packages/agent/README.md | 459 ++++-- packages/agent/package.json | 29 +- packages/agent/scripts/gen-version.mjs | 10 + packages/agent/src/lib/tool-set.ts | 39 +- packages/agent/src/mcp/build-tools.ts | 2 +- packages/agent/src/mcp/create-mcp-tools.ts | 10 +- packages/agent/src/mcp/errors.ts | 6 +- packages/agent/src/mcp/index.ts | 6 +- packages/agent/src/mcp/rehydrate.ts | 4 +- packages/agent/src/mcp/resource-tools.ts | 4 +- packages/agent/src/mcp/tool-wrapper.ts | 4 +- packages/agent/src/mcp/types.ts | 8 +- packages/agent/src/mcp/version.ts | 4 +- .../unit/call-model-active-tools.test.ts | 9 + .../tests/unit/mcp/call-tool-shape.test.ts | 115 ++ .../tests/unit/mcp/create-mcp-tools.test.ts | 240 ++++ .../tests/unit/mcp/mcp-connection.test.ts | 1251 +++++++++++++++++ .../agent/tests/unit/mcp/protocol-era.test.ts | 661 +++++++++ .../agent/tests/unit/mcp/rehydrate.test.ts | 2 +- packages/agent/tests/unit/mcp/version.test.ts | 13 + .../agent/tests/unit/server-tool-id.test-d.ts | 25 +- packages/agent/tsconfig.json | 2 +- packages/agent/tsconfig.typecheck.json | 11 +- packages/mcp/README.md | 6 +- packages/mcp/package.json | 14 +- packages/mcp/src/index.ts | 5 + packages/mcp/tests/unit/cache.test.ts | 2 +- .../mcp/tests/unit/create-mcp-tools.test.ts | 2 +- packages/mcp/tests/unit/index.test.ts | 22 +- packages/mcp/tests/unit/schema.test.ts | 2 +- packages/mcp/vitest.config.ts | 11 +- scripts/verify-package-boundaries.mjs | 50 +- 33 files changed, 2855 insertions(+), 179 deletions(-) create mode 100644 packages/agent/scripts/gen-version.mjs create mode 100644 packages/agent/tests/unit/mcp/call-tool-shape.test.ts create mode 100644 packages/agent/tests/unit/mcp/mcp-connection.test.ts create mode 100644 packages/agent/tests/unit/mcp/protocol-era.test.ts create mode 100644 packages/agent/tests/unit/mcp/version.test.ts diff --git a/.changeset/agent-mcp-subpath.md b/.changeset/agent-mcp-subpath.md index d7bc81a1..60a806ab 100644 --- a/.changeset/agent-mcp-subpath.md +++ b/.changeset/agent-mcp-subpath.md @@ -3,7 +3,7 @@ "@openrouter/mcp": minor --- -Add the full MCP integration under the canonical `@openrouter/agent/mcp` subpath. `@modelcontextprotocol/sdk` is an optional peer, so base agent installations and imports do not install or load MCP support. The existing `@openrouter/mcp` package remains as a compatibility facade and now re-exports the canonical agent subpaths. +Add the full MCP integration under the canonical `@openrouter/agent/mcp` subpath. `@modelcontextprotocol/client` is an optional peer, so base agent installations and imports do not install or load MCP support. The existing `@openrouter/mcp` package remains as a compatibility facade and now re-exports the canonical agent subpaths. ```ts import { callModel, OpenRouter } from '@openrouter/agent'; @@ -17,6 +17,8 @@ const result = callModel(new OpenRouter(), { }); ``` -Install `@modelcontextprotocol/sdk` alongside `@openrouter/agent` when using `/mcp`. The SDK is loaded lazily, so importing the base agent or the MCP entry point does not require the peer; the first MCP connection attempt without it throws an actionable `MCPMissingPeerDependencyError`. +Install `@modelcontextprotocol/client` alongside `@openrouter/agent` when using `/mcp`. The SDK is loaded lazily, so importing the base agent or the MCP entry point does not require the peer; the first MCP connection attempt without it throws an actionable `MCPMissingPeerDependencyError`. Existing `@openrouter/mcp` imports continue to work as tooling-visible deprecated migration facades, but new code should prefer `@openrouter/agent/mcp`. The facade would only be removed in a future breaking release after migration notice. + +The `@openrouter/mcp` facade continues to install `@modelcontextprotocol/client` transitively for backward compatibility; only direct `@openrouter/agent/mcp` users need to add the optional peer explicitly. diff --git a/packages/agent/README.md b/packages/agent/README.md index b7e9b4a6..810bcc56 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -58,28 +58,14 @@ console.log(text); ## Optional subpaths -The base package stays focused: `@openrouter/agent` is marked `sideEffects: false`, -and the root entry does not import either optional integration. - -- `@openrouter/agent/tool-set` adds declarative, state-aware tool activation with - no additional package installation. -- `@openrouter/agent/mcp` adds remote MCP discovery, caching, rehydration, and - tool wrapping. Install its optional peer only when you use this subpath: +- `@openrouter/agent/tool-set` provides declarative tool activation. +- `@openrouter/agent/mcp` provides MCP discovery, caching, and tool wrapping. Install its optional client when using MCP: ```bash -pnpm add @openrouter/agent @modelcontextprotocol/sdk -``` - -```ts -import { createMCPTools } from '@openrouter/agent/mcp'; -import { createToolSet } from '@openrouter/agent/tool-set'; +pnpm add @openrouter/agent @modelcontextprotocol/client ``` -Because the MCP SDK is an optional peer rather than a regular dependency, base -agent consumers do not install its transitive dependencies. The compiled MCP -adapter is included in the agent tarball, but bundlers only reach it through the -explicit `/mcp` exports. `@openrouter/mcp` remains available as a compatibility -facade for existing applications. +Existing `@openrouter/mcp` imports remain supported as compatibility facades. ## Features @@ -93,10 +79,14 @@ const result = callModel(client, { model, input, tools }); // Await the final text const text = await result.getText(); -// Await the full response with usage data +// Await the full response with usage data (the FINAL round only) const response = await result.getResponse(); console.log(response.usage); // { inputTokens, outputTokens, cost, ... } +// Await aggregate usage across EVERY round of the tool loop +const usage = await result.getUsage(); +console.log(usage); // { modelCalls, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens, cost? } + // Stream text deltas for await (const delta of result.getTextStream()) { process.stdout.write(delta); @@ -134,12 +124,47 @@ What each stream emits: | `getReasoningStream()` | reasoning deltas | | `getToolStream()` | tool-call **argument deltas**; `preliminary_result` events for generator tools — *not* execution results | | `getToolCallsStream()` | parsed tool calls as they complete | -| `getItemsStream()` | all output items (messages, function calls, …) | -| `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events | +| `getItemsStream()` | all output items (messages, function calls, …) — output items **only**, no usage/response metadata | +| `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events, and each round's `response.completed` (with that round's usage block) | + +#### Usage across a multi-round tool loop + +`getResponse()` resolves to the **final** round's response, so in a +multi-round tool loop the tokens spent on the intermediate `tool_calls` +generations are not in `response.usage`. `getItemsStream()` carries output +items only and never surfaces `response.completed`, so usage is not reachable +from that stream either. + +`getUsage()` closes the gap with aggregate totals across every model call the +run made — the initial request, each tool-round follow-up, the empty-final +retry, the `allowFinalResponse` final turn, and approval-resume requests: + +```typescript +const result = callModel(client, { model, input, tools }); + +for await (const item of result.getItemsStream()) { + render(item); +} + +const usage = await result.getUsage(); +console.log(usage.modelCalls, usage.totalTokens, usage.cost); +``` + +It gates on run completion like `getResponse()` does, so the totals are final +whether you await it directly, after `getResponse()`, or after draining any of +the streaming getters. Unlike `getResponse()` it never rejects — a failed run +still consumed tokens — and returns the totals accrued so far, with +`modelCalls: 0` and zeroed tokens when no model call completed. `cost` is +present only when the server reported cost accounting. + +Same `SessionUsageTotals` shape and numbers as the `SessionEnd` hook's +`totalUsage`. For **per-call** granularity use the `PostModelCall` hook (one +emit per model call, with `turnType`/`turnNumber`) or read each round's +`response.completed` off `getFullResponsesStream()`. ### Tool Types -The `tool()` factory creates type-safe tools with full Zod schema inference. Three tool types are supported: +The `tool()` factory creates type-safe tools with full Zod schema inference. In addition to the legacy kinds below, the unified `run` interface with `lifecycle: 'sync' | 'background' | 'deferred'` covers [async tools](#async-tools) whose results arrive after the tool round, and `tool.agent()` creates [subagent tools](#agent-tools-subagents). **Regular tools** — automatically executed by the agent loop: @@ -182,6 +207,26 @@ const confirmTool = tool({ }); ``` +**Forced tool choices** are one-shot for each resolved semantic value. After a +forced choice produces a tool call, unchanged follow-up choices are relaxed to +`auto` so the model can either call another tool or answer in text. A dynamic +choice re-arms when it resolves to a different value (or after an unforced +turn): + +```typescript +callModel(client, { + model: 'openai/gpt-4o', + input: 'Plan, research, then submit.', + tools: [planTool, searchTool, submitTool] as const, + toolChoice: ({ numberOfTurns }) => + numberOfTurns === 0 + ? { type: 'function', name: 'plan' } + : numberOfTurns === 3 + ? { type: 'function', name: 'submit' } + : 'auto', +}); +``` + ### Stop Conditions Control when the agent loop stops executing tools: @@ -310,11 +355,37 @@ if (verdict) console.warn(verdict.message); Detection is **deterministic** — a verdict is a pure function of the transcript, so the same sequence of calls/text always fires at the same -point. Identical calls in consecutive **rounds** build a per-tool streak: -interleaved calls to *other* tools don't reset it, and N identical calls -fanned out in parallel within ONE round count once (a streak measures the -model re-issuing a call *after seeing its result*, which requires a round -trip). The streak crosses a graduated ladder — strongest crossed rung wins: +point. Repeated **rounds** build a per-tool streak: interleaved calls to +*other* tools don't reset it, and N identical calls fanned out in parallel +within ONE round count once (a streak measures the model re-issuing a call +*after seeing its result*, which requires a round trip). + +Two kinds of evidence accumulate side by side, and the stronger one decides: + +- **Round-set streaks.** A round's identity for one tool is the **set** of + calls it made, so a fan-out of *distinct* arguments reissued verbatim + counts: `read(a), read(b), read(c)` every round accumulates. Ordering + within the round is irrelevant, and a round whose membership changes — in + either direction — resets this streak, since adding or dropping work is + progress for the round as a unit. +- **Per-call streaks.** Each `(tool, arguments)` identity also counts its own + consecutive rounds, whatever its round-mates did. A call repeating inside + varying company (`[a,b]`, `[a,c]`, `[a,d]` — `a` is a 3-peat) is flagged + even though every round's set differs, and a repeat spanning an approval + pause keeps counting when the paused member drops from the resumed round. + For an exactly-repeating round both counts are equal, so nothing + double-fires. + +When a repeating fan-out crosses a rung, every call in the round gets the +verdict (so `block` stops the whole fan-out, not just one member), and calls +carrying the SAME evidence share byte-identical text — the `steer` rung +dedupes on exact text, so one piece of evidence injects one correction. A +round can carry two pieces of evidence at once (`[a]`, `[a,b]`, `[a,b]`: by +round 3, `a` is a 3-peat call while `{a,b}` is a 2-peat set), in which case +each renders its own message — at most two per tool per round, each stating +a distinct fact. When the per-call count alone crosses a rung, only that +call is refused and genuinely new round-mates run free. The streak crosses +a graduated ladder — strongest crossed rung wins: | Action | Effect | |---|---| @@ -364,10 +435,11 @@ rungs (a weaker threshold at or past an enabled stronger one) warn, and so does an `escalate` rung without an `escalation` config (or vice versa). **Tools declare what identifies a call** via `loopKey` on the tool -definition — a **function or a variable**: +definition — a computed function over the call's validated arguments, like +every other tool hook, or `false` to exempt: ```typescript -// Function: compute the identity — a web-search tool normalizes its query. +// Compute the identity — a web-search tool normalizes its query. tool({ name: 'web_search', inputSchema: z.object({ query: z.string() }), @@ -375,17 +447,16 @@ tool({ execute: async ({ query }) => search(query), }); -// Variable (field list): declarative subset — data, not code, so it -// survives serializable tool caches. A bash call is identified by the -// command AND where it runs; other fields (e.g. verbose) don't count. +// Return the subset of fields that matter. A bash call is identified by +// the command AND where it runs; other fields (e.g. verbose) don't count. tool({ name: 'bash', inputSchema: z.object({ command: z.string(), cwd: z.string(), verbose: z.boolean() }), - loopKey: ['command', 'cwd'], + loopKey: ({ command, cwd }) => ({ command, cwd }), execute: async ({ command, cwd }) => run(command, cwd), }); -// Variable (false): statically exempt — repetition is this tool's job. +// false: statically exempt — repetition is this tool's job. tool({ name: 'check_status', inputSchema: z.object({ jobId: z.string() }), @@ -398,8 +469,24 @@ A function-form `loopKey` may return `null` to exempt an individual call. Returning `undefined`, throwing, or returning unhashable material (bigint, circular, >64 levels deep) falls back to the full-arguments identity with a warning — detection never fails a run. Without any `loopKey`, the full -validated arguments object is the identity. MCP-wrapped tools accept a -`loopKey` via `markMcp(tool, { loopKey })` (prefer the field-list form). +validated arguments object is the identity. A field-name array +(`loopKey: ['command', 'cwd']`) is also accepted — data rather than code, +so it can be serialized into MCP tool caches and advertised over the wire +via `_meta['openrouter/loopKey']`. MCP-wrapped tools accept a `loopKey` +via `markMcp(tool, { loopKey })` or the `loopKeys` map on +`createMCPTools`. + +> **Exempt tools that repeat by design — including repeating *fan-outs*.** +> The detector compares arguments, not results, so a call whose arguments are +> stable while its results change is indistinguishable from a loop. Since a +> round's identity is now the whole *set* of a tool's calls, this covers +> parallel shapes too: an agent that re-reads the same context files at the +> start of every turn, or fans out a fixed set of pollers, accumulates a streak +> and is refused at the default `block` rung from round 3 — and because every +> call in the round gets the verdict, that is N synthesized error outputs per +> round, not one. These shapes were invisible before this behavior existed, so +> `loopKey: false` (or a `loopKey` returning `null`) is the opt-out for any +> tool whose repetition is legitimate. **Fingerprints are a cross-port contract**: key material is canonicalized per RFC 8785 (JCS) and hashed with SHA-256 over the UTF-8 bytes, so the @@ -423,7 +510,7 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Varying-input loops.** The fingerprint is identity-based: a model that invents a fresh nonce/timestamp field each call evades the default - whole-arguments identity entirely. A `loopKey` that names the meaningful + whole-arguments identity entirely. A `loopKey` that returns the meaningful fields closes this per tool; the structural fix (outcome hashing — the progress-ledger detector from the design doc) is planned, not shipped. - **Paraphrased repetition.** Text detectors require exact repeated token @@ -436,6 +523,179 @@ block to observe for a known-chatty tool, or escalate straight to stop. - **Manual/client-executed calls** pause the loop for the caller and are not recorded (only executed, blocked, and parse-error calls are evidence). +- **Cross-tool round patterns.** Streaks are per tool: a loop alternating + BETWEEN tools with no per-tool repetition (`read(a)` one round, `grep(a)` + the next, forever) shows each tool a sparse pattern its own evidence + cannot condemn. Interleaved calls to other tools never *reset* a tool's + streak, so an every-other-round repeat still accumulates — slowly. + +### Async Tools + +One `tool()` shape covers every execution lifecycle. Write an ordinary `run` — an async function or an async generator — and say what kind it is with `lifecycle`: + +- **`'sync'`** (default): awaited in the round, exactly like `execute`. +- **`'background'`**: the loop keeps going. Work settling within the grace window (`graceMs`, default 250ms) behaves like a plain sync call; otherwise the model immediately receives a pending placeholder and the return value is injected as a `tool_task_result` message when it settles. +- **`'deferred'`**: `run` returns `ctx.defer(taskId)` to park the call on durable external work — the run pauses (`status: 'awaiting_async_tools'`) until the task is completed from any process. Returning a plain value resolves immediately. + +Generator `run` yields become the task's **log** (feeding check-ins, `tool.preliminary_result` events, and transcripts); the generator's **return** is the result, validated against `outputSchema`. Non-generator bodies log with `ctx.log()`. + +```typescript +const renderVideo = tool({ + name: 'render_video', + lifecycle: 'background', + inputSchema: z.object({ script: z.string() }), + outputSchema: z.object({ url: z.string() }), + ack: 'Rendering started.', + timeoutMs: 300_000, + run: async function* ({ script }, ctx) { + const job = await renderer.start(script, { signal: ctx?.signal }); + ctx?.onMessage((msg) => job.reprioritize(msg)); // steering opt-in + for await (const p of job.progress()) yield { pct: p }; + return job.result(); + }, +}); + +const legalReview = tool({ + name: 'request_legal_review', + lifecycle: 'deferred', + inputSchema: z.object({ contractId: z.string() }), + outputSchema: z.object({ approved: z.boolean() }), + run: async ({ contractId }, ctx) => { + const ticket = await legal.open(contractId, { conversationId: ctx?.conversationId }); + return ctx!.defer(ticket.id); + }, +}); +``` + +Deferred completion is typed and lives on the tool — callable from any process holding the `StateAccessor`: + +```typescript +// webhook handler — hours later, different process +await legalReview.resolve(client, { + state: makeAccessor(conversationId), + taskId: ticketId, + output: { approved: true }, // ← typechecked against outputSchema + run: { model: 'openai/gpt-4o' }, // continue immediately (omit to record-only) +}); +``` + +`legalReview.fail(...)` / `legalReview.cancel(...)` complete the surface; double resolution throws `ToolTaskAlreadySettledError`. The low-level `resumeToolResults()` handles batches. When a run would end with background work in flight, `asyncTools.onRunEnd` decides: `'drain'` (default), `'detach'`, or `'cancel'`. + +> **Security:** `.resolve()` injects a value the model treats as a tool result. Authenticate the webhook before calling it — the SDK cannot do that for you. Outputs are validated against `outputSchema` at runtime as well as compile time. + +### Checking On Long-Running Tasks + +When any long-running tool is registered, the SDK appends **one universal `task` tool** to the request — a single static wire definition regardless of how many async tools exist (per-tool schemas are never augmented, so context cost stays constant). The pending placeholder tells the model to use it: + +```typescript +task({ taskId: "task_7f3" }) // status: state, elapsed, last log +task({ taskId, view: "logs", tail: 5 }) // recent progress entries +task({ taskId, view: "transcript" }) // full detail (agents: child conversation) +task({ taskId, action: "steer", message: "..." }) // send guidance to the running task +task({ taskId, action: "result" }) // final result if settled, else status +task({ taskId, action: "cancel", reason: "..." }) // stop the task +``` + +Calls are engine-intercepted and dispatched to the **owning tool's** `check` config — the wire surface is universal, the handling stays tool-specific: + +```typescript +const renderVideo = tool({ + name: 'render_video', + lifecycle: 'background', + // ... as above ... + check: { // optional — SDK default when absent + schema: z.object({ focus: z.string().optional() }), // validates task({ params }) + execute: async (params, turnContext) => { + // turnContext.toolCallStatus → 'working' | 'completed' | ... + // turnContext.accumulatedYieldedEvents → every run yield so far + // turnContext.task → { statusView, tailLogs, transcript, send, cancel } + if (params.focus) turnContext.task?.send(params.focus); + return turnContext.task?.statusView(); + }, + }, +}); +``` + +Without a custom `check`, the SDK default answers the three views (`status` / `logs` / `transcript`, truncated to `asyncTools.maxTranscriptChars`, default 20k). Task-tool calls are doom-loop-exempt, bypass per-tool concurrency/timeout gates, and never fire Pre/PostToolUse hooks — but a `PermissionRequest` hook denial recorded for the call IS honored, so a policy layer can veto `cancel`/`steer`. Disable entirely with `asyncTools: { checkins: false }` (placeholders then revert to "do not call this tool again"). The name `task` is reserved: `tool()` and `tool.agent()` reject it at definition time; a dynamically-built tool list that bypasses `tool()` and claims the name suppresses the built-in with a warning (and the engine routes `task` calls to that user tool instead of intercepting them). + +After a process restart, deferred tasks answer `status` from persisted state (including a bounded `lastLog`); full logs and transcripts are in-memory only and report an explanatory note instead. + +### Steering Running Tasks + +- **From code:** `result.sendToTask(taskId, message)` delivers into the run body's `ctx.onMessage` handler (queued until one registers). Deferred tasks throw — their work runs in an external system. +- **From the model:** `task({ taskId, action: 'steer', message })` delivers directly, or expose custom `params` handled by `check.execute` with `turnContext.task.send(...)`. +- **Agent tools** forward steering messages into the child conversation automatically (as user messages at the child's next turn boundary). + +### Agent Tools (Subagents) + +`tool.agent()` creates a tool whose work IS a child `callModel` conversation, running as a background task: + +```typescript +const researcher = tool.agent({ + name: 'research_topic', + description: 'Deep-research a topic in the background.', + inputSchema: z.object({ topic: z.string() }), + outputSchema: z.object({ text: z.string() }), + agent: ({ topic }) => ({ + model: 'openai/gpt-4o', + input: `Research: ${topic}`, + tools: [searchTool, fetchTool] as const, + stopWhen: stepCountIs(15), + }), + // default result mapper — Dennis-style last_message outcome: + result: async (child) => ({ text: await child.getText() }), +}); +``` + +The parent keeps working while children run (several can run concurrently under the background pool). The child's conversation is the check-in **transcript**; each child turn is a **log** entry; `status` reports `turnsCompleted` and `currentActivity`. `cancelTask(taskId)` (or parent abort / `timeoutMs`) cancels the child; `sendToTask` steers it mid-run. Children run **in-memory** (no `StateAccessor`) and do not inherit the parent's hooks — pass child hooks explicitly in the `agent` spec if needed. A child that pauses (HITL/manual/approval/deferred tools inside it) fails the task with a clear error. + +### Strict Tool Schemas + +Every client tool kind, including `tool.agent()`, accepts `strict: true` to +request provider-enforced schema adherence for generated tool-call arguments. +The SDK faithfully converts the caller's `inputSchema`; it does not rewrite +the runtime Zod contract. + +OpenAI-style strict function calling requires every declared object property +to appear in JSON Schema's `required` list. Use `.nullable()` for a value that +may be absent conceptually, because `.optional()` omits the property from +`required`: + +```typescript +const weatherTool = tool({ + name: 'get_weather', + inputSchema: z.object({ + location: z.string(), + // The key is required, but the model may return null. + units: z.enum(['celsius', 'fahrenheit']).nullable(), + }), + strict: true, + execute: async ({ location, units }) => getWeather(location, units), +}); +``` + +The SDK sends the generated schema unchanged. Providers validate it according +to their own strict-mode dialect and the SDK propagates any API error. Use +`.nullable()`, or set `strict: false` when omission is part of the tool's +contract. Provider support and strict-schema restrictions can vary. + +### Per-Tool Timeouts & Concurrency + +Every tool kind accepts `timeoutMs` (per-execution deadline; the run-level `toolTimeoutMs` sets a default) and `maxConcurrency` (max simultaneous executions of that tool). On timeout the round stops waiting — the model receives `{ error, code: 'tool_timeout' }` and the tool's `ctx.signal` aborts; the timeout bounds the round's *wait*, not the tool body, so signal-ignoring bodies can't hang the run. `ctx.signal` also fires on run abort (`signal` option) and `ModelResult.cancel()`. + +Round-level parallelism (unbounded by default, matching previous behavior) is capped with `toolConcurrency`: + +```typescript +const result = callModel(client, { + model: 'openai/gpt-4o', + input: 'fan out', + tools: [searchTool] as const, + toolTimeoutMs: 30_000, + toolConcurrency: { round: 4, background: 8 }, // or a bare number for { round: n } +}); +``` + +Execution order may change under a cap; output order never does (results stay in call order for prompt-cache stability). ### Tool Approval @@ -544,7 +804,7 @@ const result = callModel(client, { model, input, tools, hooks }); | `SessionStart` | Once per run, before the initial request. `config` summarizes the session (`hasTools`, `hasApproval`, `hasState`) | none (void) | | `SessionEnd` | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. `reason` is `'complete' \| 'error' \| 'max_turns' \| 'user' \| 'doom_loop'`. When at least one model call completed, `totalUsage` aggregates tokens/cost across all of them (`modelCalls`, `inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, and `cost` when the server reported it) | none (void) | | `PostModelCall` | Once per completed model response, on **every** request the loop makes — initial, each tool-round follow-up, the empty-final retry, the `allowFinalResponse` final turn, and approval-resume requests. Payload: `responseId` (the OpenRouter generation id), `model`, `durationMs` (dispatch → fully materialized response, including stream consumption), `turnType` (`'initial' \| 'resume' \| 'tool_round' \| 'final' \| 'retry'`), `turnNumber`, and `usage` (`inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, `cost?`) when the server reported usage accounting. Purely observational — the telemetry primitive for tracing/benchmark consumers: one span per model call | none (void) | -| `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — parallel duplicates in one round share the event (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` | +| `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — *identical* parallel duplicates in one round share the event, but a repeating fan-out of DISTINCT arguments emits one event per member, since each is its own `(tool, fingerprint)` (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` | Notes on lifecycle pairing: `SessionEnd` only fires when a matching `SessionStart` succeeded, and at most once per run. Pending async hook work is @@ -559,6 +819,9 @@ a materialized response emits no `PostModelCall`; a `response.incomplete` response (e.g. truncated at `max_output_tokens`) **does** emit — it carries a real generation id and consumed tokens. Note `usage.cost` is only present when the request had usage accounting enabled server-side. + +`SessionEnd.totalUsage` is push-based; for the same totals without registering +a hook, await [`getUsage()`](#usage-across-a-multi-round-tool-loop). Every handler receives `(payload, context)` — `context` carries the `sessionId` (the single source of session identity; payloads do not repeat it), the `hookName`, and an `AbortSignal` for cooperative cancellation. The @@ -798,13 +1061,13 @@ const chatMsg = toChatMessage(openRouterMessage); const orMessages2 = fromChatMessages(chatMessages); ``` -### Tool Sets +# Tool Sets Declarative, state-aware activation and deactivation for tools used with `@openrouter/agent`. Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Cook), adapted for this SDK's ordered `Tool[]` / `callModel` model. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). -**What it adds:** +## What it adds - **Stable tool-set IDs** for every addressable tool: - client tools → `function.name` @@ -814,6 +1077,14 @@ Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Co - **Named declarative situations** with compile-time exact tool tuples when the situation is fully static. - Integration with `callModel`'s `activeTools` option via the snapshot's spread-safe `.callModel` input. +## Install + +```bash +pnpm add @openrouter/agent +``` + +## Usage + ```ts import { OpenRouter, tool, serverTool, callModel } from '@openrouter/agent'; import { @@ -895,7 +1166,7 @@ const result = callModel(client, { }); ``` -**Identity** +## Identity | Kind | Tool-set ID | | --- | --- | @@ -905,7 +1176,7 @@ const result = callModel(client, { Duplicate IDs throw at `createToolSet` construction. Activation methods accept only known IDs. -**Compile-time vs runtime exactness** +## Compile-time vs runtime exactness | Resolution style | Developer-time knowledge | Runtime knowledge | | --- | --- | --- | @@ -916,21 +1187,33 @@ Duplicate IDs throw at `createToolSet` construction. Activation methods accept o The type system cannot execute predicates. Conditional IDs therefore expand the compile-time upper bound of active tools; after `resolve`, the returned arrays and `statusByTool` are always exhaustive and exact. -**API** +## API + +### `createToolSet({ tools, mutable? })` -`createToolSet({ tools, mutable? })` — Build a set from an ordered tool array. Optional `TShared` types the `context` argument on predicates. Defaults to immutable. +Build a set from an ordered tool array. Optional `TShared` types the `context` argument on predicates. Defaults to immutable. -`.tools` — Concrete tools tuple in construction order (client + server), regardless of activation. +### `.tools` -`.activate(id | id[])` / `.deactivate(id | id[])` — Static flip (last-call-wins). Accepts client names **and** server IDs. Updates the compile-time partition. +Concrete tools tuple in construction order (client + server), regardless of activation. -`.activateWhen(id, predicate)` / `.activateWhen({ [id]: predicate })` — Conditional activation — defaults inactive, becomes active when predicate returns `true`. Moves the ID into the conditional partition. +### `.activate(id | id[])` / `.deactivate(id | id[])` -`.deactivateWhen(id, predicate)` / `.deactivateWhen({ [id]: predicate })` — Conditional deactivation — defaults active, becomes inactive when predicate returns `true`. Also moves the ID into the conditional partition. +Static flip (last-call-wins). Accepts client names **and** server IDs. Updates the compile-time partition. + +### `.activateWhen(id, predicate)` / `.activateWhen({ [id]: predicate })` + +Conditional activation — defaults inactive, becomes active when predicate returns `true`. Moves the ID into the conditional partition. + +### `.deactivateWhen(id, predicate)` / `.deactivateWhen({ [id]: predicate })` + +Conditional deactivation — defaults active, becomes inactive when predicate returns `true`. Also moves the ID into the conditional partition. Predicate input: `{ state?: ConversationState; context?: TShared }`. -`.defineSituations({ [name]: config })` — Declarative named situations. Each config may include: +### `.defineSituations({ [name]: config })` + +Declarative named situations. Each config may include: - `enabled?: readonly Id[]` — statically on - `disabled?: readonly Id[]` — statically off @@ -938,7 +1221,7 @@ Predicate input: `{ state?: ConversationState; context?: TShared }`. Situation overlays the base partition for every ID it mentions; unmentioned IDs keep the base state. Unknown, duplicate, or conflicting IDs within one situation throw. -`.resolve(input?)` → snapshot +### `.resolve(input?)` → snapshot ```ts { @@ -958,13 +1241,19 @@ Situation overlays the base partition for every ID it mentions; unmentioned IDs } ``` -`.resolveSituation(name, input?)` → snapshot — Same shape as `resolve`, with the named situation overlay applied first. +### `.resolveSituation(name, input?)` → snapshot + +Same shape as `resolve`, with the named situation overlay applied first. + +### `.inferTools(input?)` -`.inferTools(input?)` — Back-compat alias for `resolve`. Prefer `resolve` in new code. +Back-compat alias for `resolve`. Prefer `resolve` in new code. -`.clone({ mutable? })` — Copy state, optionally flipping mode. +### `.clone({ mutable? })` -Inference utilities: +Copy state, optionally flipping mode. + +### Inference utilities ```ts type All = InferAllIds; @@ -973,9 +1262,11 @@ type Disabled = InferDisabledIds; type Conditional = InferConditionalIds; ``` -`InferToolSet` — Alias of the agent's `CorrelatedToolEventUnion` — name-correlated preliminary/result stream events based on a tools tuple. +### `InferToolSet` + +Alias of the agent's `CorrelatedToolEventUnion` — name-correlated preliminary/result stream events based on a tools tuple. -**Notes** +## Notes - Immutable by default (every mutator returns a new `ToolSet` with refined partition types). - `mutable: true` mutates in place. Partition type parameters may widen for soundness; runtime state is still exact. @@ -984,51 +1275,31 @@ type Conditional = InferConditionalIds; - Keep a snapshot's `tools` and `activeTools` together by spreading `.callModel`; `callModel` cannot verify `activeTools` against an unrelated tools array. - `callModel` ignores names in `activeTools` that are not present in `tools`. Tool-set snapshots avoid stale names by deriving both arrays from the same set. -## Subpath Exports -The root export is the primary API. Feature subpaths isolate optional integrations, -and advanced subpaths support targeted imports without exposing undeclared filesystem -paths. All of the following are public, semver-governed entry points: +## Subpath Exports -| Entry point | Purpose | -| --- | --- | -| `@openrouter/agent` | Primary agent API and SDK-facing types. | -| `@openrouter/agent/tool-set` | Declarative, state-aware tool activation. | -| `@openrouter/agent/mcp` | Canonical optional MCP integration. | -| `@openrouter/agent/mcp/create-mcp-tools` | Focused MCP factory import. | -| `@openrouter/agent/mcp/types` | MCP option and handle types. | -| `@openrouter/agent/mcp/schema` | MCP JSON Schema conversion. | -| `@openrouter/agent/mcp/cache` | MCP cache contracts and in-memory store. | -| `@openrouter/agent/call-model` | Model-loop entry point. | -| `@openrouter/agent/openrouter` | OpenRouter client export. | -| `@openrouter/agent/tool` | Tool creation helpers. | -| `@openrouter/agent/tool-types` | Tool and correlated-event types. | -| `@openrouter/agent/model-result` | Model result consumption. | -| `@openrouter/agent/hooks-manager` | Lifecycle hook manager. | -| `@openrouter/agent/async-params` | Async request-parameter resolution. | -| `@openrouter/agent/stop-conditions` | Agent-loop stop conditions. | -| `@openrouter/agent/doom-loop` | Repetition detection and escalation. | -| `@openrouter/agent/anthropic-compat` | Anthropic message conversion. | -| `@openrouter/agent/chat-compat` | Chat message conversion. | -| `@openrouter/agent/claude-constants` | Claude compatibility constants. | -| `@openrouter/agent/claude-type-guards` | Claude message type guards. | -| `@openrouter/agent/conversation-state` | Serializable conversation state. | -| `@openrouter/agent/next-turn-params` | Next-turn request helpers. | -| `@openrouter/agent/stream-transformers` | Response stream transformation. | -| `@openrouter/agent/tool-context` | Tool execution context. | -| `@openrouter/agent/tool-event-broadcaster` | Real-time tool events. | -| `@openrouter/agent/turn-context` | Turn-scoped context types. | +For tree-shaking or targeted imports, the package provides granular subpath exports: ```typescript import { callModel } from '@openrouter/agent/call-model'; -import { createToolSet } from '@openrouter/agent/tool-set'; -import { createMCPTools } from '@openrouter/agent/mcp'; +import { tool } from '@openrouter/agent/tool'; +import { ModelResult } from '@openrouter/agent/model-result'; +import { HooksManager } from '@openrouter/agent/hooks-manager'; +import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions'; +import { DoomLoopMonitor, fingerprintToolCall } from '@openrouter/agent/doom-loop'; +import { toClaudeMessage } from '@openrouter/agent/anthropic-compat'; +import { toChatMessage } from '@openrouter/agent/chat-compat'; +import { ToolContextStore } from '@openrouter/agent/tool-context'; +import { ToolEventBroadcaster } from '@openrouter/agent/tool-event-broadcaster'; +import { createInitialState } from '@openrouter/agent/conversation-state'; +import { resumeToolResults } from '@openrouter/agent/resume-tool-results'; +import { Semaphore } from '@openrouter/agent/tool-concurrency'; +import { AsyncToolRegistry } from '@openrouter/agent/async-tool-registry'; +import { ToolTask } from '@openrouter/agent/tool-task'; +import { TaskToolInputSchema } from '@openrouter/agent/tool-check'; +import { AgentTranscriptSource } from '@openrouter/agent/agent-tool'; ``` -`@openrouter/mcp` and its matching subpaths remain migration facades for existing -applications. Prefer the canonical `@openrouter/agent/mcp` paths in new code; the -facade would only be removed in a future breaking release after migration notice. - ## Development ```bash diff --git a/packages/agent/package.json b/packages/agent/package.json index 047c7553..cd1908ea 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -120,6 +120,30 @@ "types": "./esm/mcp/cache/cache-store.d.ts", "default": "./esm/mcp/cache/cache-store.js" }, + "./tool-concurrency": { + "types": "./esm/lib/tool-concurrency.d.ts", + "default": "./esm/lib/tool-concurrency.js" + }, + "./async-tool-registry": { + "types": "./esm/lib/async-tool-registry.d.ts", + "default": "./esm/lib/async-tool-registry.js" + }, + "./resume-tool-results": { + "types": "./esm/inner-loop/resume-tool-results.d.ts", + "default": "./esm/inner-loop/resume-tool-results.js" + }, + "./tool-task": { + "types": "./esm/lib/tool-task.d.ts", + "default": "./esm/lib/tool-task.js" + }, + "./tool-check": { + "types": "./esm/lib/tool-check.d.ts", + "default": "./esm/lib/tool-check.js" + }, + "./agent-tool": { + "types": "./esm/lib/agent-tool.d.ts", + "default": "./esm/lib/agent-tool.js" + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -141,12 +165,13 @@ "scripts": { "lint": "biome check src tests", "lint:fix": "biome check --write src tests", - "build": "tsc", + "build": "node scripts/gen-version.mjs && tsc", "test": "vitest --run --project unit", "test:e2e": "vitest --run --project e2e --coverage.enabled=false", "test:watch": "vitest --watch --project unit", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", - "compile": "tsc" + "compile": "node scripts/gen-version.mjs && tsc", + "gen:version": "node scripts/gen-version.mjs" }, "dependencies": { "@openrouter/sdk": "^0.13.7", diff --git a/packages/agent/scripts/gen-version.mjs b/packages/agent/scripts/gen-version.mjs new file mode 100644 index 00000000..20569800 --- /dev/null +++ b/packages/agent/scripts/gen-version.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; +if (typeof version !== 'string' || !/^[0-9A-Za-z.+-]+$/.test(version)) process.exit(1); +const output = `// DO NOT EDIT — generated from package.json by scripts/gen-version.mjs.\n// Run \`pnpm --filter @openrouter/agent gen:version\` after bumping the version.\n\n/** This package's version, self-reported to MCP servers as \`clientInfo\`. */\nexport const PACKAGE_VERSION = '${version}';\n`; +writeFileSync(join(root, 'src/mcp/version.ts'), output); diff --git a/packages/agent/src/lib/tool-set.ts b/packages/agent/src/lib/tool-set.ts index 08c4ed75..1b9e4ee9 100644 --- a/packages/agent/src/lib/tool-set.ts +++ b/packages/agent/src/lib/tool-set.ts @@ -4,24 +4,19 @@ import type { ActivationInput, ActivationPredicate, ApplySituationPartition, - ClientToolNamesOfTuple, ConditionalPartition, DeactivatePartition, EmptySituations, - FilterToolsByIds, InferSituationMap, InitialPartition, Partition, ResolvedToolSnapshot, - ResolvedTools, - ServerToolIdsOfTuple, SituationConditionalRule, SituationConfig, SituationMap, SituationNames, StatusByToolMap, StatusReason, - ToolIdOf, ToolIdsOfTuple, ToolStatusEntry, WidenedPartition, @@ -882,12 +877,42 @@ export function createToolSet< }); } -// Re-export commonly needed type helpers used at call sites without a separate import. export type { + ActivatePartition, + ActivationInput, + ActivationPredicate, + ApplySituationPartition, + ClientToolName, ClientToolNamesOfTuple, + ConditionalPartition, + DeactivatePartition, + EmptyPartition, + EmptySituations, FilterToolsByIds, + InferAllIds, + InferConditionalIds, + InferDisabledIds, + InferEnabledIds, + InferSituationEntry, + InferSituationMap, + InferToolSet, + InitialPartition, + Partition, + ResolvedToolSnapshot, ResolvedTools, + ServerToolIdOf, ServerToolIdsOfTuple, + SituationConditionalRule, + SituationConfig, + SituationMap, + SituationNames, + StatusByToolMap, + StatusReason, + ToolById, ToolIdOf, ToolIdsOfTuple, -}; + ToolSetLike, + ToolStatusEntry, + WidenedPartition, + WidenedSituationMap, +} from './tool-set-types.js'; diff --git a/packages/agent/src/mcp/build-tools.ts b/packages/agent/src/mcp/build-tools.ts index 7e62519a..2eaaa137 100644 --- a/packages/agent/src/mcp/build-tools.ts +++ b/packages/agent/src/mcp/build-tools.ts @@ -1,5 +1,5 @@ import type { Client } from '@modelcontextprotocol/client'; -import type { Tool, ToolLoopKey } from '@openrouter/agent/tool-types'; +import type { Tool, ToolLoopKey } from '../lib/tool-types.js'; import { MCPError } from './errors.js'; import { buildResourceTools } from './resource-tools.js'; import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; diff --git a/packages/agent/src/mcp/create-mcp-tools.ts b/packages/agent/src/mcp/create-mcp-tools.ts index 01c9143f..507f2d3c 100644 --- a/packages/agent/src/mcp/create-mcp-tools.ts +++ b/packages/agent/src/mcp/create-mcp-tools.ts @@ -49,6 +49,9 @@ const FORWARDED_REHYDRATE_KEYS = [ 'loopKeys', 'autoRefreshOnListChanged', 'cacheCredentials', + 'protocolNegotiation', + 'probeTimeoutMs', + 'staleness', ] as const satisfies readonly (keyof CreateMCPToolsOptions & keyof RehydrateMCPToolsOptions)[]; /** Copy the defined forwarded options from `createMCPTools` into a rehydrate base. */ @@ -72,7 +75,12 @@ async function tryCacheHit( store: MCPCacheStore, cacheKey: string, ): Promise { - const snapshot = await store.get(cacheKey); + let snapshot: Awaited> | undefined; + try { + snapshot = await store.get(cacheKey); + } catch { + snapshot = undefined; + } if (snapshot === null || snapshot === undefined || !isSerializedMCPServer(snapshot)) { return undefined; } diff --git a/packages/agent/src/mcp/errors.ts b/packages/agent/src/mcp/errors.ts index 6d469f76..2f70abb7 100644 --- a/packages/agent/src/mcp/errors.ts +++ b/packages/agent/src/mcp/errors.ts @@ -138,14 +138,14 @@ export class MCPConnectionError extends MCPError { * Raised when MCP support is used without its optional SDK peer installed. */ export class MCPMissingPeerDependencyError extends MCPConnectionError { - readonly packageName = '@modelcontextprotocol/sdk'; + readonly packageName = '@modelcontextprotocol/client'; constructor(options?: { cause?: unknown; }) { super( - 'MCP support requires the optional peer "@modelcontextprotocol/sdk". ' + - 'Install it alongside @openrouter/agent (for example: pnpm add @modelcontextprotocol/sdk).', + 'MCP support requires the optional peer "@modelcontextprotocol/client". ' + + 'Install it alongside @openrouter/agent (for example: pnpm add @modelcontextprotocol/client).', options, ); this.name = 'MCPMissingPeerDependencyError'; diff --git a/packages/agent/src/mcp/index.ts b/packages/agent/src/mcp/index.ts index 019b3114..bcee53e0 100644 --- a/packages/agent/src/mcp/index.ts +++ b/packages/agent/src/mcp/index.ts @@ -1,7 +1,7 @@ // Main factory + rehydration // Auth -export type { MCPAuth } from './auth/auth-types.js'; +export type { MCPAuth, MCPOAuthClientProvider } from './auth/auth-types.js'; export type { MCPCacheStore } from './cache/cache-store.js'; // Cache export { defaultCacheKey, InMemoryMCPCacheStore } from './cache/cache-store.js'; @@ -15,9 +15,11 @@ export { createMCPTools } from './create-mcp-tools.js'; // Errors export { MCPCacheError, + MCPCacheWriteError, MCPConnectionError, MCPError, MCPMissingPeerDependencyError, + MCPStaleSnapshotError, MCPToolCallError, } from './errors.js'; export type { RehydrateMCPToolsOptions } from './rehydrate.js'; @@ -30,6 +32,8 @@ export type { CreateMCPToolsOptions, ElicitationHandler, ElicitationResponse, + MCPProtocolNegotiation, + MCPProtocolRevision, MCPToolsHandle, MCPTransportKind, ResourcesOption, diff --git a/packages/agent/src/mcp/rehydrate.ts b/packages/agent/src/mcp/rehydrate.ts index c7947ad7..66092d42 100644 --- a/packages/agent/src/mcp/rehydrate.ts +++ b/packages/agent/src/mcp/rehydrate.ts @@ -10,6 +10,7 @@ import { MCPCacheError, MCPCacheWriteError, MCPStaleSnapshotError } from './erro import { freshConnect, makeHandle } from './handle.js'; import type { ConnectOptions, MCPConnection } from './mcp-connection.js'; import { connect, isAuthFailure } from './mcp-connection.js'; +import { loadMcpSdk } from './mcp-sdk.js'; import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; import type { McpToolDef } from './tool-wrapper.js'; import type { @@ -616,13 +617,14 @@ export async function rehydrateMCPTools( // matching the guards on the SSE fallback and the legacy retry. Dialling a // fresh connection after the caller cancelled would both waste the dial and // bury the cancellation under a "failed to connect" it didn't cause. + const { UnauthorizedError } = await loadMcpSdk(); if ( reconnectOnExpiry && options.signal?.aborted !== true && !isAuthFailure({ err, auth: effectiveAuth, - UnauthorizedErrorType: undefined, + UnauthorizedErrorType: UnauthorizedError, }) ) { return freshConnect(createOptions, url, cacheKey); diff --git a/packages/agent/src/mcp/resource-tools.ts b/packages/agent/src/mcp/resource-tools.ts index 0e3a7774..4ce8cfb2 100644 --- a/packages/agent/src/mcp/resource-tools.ts +++ b/packages/agent/src/mcp/resource-tools.ts @@ -1,7 +1,7 @@ import type { Client } from '@modelcontextprotocol/client'; -import { markMcp, tool } from '@openrouter/agent/tool'; -import type { McpBranded } from '@openrouter/agent/tool-types'; import * as z from 'zod'; +import { markMcp, tool } from '../lib/tool.js'; +import type { McpBranded } from '../lib/tool-types.js'; export interface ResourceToolsOptions { client: Client; diff --git a/packages/agent/src/mcp/tool-wrapper.ts b/packages/agent/src/mcp/tool-wrapper.ts index 92729dfb..95b76bc2 100644 --- a/packages/agent/src/mcp/tool-wrapper.ts +++ b/packages/agent/src/mcp/tool-wrapper.ts @@ -1,7 +1,7 @@ import type { Client, Progress } from '@modelcontextprotocol/client'; -import { markMcp, tool } from '@openrouter/agent/tool'; -import type { McpBranded, ToolLoopKey } from '@openrouter/agent/tool-types'; import * as z from 'zod'; +import { markMcp, tool } from '../lib/tool.js'; +import type { McpBranded, ToolLoopKey } from '../lib/tool-types.js'; import type { RawCallToolResult } from './result-mapper.js'; import { mapCallToolResult } from './result-mapper.js'; import { isJsonSchemaObject } from './schema/json-schema-guards.js'; diff --git a/packages/agent/src/mcp/types.ts b/packages/agent/src/mcp/types.ts index c7e47e84..ceadfaee 100644 --- a/packages/agent/src/mcp/types.ts +++ b/packages/agent/src/mcp/types.ts @@ -2,9 +2,13 @@ import type { Tool, ToolLoopKey } from '../lib/tool-types.js'; import type { MCPAuth } from './auth/auth-types.js'; import type { MCPCacheStore } from './cache/cache-store.js'; import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; -import type { MCPProtocolNegotiation, MCPTransportKind } from './transport-types.js'; +import type { + MCPProtocolNegotiation, + MCPProtocolRevision, + MCPTransportKind, +} from './transport-types.js'; -export type { MCPProtocolNegotiation, MCPTransportKind }; +export type { MCPProtocolNegotiation, MCPProtocolRevision, MCPTransportKind }; /** * Response to a server-initiated elicitation request. `accept` must carry diff --git a/packages/agent/src/mcp/version.ts b/packages/agent/src/mcp/version.ts index b8fe6bd7..67ad4938 100644 --- a/packages/agent/src/mcp/version.ts +++ b/packages/agent/src/mcp/version.ts @@ -1,5 +1,5 @@ // DO NOT EDIT — generated from package.json by scripts/gen-version.mjs. -// Run `pnpm --filter @openrouter/mcp gen:version` after bumping the version. +// Run `pnpm --filter @openrouter/agent gen:version` after bumping the version. /** This package's version, self-reported to MCP servers as `clientInfo`. */ -export const PACKAGE_VERSION = '0.0.1'; +export const PACKAGE_VERSION = '0.9.0'; diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts index 3ad38568..00515945 100644 --- a/packages/agent/tests/unit/call-model-active-tools.test.ts +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -210,6 +210,15 @@ describe('callModel activeTools filter', () => { ]); }); + it('omits the tools key when tools is explicitly empty', async () => { + const { raw } = await captureOutboundRequest({ + model: 'openai/gpt-4o-mini', + input: 'hi', + tools: [], + }); + expect(raw).not.toHaveProperty('tools'); + }); + it('omits the tools key entirely (not an empty array) when activeTools filters out every tool', async () => { const captured: { names: string[] | null; diff --git a/packages/agent/tests/unit/mcp/call-tool-shape.test.ts b/packages/agent/tests/unit/mcp/call-tool-shape.test.ts new file mode 100644 index 00000000..54ddfcbf --- /dev/null +++ b/packages/agent/tests/unit/mcp/call-tool-shape.test.ts @@ -0,0 +1,115 @@ +import type { Client } from '@modelcontextprotocol/client'; +import { describe, expect, it } from 'vitest'; +import { wrapMcpTool } from '../../../src/mcp/tool-wrapper.js'; + +// Regression guard for the SDK v2 `callTool` signature change. +// +// v1 was `callTool(params, resultSchema, options)`; v2 is +// `callTool(params, options)`. The failure mode is silent rather than loud: if +// the old three-argument form survives, `signal` and `onprogress` land in a +// third parameter the SDK does not read, so cancellation and progress +// streaming stop working while every other test still passes. These tests +// assert the options object arrives in the SECOND argument. + +interface RecordedCall { + args: unknown[]; +} + +function fakeClient(recorded: RecordedCall[]): Client { + return { + callTool: (...args: unknown[]) => { + recorded.push({ + args, + }); + return Promise.resolve({ + content: [ + { + type: 'text', + text: 'ok', + }, + ], + }); + }, + } as never; +} + +/** + * The wrapped tool is an OpenRouter tool envelope — the callable lives at + * `.function.execute`, not on the object itself. + */ +function asExecute(t: unknown): (args: Record) => never { + return ( + t as { + function: { + execute: (args: Record) => never; + }; + } + ).function.execute; +} + +const DEF = { + name: 'do_thing', + inputSchema: { + type: 'object', + properties: {}, + }, +}; + +describe('callTool argument shape', () => { + it('passes exactly two arguments — params then options', async () => { + const recorded: RecordedCall[] = []; + const t = wrapMcpTool(DEF, { + client: fakeClient(recorded), + emitProgress: false, + }); + + await asExecute(t)({}); + + expect(recorded).toHaveLength(1); + expect(recorded[0]?.args).toHaveLength(2); + expect(recorded[0]?.args[0]).toEqual({ + name: 'do_thing', + arguments: {}, + }); + }); + + it('threads the abort signal into the second argument', async () => { + const recorded: RecordedCall[] = []; + const controller = new AbortController(); + const t = wrapMcpTool(DEF, { + client: fakeClient(recorded), + emitProgress: false, + signal: controller.signal, + }); + + await asExecute(t)({}); + + const options = recorded[0]?.args[1] as + | { + signal?: AbortSignal; + } + | undefined; + expect(options?.signal).toBe(controller.signal); + }); + + it('threads onprogress into the second argument for generator tools', async () => { + const recorded: RecordedCall[] = []; + const t = wrapMcpTool(DEF, { + client: fakeClient(recorded), + emitProgress: true, + }); + + // Drain the generator so the underlying callTool actually runs. + const gen = asExecute(t)({}) as AsyncGenerator; + while (!(await gen.next()).done) { + // discard progress events + } + + const options = recorded[0]?.args[1] as + | { + onprogress?: unknown; + } + | undefined; + expect(typeof options?.onprogress).toBe('function'); + }); +}); diff --git a/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts b/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts index 37a05ae4..f1fcd45b 100644 --- a/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts +++ b/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts @@ -99,4 +99,244 @@ describe('createMCPTools setup teardown', () => { process.off('unhandledRejection', onRejection); } }); + + /** + * A failed cache WRITE must not silence the list_changed announcement. + * + * `refresh()` adopts the new tools before it writes the snapshot back, so by + * the time a store outage surfaces as `MCPCacheWriteError`, `handle.tools` + * already returns the new set. Skipping notification there left subscribers + * permanently out of sync with the handle — and contradicted the best-effort + * write policy every other path follows. + */ + it('notifies subscribers of new tools even when the cache write fails', async () => { + let calls = 0; + state.listTools = () => { + calls += 1; + return Promise.resolve({ + tools: + calls === 1 + ? [] + : [ + { + name: 'brand_new', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ], + }); + }; + + const failingStore = { + get: () => Promise.resolve(null), + set: () => Promise.reject(new Error('store unavailable')), + delete: () => Promise.resolve(), + }; + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + cache: { + store: failingStore, + key: 'k', + }, + }); + + const seen: number[] = []; + handle.onToolsChanged((next) => { + seen.push(next.length); + }); + + state.listChangedHandler?.(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // The write failed, the re-list did not: subscribers hear about the new set. + expect(seen).toEqual([ + 1, + ]); + expect(handle.tools).toHaveLength(1); + }); + + /** + * A failure while BUILDING the snapshot must not wear the cache-write tag. + * + * `snapshot()` awaits the caller's own OAuth `provider.tokens()`, so a + * provider rejection is a credential problem, not a store outage. Wrapping + * the build inside the same `try` as `store.set` relabelled it + * `MCPCacheWriteError` — the one class every path is documented to treat as + * harmless — so callers following that pattern silently swallowed genuine + * credential failures. `refresh()` must surface it under its own identity. + */ + it('does not relabel a snapshot-build failure as MCPCacheWriteError', async () => { + const { MCPCacheWriteError } = await import('../../../src/mcp/errors.js'); + const providerFailure = new Error('provider token refresh failed'); + const sets: unknown[] = []; + const store = { + get: () => Promise.resolve(null), + set: (_key: string, value: unknown) => { + sets.push(value); + return Promise.resolve(); + }, + delete: () => Promise.resolve(), + }; + + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + cacheCredentials: true, + cache: { + store, + key: 'k', + }, + auth: { + kind: 'oauth', + provider: { + // Fails on the refresh() below. The construction write is + // best-effort, so the first failure is swallowed by design; the + // explicit refresh must then report the provider's own error. + tokens: () => Promise.reject(providerFailure), + } as never, + }, + }); + + let caught: unknown; + try { + await handle.refresh(); + } catch (err) { + caught = err; + } + expect(caught).toBe(providerFailure); + expect(caught).not.toBeInstanceOf(MCPCacheWriteError); + // And nothing was written with a half-built payload. + expect(sets).toHaveLength(0); + }); + + /** + * Announcement is keyed on ADOPTION, not on error class. + * + * `refresh()` can fail after swapping `tools` for reasons other than the + * store op — `snapshot()` awaits the caller's OAuth `provider.tokens()`, + * which propagates untagged (deliberately: it is a credential failure, not a + * store outage). Subscribers must still hear about the adopted set, or they + * are permanently out of sync with `handle.tools`. + */ + it('notifies subscribers when a post-re-list snapshot build fails untagged', async () => { + let calls = 0; + state.listTools = () => { + calls += 1; + return Promise.resolve({ + tools: + calls === 1 + ? [] + : [ + { + name: 'brand_new', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ], + }); + }; + + let tokenCalls = 0; + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + cacheCredentials: true, + cache: { + store: { + get: () => Promise.resolve(null), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + }, + key: 'k', + }, + auth: { + kind: 'oauth', + provider: { + // Succeeds for the construction write, fails during the + // list_changed-triggered refresh — after the new tools were adopted. + tokens: () => { + tokenCalls += 1; + return tokenCalls === 1 + ? Promise.resolve({ + access_token: 't1', + token_type: 'bearer', + }) + : Promise.reject(new Error('provider outage')); + }, + } as never, + }, + }); + + const seen: number[] = []; + handle.onToolsChanged((next) => { + seen.push(next.length); + }); + + state.listChangedHandler?.(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // The snapshot build failed untagged, but the re-list succeeded and the + // handle adopted the new set — subscribers hear about it. + expect(seen).toEqual([ + 1, + ]); + expect(handle.tools).toHaveLength(1); + }); + + /** + * A store outage on the READ is a miss, not a failure. + * + * The write side became best-effort everywhere in this PR; leaving the read + * fatal meant "a store outage leaves you with a working handle" only held if + * the outage arrived after the lookup. A failing `store.get` now falls through + * to a fresh connect, exactly as a plain miss would. + */ + it('treats a failing cache read as a miss and connects fresh', async () => { + const brokenStore = { + get: () => Promise.reject(new Error('store unavailable')), + set: () => Promise.resolve(), + delete: () => Promise.resolve(), + }; + + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + cache: { + store: brokenStore, + key: 'k', + }, + }); + + // Fresh connect succeeded despite the unreadable cache. + expect(handle.tools).toHaveLength(0); + }); + + it('does not notify subscribers when the re-list itself fails', async () => { + let calls = 0; + state.listTools = () => { + calls += 1; + if (calls === 1) { + return Promise.resolve({ + tools: [], + }); + } + return Promise.reject(new Error('server gone')); + }; + + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + }); + const seen: number[] = []; + handle.onToolsChanged((next) => { + seen.push(next.length); + }); + + state.listChangedHandler?.(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Tools were never swapped, so silence is correct here. + expect(seen).toEqual([]); + expect(handle.tools).toHaveLength(0); + }); }); diff --git a/packages/agent/tests/unit/mcp/mcp-connection.test.ts b/packages/agent/tests/unit/mcp/mcp-connection.test.ts new file mode 100644 index 00000000..56afc01b --- /dev/null +++ b/packages/agent/tests/unit/mcp/mcp-connection.test.ts @@ -0,0 +1,1251 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MCPConnectionError } from '../../../src/mcp/errors.js'; + +// These tests exercise the real `connect()` — the transport selection and the +// Streamable HTTP -> SSE fallback — by faking the SDK's transports and client +// rather than mocking `mcp-connection.js` itself (which every other unit test +// does, leaving this path uncovered). + +interface Attempt { + kind: 'streamableHttp' | 'sse'; + sessionId?: string; +} + +interface SdkState { + attempts: Attempt[]; + /** Transport kinds whose `start()` should reject. */ + failing: Set<'streamableHttp' | 'sse'>; + /** + * When true, a connect rejects only while `versionNegotiation.mode` is + * `'auto'` — the probe-hostile gateway the legacy degradation exists for. + * Under `'legacy'` the same server connects fine. + */ + probeHostile: boolean; + /** When true, the fake Client's `close()` throws synchronously. */ + closeThrows: boolean; + /** When true, every connect rejects with the SDK's `UnauthorizedError`. */ + authFailure: boolean; + /** When set, only that transport rejects with `UnauthorizedError`. */ + authFailOn: 'streamableHttp' | 'sse' | undefined; + /** + * When set, every connect rejects with an error carrying this `status` — the + * shape the SDK's probe uses for HTTP failures instead of `UnauthorizedError`. + */ + httpErrorStatus: number | undefined; + /** When set, every connect rejects with exactly this error. */ + connectError: unknown; + /** sessionId the fake Streamable HTTP transport reports after connecting. */ + httpSessionId: string | undefined; + clientsCreated: number; + /** `versionNegotiation.mode` seen by each constructed Client, in order. */ + negotiationModes: unknown[]; + /** `versionNegotiation.probe.timeoutMs` seen by each Client, in order. */ + probeTimeouts: unknown[]; + /** `clientInfo` seen by each constructed Client, in order. */ + clientInfos: unknown[]; + /** `signal` passed to each `client.connect`, in order (undefined when none). */ + connectSignals: (AbortSignal | undefined)[]; + /** + * Transport kinds whose Client had `close()` called on it. Guards the + * release of a client whose `connect()` rejected — the SDK does not close a + * transport whose `start()` threw, so `connect()` has to. + */ + closedClients: ('streamableHttp' | 'sse' | 'unattached')[]; +} + +const state: SdkState = { + attempts: [], + failing: new Set(), + probeHostile: false, + closeThrows: false, + authFailure: false, + authFailOn: undefined, + httpErrorStatus: undefined, + connectError: undefined, + httpSessionId: undefined, + clientsCreated: 0, + negotiationModes: [], + probeTimeouts: [], + clientInfos: [], + connectSignals: [], + closedClients: [], +}; + +/** Explicit marker so the fake client can tell the two transports apart. */ +const KIND = Symbol('transport-kind'); + +interface Marked { + [KIND]: 'streamableHttp' | 'sse'; + sessionId: string | undefined; +} + +// SDK v2 ships Client and both transports from one package, so the three +// separate v1 module mocks collapse into this single factory. +// The real one is brand-based rather than prototype-based; a plain Error +// subclass is enough for the `cause`-chain walk under test, and keeps the fake +// self-contained. Safe to declare after `vi.mock` despite vitest hoisting that +// call, because the factory body only runs on first import of the mocked module. +class FakeUnauthorizedError extends Error {} + +vi.mock('@modelcontextprotocol/client', () => ({ + UnauthorizedError: FakeUnauthorizedError, + StreamableHTTPClientTransport: class { + [KIND] = 'streamableHttp' as const; + sessionId: string | undefined; + constructor( + _url: URL, + opts?: { + sessionId?: string; + }, + ) { + this.sessionId = opts?.sessionId; + } + start(): Promise { + return Promise.resolve(); + } + send(): Promise { + return Promise.resolve(); + } + close(): Promise { + return Promise.resolve(); + } + }, + SSEClientTransport: class { + [KIND] = 'sse' as const; + sessionId: string | undefined = undefined; + start(): Promise { + return Promise.resolve(); + } + send(): Promise { + return Promise.resolve(); + } + close(): Promise { + return Promise.resolve(); + } + }, + Client: class { + constructor( + info: unknown, + opts?: { + versionNegotiation?: { + mode?: unknown; + probe?: { + timeoutMs?: unknown; + }; + }; + }, + ) { + state.clientsCreated += 1; + state.negotiationModes.push(opts?.versionNegotiation?.mode); + state.probeTimeouts.push(opts?.versionNegotiation?.probe?.timeoutMs); + state.clientInfos.push(info); + this.mode = opts?.versionNegotiation?.mode; + } + // v2 registration is method-name-first; the fakes accept and ignore both args. + setRequestHandler(_method: string, _handler: unknown): void {} + setNotificationHandler(_method: string, _handler: unknown): void {} + /** This client's `versionNegotiation.mode`, for probe-hostile simulation. */ + mode: unknown; + /** Set by `connect()` so `close()` can report which transport it released. */ + attached: 'streamableHttp' | 'sse' | undefined; + connect( + transport: Marked, + requestOptions?: { + signal?: AbortSignal; + }, + ): Promise { + state.connectSignals.push(requestOptions?.signal); + const kind = transport[KIND]; + this.attached = kind; + const attempt: Attempt = { + kind, + }; + if (transport.sessionId !== undefined) { + attempt.sessionId = transport.sessionId; + } + state.attempts.push(attempt); + if (state.authFailure || state.authFailOn === kind) { + return Promise.reject(new FakeUnauthorizedError('unauthorized')); + } + if (state.connectError !== undefined) { + return Promise.reject(state.connectError); + } + if (state.httpErrorStatus !== undefined) { + return Promise.reject( + Object.assign(new Error(`http ${state.httpErrorStatus}`), { + status: state.httpErrorStatus, + }), + ); + } + if (state.failing.has(kind)) { + return Promise.reject(new Error(`${kind} refused`)); + } + // A probe-hostile server breaks any mode that probes. `'auto'` probes, and + // so does `{ pin }` (it demands a specific revision via `server/discover`); + // only `'legacy'` skips it and uses the classic `initialize` handshake. + if (state.probeHostile && this.mode !== 'legacy') { + return Promise.reject(new Error('server/discover probe timed out')); + } + if (kind === 'streamableHttp' && state.httpSessionId !== undefined) { + transport.sessionId = state.httpSessionId; + } + return Promise.resolve(); + } + close(): Promise { + state.closedClients.push(this.attached ?? 'unattached'); + if (state.closeThrows) { + // Synchronous throw, not a rejected promise — the case a bare + // `.catch()` on the call would fail to intercept. + throw new Error('close exploded'); + } + return Promise.resolve(); + } + }, +})); + +const { connect } = await import('../../../src/mcp/mcp-connection.js'); + +const URL_UNDER_TEST = new URL('https://example.invalid/mcp'); + +// Minimal OAuth auth: the 401/403 status check only counts when a provider is +// configured, since that is the one auth kind where a retry replays a side +// effect. The provider itself is never invoked by the fake transports. +const OAUTH_AUTH = { + kind: 'oauth', + provider: {} as never, +} as const; + +beforeEach(() => { + state.attempts = []; + state.failing = new Set(); + state.probeHostile = false; + state.closeThrows = false; + state.authFailure = false; + state.authFailOn = undefined; + state.httpErrorStatus = undefined; + state.connectError = undefined; + state.httpSessionId = undefined; + state.clientsCreated = 0; + state.negotiationModes = []; + state.probeTimeouts = []; + state.clientInfos = []; + state.connectSignals = []; + state.closedClients = []; +}); + +describe('connect transport selection', () => { + it('defaults to Streamable HTTP and does not touch SSE when it succeeds', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(conn.transport).toBe('streamableHttp'); + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + ]); + await conn.close(); + }); + + it('uses SSE directly when pinned, without trying Streamable HTTP first', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + transport: 'sse', + }); + + expect(conn.transport).toBe('sse'); + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'sse', + ]); + await conn.close(); + }); + + it('falls back to SSE on a fresh client when Streamable HTTP fails and no transport is pinned', async () => { + state.failing.add('streamableHttp'); + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(conn.transport).toBe('sse'); + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + 'sse', + ]); + // A fresh Client is built for the fallback: the failed one may be + // half-initialized, so reusing it would be unsound. + expect(state.clientsCreated).toBe(2); + await conn.close(); + }); + + it('does not fall back when Streamable HTTP was pinned explicitly', async () => { + state.failing.add('streamableHttp'); + + await expect( + connect({ + url: URL_UNDER_TEST, + transport: 'streamableHttp', + // Explicit, so the legacy-degradation retry stays out of the way: this + // test is about transport selection, not negotiation. + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(MCPConnectionError); + + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + ]); + }); + + it('throws MCPConnectionError naming both transports when both fail', async () => { + state.failing.add('streamableHttp'); + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(/Streamable HTTP and SSE/); + + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + 'sse', + ]); + }); + + /** + * Pinned SSE wraps like every other path. + * + * It used to rethrow the raw transport error, which made the type a caller sees + * depend on an unrelated option: with `protocolNegotiation` unset the outer + * retry aggregated it into an `MCPConnectionError`, with it set the raw error + * escaped. Same server, same failure, different `catch`. + */ + it('wraps a pinned SSE failure in MCPConnectionError', async () => { + state.failing.add('sse'); + + const err = await connect({ + url: URL_UNDER_TEST, + transport: 'sse', + protocolNegotiation: 'auto', + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(MCPConnectionError); + expect((err as Error).cause).toBeInstanceOf(Error); + expect(((err as Error).cause as Error).message).toMatch(/sse refused/); + }); + + it('propagates the SSE failure when SSE is pinned and fails', async () => { + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + transport: 'sse', + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(); + + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'sse', + ]); + }); +}); + +/** + * A client whose `connect()` rejected still holds its transport: the SDK stores + * the transport before calling `start()`, and when `start()` itself throws it + * returns without teardown — so nothing closes the socket. `connect()` releases + * it explicitly on every failure path. Without that, a probe timeout against a + * strict gateway leaks a keep-alive connection, and the `'auto'` default makes + * that the expected failure mode rather than a rare one. + */ +describe('connect releases failed clients', () => { + it('closes the failed Streamable HTTP client before falling back to SSE', async () => { + state.failing.add('streamableHttp'); + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(conn.transport).toBe('sse'); + // The failed HTTP client is released; the successful SSE one is left open + // for the caller, who closes it through the returned connection. + expect(state.closedClients).toEqual([ + 'streamableHttp', + ]); + await conn.close(); + }); + + it('closes the failed client when Streamable HTTP was pinned', async () => { + state.failing.add('streamableHttp'); + + await expect( + connect({ + url: URL_UNDER_TEST, + transport: 'streamableHttp', + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(MCPConnectionError); + + expect(state.closedClients).toEqual([ + 'streamableHttp', + ]); + }); + + it('closes both clients when Streamable HTTP and SSE fall through', async () => { + state.failing.add('streamableHttp'); + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(/Streamable HTTP and SSE/); + + expect(state.closedClients).toEqual([ + 'streamableHttp', + 'sse', + ]); + }); + + it('closes the failed client when SSE was pinned', async () => { + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + transport: 'sse', + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(); + + expect(state.closedClients).toEqual([ + 'sse', + ]); + }); + + /** + * The release must never become the error the caller sees. A `close()` that + * throws synchronously produces no rejected promise, so a bare + * `.catch(() => {})` on the call would not intercept it — the teardown failure + * would escape and replace the useful "couldn't reach the server" diagnosis + * with a misleading one, on the path where the diagnosis matters most. + */ + it('surfaces the connect error even when close() throws synchronously', async () => { + state.failing.add('streamableHttp'); + state.closeThrows = true; + + await expect( + connect({ + url: URL_UNDER_TEST, + transport: 'streamableHttp', + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(MCPConnectionError); + + // The close was attempted; its explosion was swallowed. + expect(state.closedClients).toEqual([ + 'streamableHttp', + ]); + }); + + it('still falls back to SSE when the failed client close() throws', async () => { + state.failing.add('streamableHttp'); + state.closeThrows = true; + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + // A teardown failure on the HTTP client must not prevent the fallback. + expect(conn.transport).toBe('sse'); + expect(state.closedClients).toEqual([ + 'streamableHttp', + ]); + // Not closing `conn` here: the flag would make the caller-facing close throw + // too, which is that method's contract (it does not swallow) and not what + // this test is about. + }); + + it('does not close the client on a successful connect', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(state.closedClients).toEqual([]); + await conn.close(); + }); +}); + +describe('connect session handling', () => { + it('surfaces the session id reported by the Streamable HTTP transport', async () => { + state.httpSessionId = 'session-from-server'; + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(conn.sessionId).toBe('session-from-server'); + await conn.close(); + }); + + it('replays a caller-supplied session id onto the transport', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + sessionId: 'resumed-session', + }); + + expect(state.attempts[0]?.sessionId).toBe('resumed-session'); + await conn.close(); + }); + + it('leaves sessionId absent for SSE, which has no protocol-level session', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + transport: 'sse', + }); + + expect(conn.sessionId).toBeUndefined(); + await conn.close(); + }); +}); + +describe('protocol negotiation', () => { + it("defaults to 'auto' so both protocol revisions work without configuration", async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(state.negotiationModes).toEqual([ + 'auto', + ]); + await conn.close(); + }); + + it("honours an explicit 'legacy' policy", async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + protocolNegotiation: 'legacy', + }); + + expect(state.negotiationModes).toEqual([ + 'legacy', + ]); + await conn.close(); + }); + + it('passes a pinned revision through untouched', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + protocolNegotiation: { + pin: '2026-07-28', + }, + }); + + expect(state.negotiationModes).toEqual([ + { + pin: '2026-07-28', + }, + ]); + await conn.close(); + }); + + it('applies the policy to the SSE fallback client too', async () => { + state.failing.add('streamableHttp'); + + const conn = await connect({ + url: URL_UNDER_TEST, + protocolNegotiation: 'legacy', + }); + + // Both the failed Streamable HTTP client and the fresh SSE one. + expect(state.negotiationModes).toEqual([ + 'legacy', + 'legacy', + ]); + await conn.close(); + }); +}); + +/** + * `clientInfo` is what every MCP server sees us as. The version half is + * generated from package.json and guarded by version.test.ts, but nothing + * asserted that the constant actually reaches the SDK constructor — so a + * refactor could drop it, or send a hardcoded value, with that guard still + * green. These close the loop between the generated constant and the wire. + */ +describe('connect clientInfo', () => { + it('self-reports the package name and generated version by default', async () => { + const { PACKAGE_VERSION } = await import('../../../src/mcp/version.js'); + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(state.clientInfos).toEqual([ + { + name: '@openrouter/mcp', + version: PACKAGE_VERSION, + }, + ]); + await conn.close(); + }); + + it('lets an explicit clientInfo override the default', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + clientInfo: { + name: 'my-app', + version: '9.9.9', + }, + }); + + expect(state.clientInfos).toEqual([ + { + name: 'my-app', + version: '9.9.9', + }, + ]); + await conn.close(); + }); + + it('carries the same clientInfo onto the SSE fallback client', async () => { + state.failing.add('streamableHttp'); + const { PACKAGE_VERSION } = await import('../../../src/mcp/version.js'); + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + // Both clients identify identically: a server that sees the fallback must + // not see a different client than the one that just probed it. + expect(state.clientInfos).toEqual([ + { + name: '@openrouter/mcp', + version: PACKAGE_VERSION, + }, + { + name: '@openrouter/mcp', + version: PACKAGE_VERSION, + }, + ]); + await conn.close(); + }); +}); + +/** + * `'auto'` degrades to the 2025-era handshake rather than failing. + * + * We default `protocolNegotiation` to `'auto'` where the SDK defaults to + * `'legacy'`, so every connection's first request is a `server/discover` probe. + * Alone that is a connectivity regression: a proxy, WAF, or strict gateway that + * hangs or 5xx's on an unknown method takes a working server to failing, and the + * SSE fallback re-probes and fails identically — so the two-transport fallback + * collapses to a single point of failure against exactly the infrastructure it + * should rescue. + * + * Retrying once with `'legacy'` makes `'auto'` strictly additive: modern servers + * get the new revision, everything else lands where it did before this package + * probed at all. + * + * The `probeHostile` fake models the real case precisely — it rejects only while + * `mode === 'auto'`, so a test that passes here would fail if the retry did not + * actually switch modes. + */ +describe('legacy degradation under an implicit auto default', () => { + it('retries with legacy and connects against a probe-hostile server', async () => { + state.probeHostile = true; + + // No `protocolNegotiation` — the implicit default, which is what a consumer + // who never configured negotiation gets. + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(conn.transport).toBe('streamableHttp'); + // Modes in order: the 'auto' attempt, its SSE re-probe, then the legacy retry. + expect(state.negotiationModes).toEqual([ + 'auto', + 'auto', + 'legacy', + ]); + }); + + it('does not retry when the first attempt succeeds', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(state.negotiationModes).toEqual([ + 'auto', + ]); + await conn.close(); + }); + + it('honours an explicit auto without degrading', async () => { + state.probeHostile = true; + + await expect( + connect({ + url: URL_UNDER_TEST, + protocolNegotiation: 'auto', + }), + // Asking for a mode means asking for its failures too. + ).rejects.toThrow(MCPConnectionError); + + expect(state.negotiationModes).not.toContain('legacy'); + }); + + it('honours an explicit pin without degrading', async () => { + state.probeHostile = true; + + await expect( + connect({ + url: URL_UNDER_TEST, + protocolNegotiation: { + pin: '2026-07-28', + }, + }), + // Silently falling back would defeat the entire point of pinning. + ).rejects.toThrow(MCPConnectionError); + + expect(state.negotiationModes).not.toContain('legacy'); + }); + + it('degrades on a pinned SSE transport too', async () => { + state.probeHostile = true; + + // Someone who pinned SSE did so because they have a legacy server — the most + // likely person to sit behind probe-hostile infrastructure, and the least + // likely to expect a probe. + const conn = await connect({ + url: URL_UNDER_TEST, + transport: 'sse', + }); + + expect(conn.transport).toBe('sse'); + expect(state.negotiationModes).toEqual([ + 'auto', + 'legacy', + ]); + await conn.close(); + }); + + /** + * The case the whole mechanism exists for, and the one an earlier revision of + * it broke: a legacy server reachable **only** over SSE, behind infrastructure + * that chokes on the probe. + * + * Under `'auto'` both transports fail — HTTP because the server doesn't speak + * it, SSE because the probe is refused. The retry therefore has to re-walk the + * ladder; pinning it to Streamable HTTP (which I did briefly, to cap the + * attempt count) means SSE is never offered again and a server that connected + * before this PR stops connecting. + */ + it('reaches an SSE-only server whose probe is refused', async () => { + state.probeHostile = true; + state.failing.add('streamableHttp'); + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + expect(conn.transport).toBe('sse'); + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + 'sse', + 'streamableHttp', + 'sse', + ]); + }); + + /** + * The cost of that guarantee: a genuinely dead server is dialled four times, + * two per negotiation mode. Asserted so the number is a decision rather than an + * accident — the bound that matters is that it is a fixed multiple, not a retry + * loop. + */ + it('caps a dead server at four attempts — two per mode', async () => { + // Not probe-hostile — the transport itself refuses, so the retry fails too. + state.failing.add('streamableHttp'); + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(MCPConnectionError); + + expect(state.negotiationModes).toEqual([ + 'auto', + 'auto', + 'legacy', + 'legacy', + ]); + }); + + it('releases every failed client across both attempts', async () => { + state.failing.add('streamableHttp'); + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(MCPConnectionError); + + // Four clients built, four released — the retry must not leak the transports + // of the attempt that preceded it. + expect(state.clientsCreated).toBe(4); + expect(state.closedClients).toHaveLength(4); + }); + + /** + * A hanging gateway must not cost four full request timeouts. + * + * The SDK falls back to the whole request timeout (60s) for the probe when + * `probe.timeoutMs` is unset. Under `'auto'` the probe is the first request of + * every connection, and with the legacy retry re-walking the ladder that is up + * to four attempts — roughly four minutes before `createMCPTools()` rejects, on + * the path a caller gets with no configuration at all. + */ + it('bounds the probe below the SDK request timeout', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + }); + + // Half the SDK's 60s default rather than a tight cap: a probe timeout is not + // recoverable (HTTP treats it as an outage, and the legacy retry sends an + // `initialize` that 2026-07-28 removed), so a value tight enough to trip a + // cold start would make a modern-only server unreachable rather than slow. + expect(state.probeTimeouts).toEqual([ + 30_000, + ]); + await conn.close(); + }); + + /** + * The default is stated in four places — the constant, two JSDoc sites, the + * README, and the changeset — and it has already drifted once: it moved 5s → 30s + * while three JSDoc comments kept saying 5000. This pins the source files + * against the constant so the next change to it fails here rather than shipping + * hover text that is six times wrong. + */ + it('documents the same probe default that the code applies', async () => { + const { readFileSync } = await import('node:fs'); + const { dirname, join } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'src', 'mcp'); + + const conn = await connect({ + url: URL_UNDER_TEST, + }); + const applied = state.probeTimeouts[0]; + await conn.close(); + + expect(typeof applied).toBe('number'); + for (const file of [ + 'types.ts', + 'rehydrate.ts', + 'mcp-connection.ts', + ]) { + const text = readFileSync(join(srcDir, file), 'utf8'); + // Every number stated near a probe-default sentence must be the value the + // code actually passes. The pattern tolerates a symbol name and parens + // between "efaults to" and the digits — `mcp-connection.ts` writes + // "Defaults to `DEFAULT_PROBE_TIMEOUT_MS` (30000)". + const matches = [ + ...text.matchAll(/probe[\s\S]{0,120}?efaults to[^\d]{0,80}(\d[\d_]*)/gi), + ]; + // Vacuous passes defeat the point: each of these files documents the + // default, so zero matches means the regex has drifted from the prose, not + // that the file went silent. + expect(matches.length, `no probe-default sentence matched in ${file}`).toBeGreaterThan(0); + for (const match of matches) { + expect(Number(match[1]?.replaceAll('_', '')), `stale probe default in ${file}`).toBe( + applied, + ); + } + } + }); + + it('lets a caller raise the probe timeout for a slow server', async () => { + const conn = await connect({ + url: URL_UNDER_TEST, + probeTimeoutMs: 30_000, + }); + + expect(state.probeTimeouts).toEqual([ + 30_000, + ]); + await conn.close(); + }); + + /** + * `errors` entries must be real failures, not wrappers. + * + * A single-transport pass wraps its one failure with only `cause` set, so + * without unwrapping that case the aggregated list becomes two opaque + * `MCPConnectionError`s — and a caller scanning for a rejected token would have + * to dig through `cause` on some entries but not others. + */ + it('unwraps single-transport passes so errors holds real failures', async () => { + // Pinned Streamable HTTP: each pass wraps its single failure in an + // `MCPConnectionError` whose own `errors` is empty, so this is the case where + // the aggregated list would otherwise be two opaque wrappers. (Pinned SSE + // rethrows the raw error, so it never had the problem.) + state.failing.add('streamableHttp'); + + const err = await connect({ + url: URL_UNDER_TEST, + transport: 'streamableHttp', + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(MCPConnectionError); + const { errors } = err as MCPConnectionError; + // One attempt per pass, both unwrapped to the underlying transport error. + expect(errors).toHaveLength(2); + for (const nested of errors) { + expect(nested).not.toBeInstanceOf(MCPConnectionError); + expect((nested as Error).message).toMatch(/streamableHttp refused/); + } + }); + + /** + * Node's happy-eyeballs path and some fetch implementations report a 401 as an + * `AggregateError` member rather than as a `cause`. A spine-only walk misses it + * and re-drives the OAuth flow. + */ + it('finds an auth failure inside an AggregateError', async () => { + state.connectError = new AggregateError( + [ + new Error('ipv6 refused'), + Object.assign(new Error('http 401'), { + status: 401, + }), + ], + 'all addresses failed', + ); + + await expect( + connect({ + url: URL_UNDER_TEST, + auth: OAUTH_AUTH, + }), + ).rejects.toThrow(); + + expect(state.negotiationModes).not.toContain('legacy'); + }); + + /** + * `errors` has to span both negotiation passes, not just the last. + * + * `MCPConnectionError.errors` documents itself as every failure in attempt + * order. If the retry's rejection propagated untouched, the `'auto'` pass's + * failures would vanish — half the attempts, plus any auth-shaped rejection + * `isAuthFailure` didn't match — and someone debugging an unreachable server + * would be reading a partial record while the docs promised a complete one. + */ + /** + * `errors` is never empty — a single-attempt failure carries its own cause. + * + * The auth short-circuit rethrows the first pass's error untouched, and for a + * single-transport pass that error used to have `errors: []` while the docs + * promised "every underlying failure". A caller iterating `errors` alone saw + * nothing precisely in the auth case, where the rejection is the one thing + * worth finding. + */ + it('populates errors even on the single-attempt auth short-circuit', async () => { + state.authFailure = true; + + const err = await connect({ + url: URL_UNDER_TEST, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(MCPConnectionError); + const { errors } = err as MCPConnectionError; + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(FakeUnauthorizedError); + }); + + it('reports every attempt across both negotiation passes', async () => { + state.failing.add('streamableHttp'); + state.failing.add('sse'); + + const err = await connect({ + url: URL_UNDER_TEST, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(MCPConnectionError); + // Four attempts: two transports × two negotiation modes, flat rather than a + // tree of wrappers so a caller can iterate without recursing. + expect((err as MCPConnectionError).errors).toHaveLength(4); + for (const nested of (err as MCPConnectionError).errors) { + expect(nested).not.toBeInstanceOf(MCPConnectionError); + } + // `cause` still points at the last thing tried. + expect((err as MCPConnectionError).cause).toBeInstanceOf(Error); + }); + + /** + * An auth rejection does not always arrive as `UnauthorizedError`. + * + * The version-negotiation probe doesn't route 401/403 through the OAuth flow — + * `classifyHttpError` turns them into an `SdkHttpError` with + * `ClientHttpAuthentication` / `ClientHttpForbidden`. So a probe rejected for + * auth reasons is a different type entirely, and a guard keyed only on + * `UnauthorizedError` would retry it and re-drive the flow. + */ + it('suppresses the retry for a 401 status under OAuth', async () => { + state.httpErrorStatus = 401; + + await expect( + connect({ + url: URL_UNDER_TEST, + auth: OAUTH_AUTH, + }), + ).rejects.toThrow(); + + expect(state.negotiationModes).not.toContain('legacy'); + }); + + /** + * A 403 degrades even under OAuth. The SDK's PKCE side effects + * (`saveCodeVerifier`, `redirectToAuthorization`) live exclusively behind its + * `status === 401 && authProvider` branch — a 403 never enters the OAuth flow, + * so a retry after one re-drives nothing. Suppressing on 403 made OAuth + * deployments behind WAFs that 403 unknown methods permanently unreachable — + * the exact scenario the legacy retry exists to rescue. + */ + it('still degrades on a 403 under OAuth', async () => { + state.httpErrorStatus = 403; + + await expect( + connect({ + url: URL_UNDER_TEST, + auth: OAUTH_AUTH, + }), + ).rejects.toThrow(); + + expect(state.negotiationModes).toContain('legacy'); + }); + + /** + * Without OAuth, a 401/403 must NOT suppress the degradation. + * + * Proxies and WAFs commonly answer an unknown method like `server/discover` + * with 403 — the probe-hostile infrastructure the legacy retry exists to + * rescue. With bearer, headers, or no auth there is no side effect to replay, + * so suppressing there turns the retry's own target scenario into a hard + * failure: the guard would cancel the recovery precisely where it was needed. + */ + it('still degrades on a 403 when no OAuth provider is configured', async () => { + state.httpErrorStatus = 403; + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(); + + // The retry ran: the gateway rejects every mode in this fake, but the + // degradation was attempted rather than suppressed. + expect(state.negotiationModes).toContain('legacy'); + }); + + it('still degrades on a 403 under bearer auth', async () => { + state.httpErrorStatus = 403; + + await expect( + connect({ + url: URL_UNDER_TEST, + auth: { + kind: 'bearer', + token: 'tok', + }, + }), + ).rejects.toThrow(); + + expect(state.negotiationModes).toContain('legacy'); + }); + + /** + * A non-Error payload carrying `status: 401` must not suppress the retry. + * + * The guard is duck-typed on `status`, which is the stable half of the SDK's + * contract — but a plain object with that field is far more likely to be a + * response or a log record riding along in a `cause` than an authoritative + * rejection (the SDK builds log entries with `status: 0`). Treating one as a + * credential failure would silently suppress the retry and make a + * probe-hostile-but-authenticated server unreachable — the regression the + * retry exists to prevent. + */ + it('ignores a status on a non-Error payload', async () => { + state.connectError = Object.assign(new Error('gateway barfed'), { + cause: { + status: 401, + note: 'upstream response, not our rejection', + }, + }); + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(); + + expect(state.negotiationModes).toContain('legacy'); + }); + + it('still retries a non-auth HTTP status', async () => { + // 404 is the SSE-endpoint-doesn't-exist case, not a credentials problem — + // guards against the status check over-matching and disabling degradation. + state.httpErrorStatus = 404; + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(); + + expect(state.negotiationModes).toContain('legacy'); + }); + + /** + * The auth guard has to see *both* transport attempts, not just the last. + * + * When Streamable HTTP 401s (OAuth flow driven once) and the SSE fallback then + * fails for an unrelated reason — the same URL answering 404 to an SSE GET, + * which never reaches the auth path — the `cause` spine holds only the SSE + * error. A guard reading `cause` alone would miss the `UnauthorizedError` and + * retry, re-driving `redirectToAuthorization` and overwriting the stored PKCE + * verifier. + */ + it('suppresses the retry when only the HTTP attempt was an auth failure', async () => { + state.authFailOn = 'streamableHttp'; + state.failing.add('sse'); + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(MCPConnectionError); + + // Stops at the HTTP auth failure: no SSE fallback (which would re-drive the + // OAuth flow) and no legacy retry behind it. + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + ]); + expect(state.negotiationModes).not.toContain('legacy'); + }); + + /** + * An auth rejection means the transport reached the server and the credentials + * were refused — nothing a different protocol revision changes. Retrying would + * re-drive an OAuth provider's authorization flow: a second + * `redirectToAuthorization`, a second saved PKCE verifier overwriting the + * first. Replaying a failure that had side effects is worse than not retrying. + */ + /** + * `signal` reaches every SDK connect, and an aborted caller is not retried. + * + * Without threading, a caller with its own deadline had no way to bound the + * ladder — whose worst case on the default path is ~3 minutes across four + * attempts. And retrying after the caller aborted would immediately re-abort + * or outlive the deadline the signal expressed. + */ + it('threads the abort signal into the SDK connect', async () => { + const controller = new AbortController(); + + const conn = await connect({ + url: URL_UNDER_TEST, + signal: controller.signal, + }); + + expect(state.connectSignals).toEqual([ + controller.signal, + ]); + await conn.close(); + }); + + /** + * An abort arriving mid-attempt stops the ladder inside the pass, too — not + * only the legacy retry. The same signal rides into the SSE attempt, but + * whether the SDK interrupts `transport.start()` promptly is its business; + * the explicit check makes "abort means stop dialling" deterministic. + */ + it('does not fall back to SSE when the caller aborted mid-attempt', async () => { + const controller = new AbortController(); + // The HTTP attempt fails *because* the abort landed during it. + state.failing.add('streamableHttp'); + controller.abort(); + + await expect( + connect({ + url: URL_UNDER_TEST, + signal: controller.signal, + // Pinned negotiation isolates the in-pass ladder from the outer retry. + protocolNegotiation: 'auto', + }), + ).rejects.toThrow(MCPConnectionError); + + // One dial: no SSE attempt behind an aborted caller. + expect(state.attempts.map((a) => a.kind)).toEqual([ + 'streamableHttp', + ]); + }); + + it('does not run the legacy retry when the caller aborted', async () => { + const controller = new AbortController(); + state.failing.add('streamableHttp'); + state.failing.add('sse'); + // Simulate the abort arriving during the first pass. + controller.abort(); + + await expect( + connect({ + url: URL_UNDER_TEST, + signal: controller.signal, + }), + ).rejects.toThrow(); + + // Only the 'auto' pass ran; no 'legacy' behind an aborted caller. + expect(state.negotiationModes).not.toContain('legacy'); + }); + + it('does not retry an auth failure', async () => { + state.authFailure = true; + + await expect( + connect({ + url: URL_UNDER_TEST, + }), + ).rejects.toThrow(MCPConnectionError); + + // One attempt only. The ladder short-circuits too: an SSE fallback would + // carry the same `authProvider` into the SDK's auth path and drive a second + // `redirectToAuthorization`, which is the duplicated side effect this guard + // exists to prevent — inside a single pass the caller didn't even opt into. + expect(state.negotiationModes).toEqual([ + 'auto', + ]); + expect(state.attempts).toHaveLength(1); + }); + + it('detects an auth failure nested inside the wrapper error', async () => { + // Guards the `cause`-chain walk specifically: `connectWithNegotiation` wraps + // transport errors in `MCPConnectionError`, so a bare top-level `instanceof` + // check would miss the nested `UnauthorizedError` and retry anyway — + // re-driving the authorization flow these tests exist to prevent. + state.authFailure = true; + + const err = await connect({ + url: URL_UNDER_TEST, + }).catch((e: unknown) => e); + + // Wrapped, not bare — which is exactly why the walk is needed. + expect(err).toBeInstanceOf(MCPConnectionError); + expect((err as Error).cause).toBeInstanceOf(FakeUnauthorizedError); + }); +}); diff --git a/packages/agent/tests/unit/mcp/protocol-era.test.ts b/packages/agent/tests/unit/mcp/protocol-era.test.ts new file mode 100644 index 00000000..74c38024 --- /dev/null +++ b/packages/agent/tests/unit/mcp/protocol-era.test.ts @@ -0,0 +1,661 @@ +import type { Transport } from '@modelcontextprotocol/client'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { describe, expect, it } from 'vitest'; +import type { MCPConnection } from '../../../src/mcp/mcp-connection.js'; + +// Proves the SDK negotiates BOTH protocol revisions — 2025-11-25 ("legacy", +// `initialize` handshake) and 2026-07-28 ("modern", per-request `_meta` +// envelope with no handshake). Everything here runs over InMemoryTransport, so +// there is no network, no fixture process, and no MCP_TEST_URL gate. +// +// These tests are what protect the assumptions the rest of the package leans +// on: that `getServerVersion()` / `getServerCapabilities()` stay populated in +// the modern era (handle.ts reads both synchronously), that `sessionId` simply +// goes undefined rather than erroring, and that one elicitation handler serves +// both eras. + +type JsonRpc = { + jsonrpc: '2.0'; + id?: string | number; + method?: string; + params?: Record; + result?: unknown; + error?: unknown; +}; + +interface ServerBehavior { + /** Answer `server/discover` as a 2026-07-28 server. */ + modern: boolean; + /** Methods the fake server saw, in order. */ + seen: string[]; + /** When set, `tools/call` demands input once via an input_required result. */ + demandInput?: boolean; + /** `ttlMs` the fake reports on `tools/list`; defaults to 0 (uncacheable). */ + toolsListTtlMs?: number; +} + +/** + * Minimal hand-rolled MCP server over one end of a linked transport pair. + * Responds only to what these tests exercise. + */ +function startFakeServer(serverSide: Transport, behavior: ServerBehavior): void { + let callRound = 0; + + serverSide.onmessage = (raw: unknown) => { + const msg = raw as JsonRpc; + const method = msg.method; + if (method === undefined) { + return; + } + behavior.seen.push(method); + + // Notifications carry no id and expect no reply. + if (msg.id === undefined) { + return; + } + const id = msg.id; + const reply = (result: unknown): void => { + void serverSide.send({ + jsonrpc: '2.0', + id, + result, + } as never); + }; + const replyError = (code: number, message: string): void => { + void serverSide.send({ + jsonrpc: '2.0', + id, + error: { + code, + message, + }, + } as never); + }; + + if (method === 'server/discover') { + if (!behavior.modern) { + // A legacy server has never heard of this method. + replyError(-32601, 'Method not found'); + return; + } + reply({ + resultType: 'complete', + // NOTE: the field is `supportedVersions`, not `protocolVersions`. + supportedVersions: [ + '2026-07-28', + ], + capabilities: { + tools: {}, + resources: {}, + }, + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'fake-modern', + version: '9.9.9', + }, + }, + }); + return; + } + + if (method === 'initialize') { + reply({ + protocolVersion: '2025-11-25', + serverInfo: { + name: 'fake-legacy', + version: '1.2.3', + }, + capabilities: { + tools: {}, + resources: {}, + }, + }); + return; + } + + if (method === 'tools/list') { + reply({ + resultType: 'complete', + // Non-zero for the cache-invalidation test below, which needs the SDK to + // actually cache the response so we can prove the notification evicts it. + ttlMs: behavior.toolsListTtlMs ?? 0, + cacheScope: 'public', + tools: [ + { + name: 'needs_input', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ], + }); + return; + } + + if (method === 'tools/call') { + callRound += 1; + if (behavior.demandInput === true && callRound === 1) { + reply({ + resultType: 'input_required', + requestState: 'opaque-state-blob', + // `inputRequests` is an object keyed by request id, not an array. + inputRequests: { + r1: { + method: 'elicitation/create', + params: { + message: 'Your name?', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: [ + 'name', + ], + }, + }, + }, + }, + }); + return; + } + reply({ + resultType: 'complete', + content: [ + { + type: 'text', + text: 'done', + }, + ], + }); + return; + } + + reply({ + resultType: 'complete', + }); + }; + void serverSide.start(); +} + +interface Harness { + client: Client; + behavior: ServerBehavior; + clientSide: Transport; +} + +async function connectTo(options: { + modern: boolean; + mode: + | 'legacy' + | 'auto' + | { + pin: string; + }; + demandInput?: boolean; + onElicit?: () => { + action: 'accept'; + content: Record; + }; +}): Promise { + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + const behavior: ServerBehavior = { + modern: options.modern, + seen: [], + ...(options.demandInput !== undefined && { + demandInput: options.demandInput, + }), + }; + startFakeServer(serverSide, behavior); + + const client = new Client( + { + name: 'era-test', + version: '0.0.0', + }, + { + capabilities: { + elicitation: {}, + }, + versionNegotiation: { + mode: options.mode, + probe: { + timeoutMs: 2000, + }, + }, + }, + ); + if (options.onElicit !== undefined) { + const handler = options.onElicit; + client.setRequestHandler('elicitation/create', () => handler()); + } + await client.connect(clientSide); + return { + client, + behavior, + clientSide, + }; +} + +describe("mode: 'auto' against a legacy (2025-11-25) server", () => { + it('probes server/discover, then falls back to the initialize handshake', async () => { + const { client, behavior } = await connectTo({ + modern: false, + mode: 'auto', + }); + + expect(behavior.seen[0]).toBe('server/discover'); + expect(behavior.seen).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + it('still reports server identity and capabilities', async () => { + const { client } = await connectTo({ + modern: false, + mode: 'auto', + }); + + expect(client.getServerVersion()?.name).toBe('fake-legacy'); + expect(client.getServerCapabilities()?.resources).toBeDefined(); + await client.close(); + }); +}); + +describe("mode: 'auto' against a modern (2026-07-28) server", () => { + it('never sends initialize — the handshake is removed in this revision', async () => { + const { client, behavior } = await connectTo({ + modern: true, + mode: 'auto', + }); + + expect(behavior.seen[0]).toBe('server/discover'); + expect(behavior.seen).not.toContain('initialize'); + expect(client.getProtocolEra()).toBe('modern'); + await client.close(); + }); + + it('populates serverInfo and capabilities from server/discover', async () => { + // handle.ts reads both of these synchronously; if the modern era left them + // empty, resource tools would silently disappear and snapshots would lose + // their serverInfo. + const { client } = await connectTo({ + modern: true, + mode: 'auto', + }); + + expect(client.getServerVersion()).toEqual({ + name: 'fake-modern', + version: '9.9.9', + }); + expect(client.getServerCapabilities()?.resources).toBeDefined(); + await client.close(); + }); + + it('leaves sessionId undefined — protocol sessions are removed (SEP-2567)', async () => { + const { client, clientSide } = await connectTo({ + modern: true, + mode: 'auto', + }); + + expect(clientSide.sessionId).toBeUndefined(); + await client.close(); + }); + + it('still lists tools', async () => { + const { client } = await connectTo({ + modern: true, + mode: 'auto', + }); + + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual([ + 'needs_input', + ]); + await client.close(); + }); +}); + +describe('multi-round-trip input (SEP-2322)', () => { + it('fulfils input_required through the same elicitation handler and retries', async () => { + // This is what justifies keeping `onElicitation` rather than deprecating + // it: the 2026-07-28 era has no server-initiated elicitation/create, but + // the MRTR driver dispatches through the very same registered handler. + let calls = 0; + const { client, behavior } = await connectTo({ + modern: true, + mode: 'auto', + demandInput: true, + onElicit: () => { + calls += 1; + return { + action: 'accept', + content: { + name: 'Luke', + }, + }; + }, + }); + + const result = await client.callTool({ + name: 'needs_input', + arguments: {}, + }); + + expect(calls).toBe(1); + // Two tools/call round trips: the input_required answer, then the retry. + expect(behavior.seen.filter((m) => m === 'tools/call')).toHaveLength(2); + expect(result.content).toEqual([ + { + type: 'text', + text: 'done', + }, + ]); + await client.close(); + }); +}); + +describe('pinning', () => { + it('fails loudly when a pinned revision is not offered', async () => { + await expect( + connectTo({ + modern: false, + mode: { + pin: '2026-07-28', + }, + }), + ).rejects.toThrow(); + }); + + it('connects in the modern era when the pin is offered', async () => { + const { client } = await connectTo({ + modern: true, + mode: { + pin: '2026-07-28', + }, + }); + + expect(client.getProtocolEra()).toBe('modern'); + await client.close(); + }); +}); + +/** + * The `tools/list_changed` wiring, end to end over a real transport. + * + * `mcp-connection.ts` registers the subscription with a bare string method name + * rather than an SDK schema value. The name itself is compile-checked — the + * parameter is a `NotificationMethod` literal union, so a typo fails `tsc` + * (verified: mutating it to `'notifications/tools/list_changedX'` produces + * TS2345). What the type cannot check is *dispatch*: that an inbound + * notification actually reaches the callback registered via + * `setToolListChangedHandler`. + * + * That gap matters because `autoRefreshOnListChanged` defaults to on, so a + * broken dispatch path means tool-list refreshes silently never happen — the + * same failure shape as the `callTool` arity bug this PR fixes, which was also + * invisible to a green test suite. + * + * These drive the real `makeClient` from `mcp-connection.ts` (via the + * `makeClientForTest` seam), not a locally-built `Client`, so the assertion + * covers our registration rather than a restatement of the SDK's. + */ +describe('tools/list_changed dispatch', () => { + async function connectRealClient( + modern: boolean, + toolsListTtlMs?: number, + ): Promise<{ + client: Client; + serverSide: Transport; + fired: () => number; + seen: string[]; + }> { + const { makeClientForTest } = await import('../../../src/mcp/mcp-connection.js'); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + const seen: string[] = []; + startFakeServer(serverSide, { + modern, + seen, + ...(toolsListTtlMs !== undefined && { + toolsListTtlMs, + }), + }); + + let count = 0; + const client = await makeClientForTest( + { + url: new URL('https://example.invalid/mcp'), + }, + () => { + count += 1; + }, + ); + await client.connect(clientSide); + return { + client, + serverSide, + fired: () => count, + seen, + }; + } + + async function emitListChanged(serverSide: Transport): Promise { + await serverSide.send({ + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + } as never); + // Notifications are fire-and-forget in both directions; yield so the + // client's handler runs before we assert. + await new Promise((resolve) => setImmediate(resolve)); + } + + it('routes the notification to the registered handler in the modern era', async () => { + const { client, serverSide, fired } = await connectRealClient(true); + + expect(client.getProtocolEra()).toBe('modern'); + expect(fired()).toBe(0); + + await emitListChanged(serverSide); + + expect(fired()).toBe(1); + await client.close(); + }); + + it('routes the notification to the registered handler in the legacy era', async () => { + const { client, serverSide, fired } = await connectRealClient(false); + + expect(client.getProtocolEra()).toBe('legacy'); + + await emitListChanged(serverSide); + + // One handler serves both revisions — the same guarantee the elicitation + // tests above establish for requests. + expect(fired()).toBe(1); + await client.close(); + }); + + /** + * The SDK keeps a per-client response cache (24h ceiling), and `makeClient` + * deliberately opts out of `ClientOptions.listChanged` so the handle's own + * `refresh()` owns the re-list. Devin raised the question that follows: if the + * cache were invalidated by the SDK's `listChanged` machinery — the machinery + * we opt out of — then `refresh()`'s `listTools()` would be served from cache + * and `autoRefreshOnListChanged` would silently do nothing. + * + * It isn't. Eviction lives in the base `_onnotification` dispatcher, keyed off + * the notification method (`notifications/tools/list_changed` → evict + * `tools/list`), so it fires for any inbound notification regardless of how the + * handler was registered. This test is the executable form of that claim, + * because reading the SDK proves it today and a test proves it after the next + * version bump. + * + * `ttlMs` must be non-zero here: with the default 0 the response is + * uncacheable, so a cache bug would be invisible. + */ + it('evicts the SDK response cache so a post-notification re-list hits the wire', async () => { + const { client, serverSide, seen } = await connectRealClient(true, 60_000); + + await client.listTools(); + const afterFirst = seen.filter((m) => m === 'tools/list').length; + expect(afterFirst).toBe(1); + + // Second call with no notification in between: served from the SDK cache. + await client.listTools(); + expect(seen.filter((m) => m === 'tools/list')).toHaveLength(1); + + await emitListChanged(serverSide); + + // Now it must reach the server again — otherwise `refresh()` would return + // the stale tool set and auto-refresh would be a silent no-op. + await client.listTools(); + expect(seen.filter((m) => m === 'tools/list')).toHaveLength(2); + + await client.close(); + }); + + it('fires once per notification', async () => { + const { client, serverSide, fired } = await connectRealClient(true); + + await emitListChanged(serverSide); + await emitListChanged(serverSide); + + expect(fired()).toBe(2); + await client.close(); + }); +}); + +/** + * `listToolDefs` must reach the server every time, even inside the SDK's + * response-cache TTL. + * + * SDK v2 caches `tools/list` per client, honouring the server's `ttlMs` up to a + * 24h ceiling. Under the default `cacheMode: 'use'` that makes + * `MCPToolsHandle.refresh()` a liar — it documents a forced re-read but would + * return the cached list, so an app calling `refresh()` to pick up newly added + * server tools could keep the old set for as long as the server allows reuse. + * A behavior change introduced by the v1 → v2 migration, since v1 had no + * response cache. + * + * The `tools/list_changed` path was already safe (the SDK evicts in its + * notification dispatcher), so this covers the consumer-initiated path that has + * no notification to trigger eviction. + */ +describe('listToolDefs bypasses the SDK response cache', () => { + it('hits the wire on every call despite a live cache entry', async () => { + const { makeClientForTest } = await import('../../../src/mcp/mcp-connection.js'); + const { listToolDefs } = await import('../../../src/mcp/handle.js'); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + const seen: string[] = []; + startFakeServer(serverSide, { + modern: true, + seen, + // Long enough that a cached read would definitely be served. + toolsListTtlMs: 60_000, + }); + + const client = await makeClientForTest( + { + url: new URL('https://example.invalid/mcp'), + }, + () => {}, + ); + await client.connect(clientSide); + const connection: MCPConnection = { + client, + transport: 'streamableHttp', + setToolListChangedHandler: () => {}, + close: () => client.close(), + }; + + await listToolDefs(connection, undefined); + expect(seen.filter((m) => m === 'tools/list')).toHaveLength(1); + + // The assertion that matters: a second read inside the TTL. A plain + // `client.listTools()` here would still be 1 (proven by the eviction test + // above), so this only passes because `listToolDefs` sends + // `cacheMode: 'refresh'`. + await listToolDefs(connection, undefined); + expect(seen.filter((m) => m === 'tools/list')).toHaveLength(2); + + await listToolDefs(connection, undefined); + expect(seen.filter((m) => m === 'tools/list')).toHaveLength(3); + + await client.close(); + }); +}); + +/** + * The probe timeout on the production client. + * + * `makeClient` sets only `versionNegotiation.mode`, so with `'auto'` now the + * default every connection's first request is a `server/discover` probe governed + * by the SDK's *default* timeout. Devin flagged that no test exercised that + * default — the tests above build their own `Client` with an explicit + * `probe: { timeoutMs: 2000 }`, and `mcp-connection.test.ts` fakes the `Client` + * entirely — so a change to an unbounded default upstream would land silently on + * the critical path of every `createMCPTools()` call. + * + * It is bounded: `negotiateEra` resolves `negotiation.probe.timeoutMs ?? + * deps.defaultTimeoutMs`, which `_connectNegotiated` fills from `options?.timeout + * ?? DEFAULT_REQUEST_TIMEOUT_MSEC` (60s). So a gateway that black-holes + * `server/discover` rejects rather than hanging forever. This pins that. + */ +/** + * The probe block is passed unconditionally — including under `'legacy'`, where + * no `server/discover` is sent and the field should be inert. This pins that the + * real SDK constructor tolerates the combination rather than validating it away: + * both the implicit legacy retry and an explicit `protocolNegotiation: 'legacy'` + * construct exactly this shape. + */ +describe('probe options under legacy mode', () => { + it('connects with a probe block alongside mode legacy', async () => { + const { makeClientForTest } = await import('../../../src/mcp/mcp-connection.js'); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + startFakeServer(serverSide, { + modern: false, + seen: [], + }); + + const client = await makeClientForTest( + { + url: new URL('https://example.invalid/mcp'), + protocolNegotiation: 'legacy', + }, + () => {}, + ); + await expect(client.connect(clientSide)).resolves.toBeUndefined(); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); +}); + +describe('probe timeout default', () => { + it('bounds the probe on a client built the way production builds it', async () => { + const { makeClientForTest } = await import('../../../src/mcp/mcp-connection.js'); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + + // A server that accepts the probe and never answers it — the black-hole + // gateway case. No fake server: nothing is wired to `serverSide.onmessage`. + serverSide.onmessage = () => {}; + + const client = await makeClientForTest( + { + url: new URL('https://example.invalid/mcp'), + probeTimeoutMs: 150, + }, + () => {}, + ); + + // The bound is asserted via `probeTimeoutMs` rather than `connect`'s + // `timeout`: this package now sets `probe.timeoutMs` explicitly, which takes + // precedence over the per-request timeout, so passing the latter would leave + // the test waiting out the real 30s default. A short override keeps it fast + // while still proving the probe honours the ceiling instead of hanging. + await expect(client.connect(clientSide)).rejects.toThrow(/timed out|timeout/i); + + await client.close().catch(() => {}); + }); +}); diff --git a/packages/agent/tests/unit/mcp/rehydrate.test.ts b/packages/agent/tests/unit/mcp/rehydrate.test.ts index aef001fe..b6371682 100644 --- a/packages/agent/tests/unit/mcp/rehydrate.test.ts +++ b/packages/agent/tests/unit/mcp/rehydrate.test.ts @@ -28,7 +28,7 @@ let closeThrowsSync = false; vi.mock('../../../src/mcp/mcp-connection.js', () => ({ // Minimal stand-in for the real guard: these tests drive auth failures with a // `FakeUnauthorized` whose marker the walk below recognises. - isAuthFailure: (err: unknown): boolean => + isAuthFailure: ({ err }: { err: unknown }): boolean => typeof err === 'object' && err !== null && 'isFakeAuthFailure' in err, connect: (options: ConnectOptions): Promise => { connectCalls.push(options); diff --git a/packages/agent/tests/unit/mcp/version.test.ts b/packages/agent/tests/unit/mcp/version.test.ts new file mode 100644 index 00000000..cf485c00 --- /dev/null +++ b/packages/agent/tests/unit/mcp/version.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { PACKAGE_VERSION } from '../../../src/mcp/version.js'; + +describe('MCP client PACKAGE_VERSION', () => { + it('matches the agent package version', () => { + const pkg = JSON.parse( + readFileSync(join(import.meta.dirname, '../../../package.json'), 'utf8'), + ); + expect(PACKAGE_VERSION).toBe(pkg.version); + }); +}); diff --git a/packages/agent/tests/unit/server-tool-id.test-d.ts b/packages/agent/tests/unit/server-tool-id.test-d.ts index bcb520e4..387883e6 100644 --- a/packages/agent/tests/unit/server-tool-id.test-d.ts +++ b/packages/agent/tests/unit/server-tool-id.test-d.ts @@ -1,9 +1,19 @@ -import type { ServerToolBase } from '@openrouter/agent'; -import { serverTool } from '@openrouter/agent'; import { expectTypeOf } from 'vitest'; -import { createToolSet } from '../../src/tool-set.js'; -import type { FilterToolsByIds, InferAllIds, ServerToolIdOf } from '../../src/types.js'; +import { z } from 'zod/v4'; +import { serverTool, tool } from '../../src/lib/tool.js'; +import { createToolSet } from '../../src/lib/tool-set.js'; +import type { + FilterToolsByIds, + InferAllIds, + ServerToolIdOf, +} from '../../src/lib/tool-set-types.js'; +import type { ServerToolBase } from '../../src/lib/tool-types.js'; +const local = tool({ + name: 'local', + inputSchema: z.object({}), + execute: async () => undefined, +}); const precise = serverTool( { type: 'web_search_2025_08_26', @@ -14,13 +24,14 @@ const precise = serverTool( ); expectTypeOf>().toEqualTypeOf<'server:public_search'>(); -const generalized: ServerToolBase = precise; +const erased: ServerToolBase = precise; const set = createToolSet({ tools: [ - generalized, + local, + erased, ] as const, }); -set.deactivate('any-runtime-server-tool-id'); +set.deactivate('server:public_search'); expectTypeOf>().toEqualTypeOf(); const handWritten = { diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 966c5303..39828ff8 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "esm", "rootDir": "src", // Resolve deps via published exports, not the repo-wide "source" condition: - // the optional peer "@modelcontextprotocol/sdk" (used by src/mcp) transitively + // the optional peer "@modelcontextprotocol/client" (used by src/mcp) transitively // exposes a "source" condition pointing at raw .ts files (eventsource). "customConditions": [] }, diff --git a/packages/agent/tsconfig.typecheck.json b/packages/agent/tsconfig.typecheck.json index 94c4c5d7..cf236ab7 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -1,6 +1,9 @@ { "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "rootDir": "." }, + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, "include": [ "src/**/*.ts", "tests/unit/context-schema-inference.test-d.ts", @@ -8,7 +11,11 @@ "tests/unit/has-approval-tools.test-d.ts", "tests/unit/mcp-result-discrimination.test-d.ts", "tests/unit/resolved-tools.test-d.ts", + "tests/unit/server-tool-id.test-d.ts", "tests/unit/tool-shared-name.test-d.ts" ], - "exclude": ["node_modules", "esm"] + "exclude": [ + "node_modules", + "esm" + ] } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 6be7ebf3..7f748980 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -23,13 +23,13 @@ Expose the tools of a remote [Model Context Protocol](https://modelcontextprotoc For new code, install the agent plus the optional MCP peer: ```bash -pnpm add @openrouter/agent @modelcontextprotocol/sdk +pnpm add @openrouter/agent @modelcontextprotocol/client ``` -Existing applications may continue installing the compatibility package: +Existing applications can keep installing only the compatibility package; it retains `@modelcontextprotocol/client` as a dependency, so the prior transitive-install behavior is unchanged: ```bash -pnpm add @openrouter/mcp @openrouter/agent @modelcontextprotocol/sdk +pnpm add @openrouter/mcp ``` The agent package is marked `sideEffects: false`, and MCP code is exposed only diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 137d2616..22db355a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -33,12 +33,12 @@ "default": "./esm/types.js" }, "./schema": { - "types": "./esm/schema/json-schema-to-zod.d.ts", - "default": "./esm/schema/json-schema-to-zod.js" + "types": "./esm/schema.d.ts", + "default": "./esm/schema.js" }, "./cache": { - "types": "./esm/cache/cache-store.d.ts", - "default": "./esm/cache/cache-store.js" + "types": "./esm/cache.d.ts", + "default": "./esm/cache.js" }, "./package.json": "./package.json" }, @@ -60,13 +60,11 @@ "scripts": { "lint": "biome check src tests", "lint:fix": "biome check --write src tests", - "gen:version": "node scripts/gen-version.mjs", - "build": "node scripts/gen-version.mjs && tsc", + "build": "tsc", "test": "vitest --run --project unit", - "test:e2e": "vitest --run --project e2e --coverage.enabled=false", "test:watch": "vitest --watch --project unit", "typecheck": "tsc --noEmit", - "compile": "node scripts/gen-version.mjs && tsc" + "compile": "tsc" }, "dependencies": { "@modelcontextprotocol/client": "^2.0.0", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e8b2417a..8357373c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -10,6 +10,9 @@ export type { ElicitationResponse, MCPAuth, MCPCacheStore, + MCPOAuthClientProvider, + MCPProtocolNegotiation, + MCPProtocolRevision, MCPToolsHandle, MCPTransportKind, RehydrateMCPToolsOptions, @@ -26,9 +29,11 @@ export { InMemoryMCPCacheStore, isSerializedMCPServer, MCPCacheError, + MCPCacheWriteError, MCPConnectionError, MCPError, MCPMissingPeerDependencyError, + MCPStaleSnapshotError, MCPToolCallError, rehydrateMCPTools, } from '@openrouter/agent/mcp'; diff --git a/packages/mcp/tests/unit/cache.test.ts b/packages/mcp/tests/unit/cache.test.ts index e00043f9..8eec7579 100644 --- a/packages/mcp/tests/unit/cache.test.ts +++ b/packages/mcp/tests/unit/cache.test.ts @@ -1,6 +1,6 @@ import * as agentCache from '@openrouter/agent/mcp/cache'; -import * as wrapperCache from '@openrouter/mcp/cache'; import { describe, expect, it } from 'vitest'; +import * as wrapperCache from '../../src/cache.js'; describe('@openrouter/mcp/cache export parity', () => { it('exports exactly the same runtime binding names as @openrouter/agent/mcp/cache', () => { diff --git a/packages/mcp/tests/unit/create-mcp-tools.test.ts b/packages/mcp/tests/unit/create-mcp-tools.test.ts index e1771658..d182667f 100644 --- a/packages/mcp/tests/unit/create-mcp-tools.test.ts +++ b/packages/mcp/tests/unit/create-mcp-tools.test.ts @@ -1,6 +1,6 @@ import * as agentCreateMcpTools from '@openrouter/agent/mcp/create-mcp-tools'; -import * as wrapperCreateMcpTools from '@openrouter/mcp/create-mcp-tools'; import { describe, expect, it } from 'vitest'; +import * as wrapperCreateMcpTools from '../../src/create-mcp-tools.js'; describe('@openrouter/mcp/create-mcp-tools export parity', () => { it('exports exactly the same runtime binding names as @openrouter/agent/mcp/create-mcp-tools', () => { diff --git a/packages/mcp/tests/unit/index.test.ts b/packages/mcp/tests/unit/index.test.ts index c8e0ff37..4ec61274 100644 --- a/packages/mcp/tests/unit/index.test.ts +++ b/packages/mcp/tests/unit/index.test.ts @@ -1,6 +1,6 @@ import * as agentMcp from '@openrouter/agent/mcp'; -import * as wrapperMcp from '@openrouter/mcp'; import { describe, expect, it } from 'vitest'; +import * as wrapperMcp from '../../src/index.js'; // Compatibility/export-parity tests: @openrouter/mcp is a thin wrapper that // re-exports @openrouter/agent/mcp. These tests verify the wrapper's runtime @@ -23,3 +23,23 @@ describe('@openrouter/mcp root export parity with @openrouter/agent/mcp', () => } }); }); + +it('preserves the published runtime export surface', () => { + expect(Object.keys(wrapperMcp).sort()).toEqual( + [ + 'InMemoryMCPCacheStore', + 'MCPCacheError', + 'MCPCacheWriteError', + 'MCPConnectionError', + 'MCPError', + 'MCPMissingPeerDependencyError', + 'MCPStaleSnapshotError', + 'MCPToolCallError', + 'convertMcpInputSchema', + 'createMCPTools', + 'defaultCacheKey', + 'isSerializedMCPServer', + 'rehydrateMCPTools', + ].sort(), + ); +}); diff --git a/packages/mcp/tests/unit/schema.test.ts b/packages/mcp/tests/unit/schema.test.ts index 8067583c..fea8f149 100644 --- a/packages/mcp/tests/unit/schema.test.ts +++ b/packages/mcp/tests/unit/schema.test.ts @@ -1,6 +1,6 @@ import * as agentSchema from '@openrouter/agent/mcp/schema'; -import * as wrapperSchema from '@openrouter/mcp/schema'; import { describe, expect, it } from 'vitest'; +import * as wrapperSchema from '../../src/schema.js'; describe('@openrouter/mcp/schema export parity', () => { it('exports exactly the same runtime binding names as @openrouter/agent/mcp/schema', () => { diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index e757a909..3508bf50 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ enabled: true, provider: 'v8', include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts'], + exclude: ['src/close-quietly.ts', 'src/version.ts'], reporter: ['text', 'json-summary', 'html'], // Coverage ratchet: thresholds are pinned at current levels. Any PR // that lowers coverage fails the unit-test job. When you raise @@ -41,15 +41,6 @@ export default defineConfig({ hookTimeout: 10000, }, }, - { - extends: true, - test: { - name: 'e2e', - include: ['tests/e2e/**/*.test.ts'], - testTimeout: 30000, - hookTimeout: 30000, - }, - }, ], }, }); diff --git a/scripts/verify-package-boundaries.mjs b/scripts/verify-package-boundaries.mjs index 74478e16..c34cb0a7 100644 --- a/scripts/verify-package-boundaries.mjs +++ b/scripts/verify-package-boundaries.mjs @@ -128,43 +128,47 @@ try { '--ignore-scripts', '--no-audit', '--no-fund', - ...tarballs, + tarballs[0], ], cwd: consumerDir, }); - const smoke = ` -const entries = [ - '@openrouter/agent', - '@openrouter/agent/tool-set', - '@openrouter/agent/mcp', - '@openrouter/agent/mcp/create-mcp-tools', - '@openrouter/agent/mcp/types', - '@openrouter/agent/mcp/schema', - '@openrouter/agent/mcp/cache', - '@openrouter/mcp', - '@openrouter/mcp/create-mcp-tools', - '@openrouter/mcp/types', - '@openrouter/mcp/schema', - '@openrouter/mcp/cache', -]; -for (const entry of entries) await import(entry); + const baseSmoke = ` +for (const entry of ['@openrouter/agent', '@openrouter/agent/tool-set', '@openrouter/agent/mcp']) { + await import(entry); +} const { createMCPTools, MCPMissingPeerDependencyError } = await import('@openrouter/agent/mcp'); try { await createMCPTools({ url: 'https://mcp.example.com/mcp' }); throw new Error('Expected the optional MCP peer to be absent'); } catch (error) { if (!(error instanceof MCPMissingPeerDependencyError)) throw error; - if (!error.message.includes('pnpm add @modelcontextprotocol/sdk')) throw error; + if (!error.message.includes('pnpm add @modelcontextprotocol/client')) throw error; } `; run({ command: 'node', - args: [ - '--input-type=module', - '--eval', - smoke, - ], + args: ['--input-type=module', '--eval', baseSmoke], + cwd: consumerDir, + }); + + run({ + command: 'npm', + args: ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarballs[1]], + cwd: consumerDir, + }); + const facadeSmoke = ` +for (const entry of [ + '@openrouter/mcp', + '@openrouter/mcp/create-mcp-tools', + '@openrouter/mcp/types', + '@openrouter/mcp/schema', + '@openrouter/mcp/cache', +]) await import(entry); +`; + run({ + command: 'node', + args: ['--input-type=module', '--eval', facadeSmoke], cwd: consumerDir, }); From 2428e62ce821c807f820569943c4500ef840ffdf Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:33:23 -0500 Subject: [PATCH 05/14] ci: include package-boundaries in aggregated ci-status gate --- .github/workflows/ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3334a693..b5b3281d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -185,7 +185,7 @@ jobs: ci-status: name: CI status if: always() - needs: [lint, typecheck, unit-tests, e2e-tests, structural-gate] + needs: [lint, typecheck, unit-tests, e2e-tests, structural-gate, package-boundaries] runs-on: ubuntu-latest steps: - name: Verify all checks passed @@ -196,6 +196,7 @@ jobs: unit-tests=${{ needs.unit-tests.result }} e2e-tests=${{ needs.e2e-tests.result }} structural-gate=${{ needs.structural-gate.result }} + package-boundaries=${{ needs.package-boundaries.result }} run: | failed=0 for entry in $RESULTS; do From 46e5340736fd6a22cc7706bd23a7c4e38db88a6b Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:27:39 -0500 Subject: [PATCH 06/14] fix(agent): narrow missing MCP peer detection --- packages/agent/src/mcp/mcp-sdk.ts | 6 +- packages/agent/tests/unit/mcp/mcp-sdk.test.ts | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/agent/tests/unit/mcp/mcp-sdk.test.ts diff --git a/packages/agent/src/mcp/mcp-sdk.ts b/packages/agent/src/mcp/mcp-sdk.ts index 72fcb09e..d19f2ec3 100644 --- a/packages/agent/src/mcp/mcp-sdk.ts +++ b/packages/agent/src/mcp/mcp-sdk.ts @@ -19,9 +19,11 @@ function isMissingSdk(error: unknown): boolean { let current = error; while (current instanceof Error) { const code = 'code' in current ? current.code : undefined; + const specifier = /Cannot find (?:package|module) '([^']+)'/.exec(current.message)?.[1]; if ( - code === 'ERR_MODULE_NOT_FOUND' && - current.message.includes('@modelcontextprotocol/client') + (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') && + (specifier === '@modelcontextprotocol/client' || + specifier?.startsWith('@modelcontextprotocol/client/') === true) ) { return true; } diff --git a/packages/agent/tests/unit/mcp/mcp-sdk.test.ts b/packages/agent/tests/unit/mcp/mcp-sdk.test.ts new file mode 100644 index 00000000..e6073ef2 --- /dev/null +++ b/packages/agent/tests/unit/mcp/mcp-sdk.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MCPMissingPeerDependencyError } from '../../../src/mcp/errors.js'; + +const state = vi.hoisted(() => ({ + error: new Error(), +})); + +vi.mock('@modelcontextprotocol/client', () => { + throw state.error; +}); + +const { loadMcpSdk } = await import('../../../src/mcp/mcp-sdk.js'); + +function moduleNotFound(message: string): Error { + return Object.assign(new Error(message), { + code: 'ERR_MODULE_NOT_FOUND', + }); +} + +async function loadError(): Promise { + try { + await loadMcpSdk(); + throw new Error('Expected loadMcpSdk to reject'); + } catch (error) { + return error as Error; + } +} + +describe('loadMcpSdk missing peer classification', () => { + beforeEach(() => { + state.error = new Error(); + }); + + it('wraps a missing @modelcontextprotocol/client peer', async () => { + state.error = moduleNotFound( + "Cannot find package '@modelcontextprotocol/client' imported from /app/agent.js", + ); + + const error = await loadError(); + expect(error).toBeInstanceOf(MCPMissingPeerDependencyError); + expect((error.cause as Error).cause).toBe(state.error); + }); + + it('surfaces a missing transitive dependency unchanged', async () => { + state.error = moduleNotFound( + "Cannot find package 'eventsource' imported from /app/node_modules/@modelcontextprotocol/client/dist/index.js", + ); + + const error = await loadError(); + expect(error).not.toBeInstanceOf(MCPMissingPeerDependencyError); + expect(error.cause).toBe(state.error); + }); + + it('wraps a missing @modelcontextprotocol/client subpath', async () => { + state.error = moduleNotFound( + "Cannot find module '@modelcontextprotocol/client/streamableHttp' imported from /app/agent.js", + ); + + const error = await loadError(); + expect(error).toBeInstanceOf(MCPMissingPeerDependencyError); + expect((error.cause as Error).cause).toBe(state.error); + }); +}); From 21be0854b416dc0668c03fb14a97a81506d1e653 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:50:06 -0500 Subject: [PATCH 07/14] fix(mcp): preserve compatibility type exports --- packages/mcp/src/types.ts | 2 ++ packages/mcp/tests/unit/types.test.ts | 4 +++- scripts/verify-package-boundaries.mjs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 6bd81908..cb619e0d 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -6,6 +6,8 @@ export type { CreateMCPToolsOptions, ElicitationHandler, ElicitationResponse, + MCPProtocolNegotiation, + MCPProtocolRevision, MCPToolsHandle, MCPTransportKind, ResourcesOption, diff --git a/packages/mcp/tests/unit/types.test.ts b/packages/mcp/tests/unit/types.test.ts index bd4a5e2b..ffdc3cab 100644 --- a/packages/mcp/tests/unit/types.test.ts +++ b/packages/mcp/tests/unit/types.test.ts @@ -26,7 +26,9 @@ describe('@openrouter/mcp/types export parity (type-level)', () => { expectTypeOf().toEqualTypeOf(); }); - it('MCPTransportKind is structurally identical', () => { + it('preserves the published protocol types', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); }); diff --git a/scripts/verify-package-boundaries.mjs b/scripts/verify-package-boundaries.mjs index c34cb0a7..9d713826 100644 --- a/scripts/verify-package-boundaries.mjs +++ b/scripts/verify-package-boundaries.mjs @@ -38,7 +38,7 @@ function pack(packageDir) { if (typeof filename !== 'string') { throw new Error(`Could not determine tarball name for ${packageDir}`); } - return resolve(join(root, packageDir), filename); + return resolve(packDir, filename); } function tarEntries(tarball) { From 6addd0fcca73c20f2c9409f4085bda27d5244f52 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:03:39 -0500 Subject: [PATCH 08/14] test(mcp): typecheck compatibility exports --- packages/mcp/package.json | 2 +- packages/mcp/tests/unit/types.test-d.ts | 30 ++++++++++++++++++++++ packages/mcp/tests/unit/types.test.ts | 34 ------------------------- packages/mcp/tsconfig.typecheck.json | 9 +++++++ 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 packages/mcp/tests/unit/types.test-d.ts delete mode 100644 packages/mcp/tests/unit/types.test.ts create mode 100644 packages/mcp/tsconfig.typecheck.json diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 22db355a..ff8f57a1 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -63,7 +63,7 @@ "build": "tsc", "test": "vitest --run --project unit", "test:watch": "vitest --watch --project unit", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "compile": "tsc" }, "dependencies": { diff --git a/packages/mcp/tests/unit/types.test-d.ts b/packages/mcp/tests/unit/types.test-d.ts new file mode 100644 index 00000000..241407f3 --- /dev/null +++ b/packages/mcp/tests/unit/types.test-d.ts @@ -0,0 +1,30 @@ +import type { + CreateMCPToolsOptions as AgentCreateMCPToolsOptions, + ElicitationHandler as AgentElicitationHandler, + ElicitationResponse as AgentElicitationResponse, + MCPProtocolNegotiation as AgentMCPProtocolNegotiation, + MCPProtocolRevision as AgentMCPProtocolRevision, + MCPToolsHandle as AgentMCPToolsHandle, + MCPTransportKind as AgentMCPTransportKind, + ResourcesOption as AgentResourcesOption, +} from '@openrouter/agent/mcp/types'; +import type { + CreateMCPToolsOptions, + ElicitationHandler, + ElicitationResponse, + MCPProtocolNegotiation, + MCPProtocolRevision, + MCPToolsHandle, + MCPTransportKind, + ResourcesOption, +} from '@openrouter/mcp/types'; +import { expectTypeOf } from 'vitest'; + +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); diff --git a/packages/mcp/tests/unit/types.test.ts b/packages/mcp/tests/unit/types.test.ts deleted file mode 100644 index ffdc3cab..00000000 --- a/packages/mcp/tests/unit/types.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type * as AgentTypes from '@openrouter/agent/mcp/types'; -import type * as WrapperTypes from '@openrouter/mcp/types'; -import { describe, expectTypeOf, it } from 'vitest'; - -// `@openrouter/mcp/types` is type-only, so parity is verified structurally at -// the type level rather than via runtime `Object.keys` (there is nothing to -// inspect at runtime for a type-only module). -describe('@openrouter/mcp/types export parity (type-level)', () => { - it('CreateMCPToolsOptions is structurally identical', () => { - expectTypeOf().toEqualTypeOf(); - }); - - it('MCPToolsHandle is structurally identical', () => { - expectTypeOf().toEqualTypeOf(); - }); - - it('ElicitationHandler is structurally identical', () => { - expectTypeOf().toEqualTypeOf(); - }); - - it('ElicitationResponse is structurally identical', () => { - expectTypeOf().toEqualTypeOf(); - }); - - it('ResourcesOption is structurally identical', () => { - expectTypeOf().toEqualTypeOf(); - }); - - it('preserves the published protocol types', () => { - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - }); -}); diff --git a/packages/mcp/tsconfig.typecheck.json b/packages/mcp/tsconfig.typecheck.json new file mode 100644 index 00000000..1f17bdb1 --- /dev/null +++ b/packages/mcp/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/unit/types.test-d.ts"], + "exclude": ["node_modules", "esm"] +} From d84bff6bca0b3234cc0ad62ce918b1db22534a6a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:14:17 -0500 Subject: [PATCH 09/14] fix(mcp): build declarations before typecheck --- packages/mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ff8f57a1..8b03adf0 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -63,7 +63,7 @@ "build": "tsc", "test": "vitest --run --project unit", "test:watch": "vitest --watch --project unit", - "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", + "typecheck": "pnpm run build && tsc --noEmit -p tsconfig.typecheck.json", "compile": "tsc" }, "dependencies": { From ec8f567a9053073347b2f83e447477095fbb7f2a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:22:54 -0500 Subject: [PATCH 10/14] fix(agent): preserve tool-set metadata marker after restack --- packages/agent/src/lib/tool-set-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/src/lib/tool-set-types.ts b/packages/agent/src/lib/tool-set-types.ts index e991041b..d42e4e79 100644 --- a/packages/agent/src/lib/tool-set-types.ts +++ b/packages/agent/src/lib/tool-set-types.ts @@ -1,3 +1,4 @@ +import { TOOL_SET_SNAPSHOT } from './async-params.js'; import type { ClientTool, ConversationState, @@ -5,7 +6,6 @@ import type { ServerToolBase, Tool, } from './tool-types.js'; -import { TOOL_SET_SNAPSHOT } from './async-params.js'; // ─── identity ─────────────────────────────────────────────────────────────── From f0fd5d574f9ff74a48c17b8d713c902f6f5c33e2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:33:33 -0500 Subject: [PATCH 11/14] fix(agent): report agent MCP client identity --- packages/agent/src/mcp/mcp-connection.ts | 6 +++--- packages/agent/tests/unit/mcp/mcp-connection.test.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/mcp/mcp-connection.ts b/packages/agent/src/mcp/mcp-connection.ts index 6728198d..c3072d2b 100644 --- a/packages/agent/src/mcp/mcp-connection.ts +++ b/packages/agent/src/mcp/mcp-connection.ts @@ -47,10 +47,10 @@ import { PACKAGE_VERSION } from './version.js'; */ const DEFAULT_PROBE_TIMEOUT_MS = 30_000; -// Self-reported to every MCP server we connect to as `clientInfo`. The version -// is generated from package.json (scripts/gen-version.mjs) so it cannot drift. +// Self-reported to every MCP server we connect to as `clientInfo`. Both the +// implementation and generated version belong to the agent package. const DEFAULT_CLIENT_INFO = { - name: '@openrouter/mcp', + name: '@openrouter/agent', version: PACKAGE_VERSION, }; diff --git a/packages/agent/tests/unit/mcp/mcp-connection.test.ts b/packages/agent/tests/unit/mcp/mcp-connection.test.ts index 56afc01b..e7c4376a 100644 --- a/packages/agent/tests/unit/mcp/mcp-connection.test.ts +++ b/packages/agent/tests/unit/mcp/mcp-connection.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import agentPackage from '../../../package.json' with { type: 'json' }; import { MCPConnectionError } from '../../../src/mcp/errors.js'; // These tests exercise the real `connect()` — the transport selection and the @@ -585,10 +586,11 @@ describe('connect clientInfo', () => { expect(state.clientInfos).toEqual([ { - name: '@openrouter/mcp', + name: agentPackage.name, version: PACKAGE_VERSION, }, ]); + expect(PACKAGE_VERSION).toBe(agentPackage.version); await conn.close(); }); @@ -622,11 +624,11 @@ describe('connect clientInfo', () => { // not see a different client than the one that just probed it. expect(state.clientInfos).toEqual([ { - name: '@openrouter/mcp', + name: agentPackage.name, version: PACKAGE_VERSION, }, { - name: '@openrouter/mcp', + name: agentPackage.name, version: PACKAGE_VERSION, }, ]); From c8f3c79a8dfd41d9e2978fbf75260b1b773a1efc Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:37:48 -0500 Subject: [PATCH 12/14] fix(agent): finish tool-set test relocation --- packages/agent-tool-set/tsconfig.typecheck.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 packages/agent-tool-set/tsconfig.typecheck.json diff --git a/packages/agent-tool-set/tsconfig.typecheck.json b/packages/agent-tool-set/tsconfig.typecheck.json deleted file mode 100644 index e4829760..00000000 --- a/packages/agent-tool-set/tsconfig.typecheck.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "tests/unit/resolved-tools.test-d.ts"], - "exclude": ["node_modules", "esm"] -} From af734b7a3ef1c0ff2c1820278438b0147f198a13 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:38:31 -0500 Subject: [PATCH 13/14] test(agent): preserve relocated tool-set declaration coverage --- packages/agent/tests/unit/resolved-tools.test-d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/tests/unit/resolved-tools.test-d.ts b/packages/agent/tests/unit/resolved-tools.test-d.ts index edb4cceb..f15bc7a3 100644 --- a/packages/agent/tests/unit/resolved-tools.test-d.ts +++ b/packages/agent/tests/unit/resolved-tools.test-d.ts @@ -1,12 +1,12 @@ -import { tool } from '../../src/lib/tool.js'; import { expectTypeOf } from 'vitest'; import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; +import { createToolSet } from '../../src/lib/tool-set.js'; import type { ConditionalPartition, InitialPartition, ResolvedToolSnapshot, } from '../../src/lib/tool-set-types.js'; -import { createToolSet } from '../../src/lib/tool-set.js'; const a = tool({ name: 'a', From e39dbd1822666e693d7cde2725916234417f9cd8 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:49:33 -0500 Subject: [PATCH 14/14] fix(mcp): remove dead compatibility files --- packages/mcp/scripts/gen-version.mjs | 65 ---------------------------- packages/mcp/src/close-quietly.ts | 28 ------------ packages/mcp/src/version.ts | 5 --- packages/mcp/vitest.config.ts | 1 - 4 files changed, 99 deletions(-) delete mode 100644 packages/mcp/scripts/gen-version.mjs delete mode 100644 packages/mcp/src/close-quietly.ts delete mode 100644 packages/mcp/src/version.ts diff --git a/packages/mcp/scripts/gen-version.mjs b/packages/mcp/scripts/gen-version.mjs deleted file mode 100644 index c4a88659..00000000 --- a/packages/mcp/scripts/gen-version.mjs +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -/** - * Generates src/version.ts from this package's package.json `version`. - * - * package.json is the single source of truth. The generated file is committed - * (not gitignored) because CI's lint/typecheck/unit-test jobs run without a - * build step, and turbo's `dependsOn: ["^build"]` only builds upstream - * packages — so nothing would regenerate it before those jobs compile `src`. - * - * tests/unit/version.test.ts fails when the committed constant drifts from - * package.json, so a stale file cannot merge or publish silently. - * - * Run `pnpm --filter @openrouter/mcp gen:version` after bumping the version - * (changesets does the bump; `build` reruns this before `tsc`). - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const pkgPath = join(packageRoot, 'package.json'); -const outPath = join(packageRoot, 'src', 'version.ts'); - -const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); -const { version } = pkg; - -if (typeof version !== 'string' || version.length === 0) { - console.error(`gen-version: no usable "version" in ${pkgPath}`); - process.exit(1); -} - -// Charset allow-list (semver plus pre-release/build punctuation) so the value -// provably cannot break out of the string literal below — no quotes, -// backslashes, newlines, or backticks can pass. Interpolating into committed, -// import-executed TS without this would let a malformed `version` inject code; -// developer-controlled, so defense-in-depth, but one regex is cheap. The -// single-quoted literal below is safe ONLY because of this guard (a -// JSON.stringify'd literal would fight biome's single-quote formatting of -// src/, and CI checks the generated file for drift). -if (!/^[0-9A-Za-z.+-]+$/.test(version)) { - console.error(`gen-version: "version" contains characters outside [0-9A-Za-z.+-]: ${version}`); - process.exit(1); -} - -const contents = `// DO NOT EDIT — generated from package.json by scripts/gen-version.mjs. -// Run \`pnpm --filter @openrouter/mcp gen:version\` after bumping the version. - -/** This package's version, self-reported to MCP servers as \`clientInfo\`. */ -export const PACKAGE_VERSION = '${version}'; -`; - -// Skip the write when unchanged so turbo/watch modes don't see a dirty output. -let existing; -try { - existing = readFileSync(outPath, 'utf8'); -} catch { - existing = undefined; -} - -if (existing === contents) { - console.log(`gen-version: src/version.ts already at ${version}`); -} else { - writeFileSync(outPath, contents); - console.log(`gen-version: wrote src/version.ts at ${version}`); -} diff --git a/packages/mcp/src/close-quietly.ts b/packages/mcp/src/close-quietly.ts deleted file mode 100644 index b52b9d0f..00000000 --- a/packages/mcp/src/close-quietly.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Release something closeable on a path that is already failing, swallowing - * whatever `close()` does on the way out. - * - * The `try` matters as much as the `catch`: `close()` is not guaranteed to - * return a rejected promise on failure, and a *synchronous* throw never - * produces one — so `x.close().catch(() => {})` throws before `.catch()` is - * ever attached. On an unwinding path that is actively harmful in two ways: it - * replaces the real error (a useful "couldn't reach the server" becomes an - * opaque teardown failure), and it skips whatever recovery followed the close — - * a fallback that used to self-heal starts rejecting instead. - * - * Every teardown-on-failure site in this package goes through here, so the - * guarantee holds uniformly rather than depending on each caller remembering - * the distinction. - * - * Deliberately not exported from the package entrypoint: callers close through - * `MCPToolsHandle.close()` / `MCPConnection.close()`, which report failures - * rather than swallowing them. This is only for teardown during error unwinding. - */ -export async function closeQuietly(closeable: { close(): Promise }): Promise { - try { - await closeable.close(); - } catch { - // Nothing actionable: we are already unwinding a failure, and the close - // outcome is never the error the caller needs to see. - } -} diff --git a/packages/mcp/src/version.ts b/packages/mcp/src/version.ts deleted file mode 100644 index 89f5b872..00000000 --- a/packages/mcp/src/version.ts +++ /dev/null @@ -1,5 +0,0 @@ -// DO NOT EDIT — generated from package.json by scripts/gen-version.mjs. -// Run `pnpm --filter @openrouter/mcp gen:version` after bumping the version. - -/** This package's version, self-reported to MCP servers as `clientInfo`. */ -export const PACKAGE_VERSION = '1.0.0'; diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index 3508bf50..2e2d5ae5 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ enabled: true, provider: 'v8', include: ['src/**/*.ts'], - exclude: ['src/close-quietly.ts', 'src/version.ts'], reporter: ['text', 'json-summary', 'html'], // Coverage ratchet: thresholds are pinned at current levels. Any PR // that lowers coverage fails the unit-test job. When you raise