diff --git a/.changeset/agent-mcp-subpath.md b/.changeset/agent-mcp-subpath.md new file mode 100644 index 00000000..60a806ab --- /dev/null +++ b/.changeset/agent-mcp-subpath.md @@ -0,0 +1,24 @@ +--- +"@openrouter/agent": minor +"@openrouter/mcp": minor +--- + +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'; +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/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/.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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b885192b..b5b3281d 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: @@ -166,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 @@ -177,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 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/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/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-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/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"] -} 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..810bcc56 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -56,6 +56,17 @@ const text = await result.getText(); console.log(text); ``` +## Optional subpaths + +- `@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/client +``` + +Existing `@openrouter/mcp` imports remain supported as compatibility facades. + ## Features ### Multiple Response Consumption Patterns @@ -1050,6 +1061,221 @@ 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. + +## Install + +```bash +pnpm add @openrouter/agent +``` + +## 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 | 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: 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..cd1908ea 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", @@ -92,11 +92,34 @@ "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", + "./mcp": { + "types": "./esm/mcp/index.d.ts", + "default": "./esm/mcp/index.js" + }, + "./mcp/create-mcp-tools": { + "types": "./esm/mcp/create-mcp-tools.d.ts", + "default": "./esm/mcp/create-mcp-tools.js" + }, + "./mcp/types": { + "types": "./esm/mcp/types.d.ts", + "default": "./esm/mcp/types.js" + }, + "./mcp/schema": { + "types": "./esm/mcp/schema/json-schema-to-zod.d.ts", + "default": "./esm/mcp/schema/json-schema-to-zod.js" + }, + "./mcp/cache": { + "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" @@ -120,7 +143,8 @@ "./agent-tool": { "types": "./esm/lib/agent-tool.d.ts", "default": "./esm/lib/agent-tool.js" - } + }, + "./package.json": "./package.json" }, "sideEffects": false, "repository": { @@ -135,20 +159,33 @@ "files": [ "esm", "package.json", - "README.md" + "README.md", + "THIRD_PARTY_NOTICES.md" ], "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", "zod": "^4.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/client": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0" } } 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/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 98% rename from packages/agent-tool-set/src/types.ts rename to packages/agent/src/lib/tool-set-types.ts index 59593f6c..d42e4e79 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent/src/lib/tool-set-types.ts @@ -1,11 +1,11 @@ +import { TOOL_SET_SNAPSHOT } from './async-params.js'; import type { ClientTool, ConversationState, CorrelatedToolEventUnion, ServerToolBase, Tool, -} from '@openrouter/agent'; -import { TOOL_SET_SNAPSHOT } from '@openrouter/agent'; +} from './tool-types.js'; // ─── identity ─────────────────────────────────────────────────────────────── @@ -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; /** @@ -92,7 +94,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 97% rename from packages/agent-tool-set/src/tool-set.ts rename to packages/agent/src/lib/tool-set.ts index 083d2da5..1b9e4ee9 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent/src/lib/tool-set.ts @@ -1,33 +1,29 @@ -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, ActivationPredicate, ApplySituationPartition, - ClientToolNamesOfTuple, ConditionalPartition, DeactivatePartition, EmptySituations, - FilterToolsByIds, InferSituationMap, InitialPartition, Partition, ResolvedToolSnapshot, - ResolvedTools, - ServerToolIdsOfTuple, SituationConditionalRule, SituationConfig, SituationMap, SituationNames, StatusByToolMap, StatusReason, - ToolIdOf, ToolIdsOfTuple, 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> = | { @@ -881,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/mcp/src/auth/auth-resolver.ts b/packages/agent/src/mcp/auth/auth-resolver.ts similarity index 100% rename from packages/mcp/src/auth/auth-resolver.ts rename to packages/agent/src/mcp/auth/auth-resolver.ts diff --git a/packages/mcp/src/auth/auth-types.ts b/packages/agent/src/mcp/auth/auth-types.ts similarity index 100% rename from packages/mcp/src/auth/auth-types.ts rename to packages/agent/src/mcp/auth/auth-types.ts diff --git a/packages/mcp/src/build-tools.ts b/packages/agent/src/mcp/build-tools.ts similarity index 98% rename from packages/mcp/src/build-tools.ts rename to packages/agent/src/mcp/build-tools.ts index 7e62519a..2eaaa137 100644 --- a/packages/mcp/src/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/mcp/src/cache/cache-store.ts b/packages/agent/src/mcp/cache/cache-store.ts similarity index 100% rename from packages/mcp/src/cache/cache-store.ts rename to packages/agent/src/mcp/cache/cache-store.ts diff --git a/packages/mcp/src/cache/cache-types.ts b/packages/agent/src/mcp/cache/cache-types.ts similarity index 100% rename from packages/mcp/src/cache/cache-types.ts rename to packages/agent/src/mcp/cache/cache-types.ts diff --git a/packages/mcp/src/cache/serialize.ts b/packages/agent/src/mcp/cache/serialize.ts similarity index 100% rename from packages/mcp/src/cache/serialize.ts rename to packages/agent/src/mcp/cache/serialize.ts diff --git a/packages/mcp/src/close-quietly.ts b/packages/agent/src/mcp/close-quietly.ts similarity index 100% rename from packages/mcp/src/close-quietly.ts rename to packages/agent/src/mcp/close-quietly.ts 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..507f2d3c --- /dev/null +++ b/packages/agent/src/mcp/create-mcp-tools.ts @@ -0,0 +1,103 @@ +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', + '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 { + 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 }; 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 86% rename from packages/mcp/src/errors.ts rename to packages/agent/src/mcp/errors.ts index 4d1ec677..2f70abb7 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( @@ -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/client'; + + constructor(options?: { + cause?: unknown; + }) { + super( + '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/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..bcee53e0 --- /dev/null +++ b/packages/agent/src/mcp/index.ts @@ -0,0 +1,40 @@ +// 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'; +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, + MCPCacheWriteError, + MCPConnectionError, + MCPError, + MCPMissingPeerDependencyError, + 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'; diff --git a/packages/mcp/src/mcp-connection.ts b/packages/agent/src/mcp/mcp-connection.ts similarity index 89% rename from packages/mcp/src/mcp-connection.ts rename to packages/agent/src/mcp/mcp-connection.ts index d22a10f5..c3072d2b 100644 --- a/packages/mcp/src/mcp-connection.ts +++ b/packages/agent/src/mcp/mcp-connection.ts @@ -1,10 +1,7 @@ -// SSEClientTransport is deprecated upstream (SEP-2596) but intentionally -// supported here for legacy MCP servers that haven't migrated to Streamable HTTP. -import { +import type { Client, SSEClientTransport, StreamableHTTPClientTransport, - UnauthorizedError, } from '@modelcontextprotocol/client'; import { resolveAuth } from './auth/auth-resolver.js'; import type { MCPAuth } from './auth/auth-types.js'; @@ -12,6 +9,8 @@ import { isOAuthAuth } from './auth/auth-types.js'; import { closeQuietly } from './close-quietly.js'; import { makeElicitationRequestHandler } from './elicitation.js'; import { MCPConnectionError } from './errors.js'; +import type { MCPSdk } from './mcp-sdk.js'; +import { loadMcpSdk } from './mcp-sdk.js'; import type { MCPProtocolNegotiation, MCPTransportKind } from './transport-types.js'; import type { ElicitationHandler } from './types.js'; import { PACKAGE_VERSION } from './version.js'; @@ -48,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, }; @@ -109,9 +108,9 @@ export interface MCPConnection { close(): Promise; } -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 new file mode 100644 index 00000000..d19f2ec3 --- /dev/null +++ b/packages/agent/src/mcp/mcp-sdk.ts @@ -0,0 +1,54 @@ +import type { + Client, + SSEClientTransport, + StreamableHTTPClientTransport, + UnauthorizedError, +} from '@modelcontextprotocol/client'; +import { MCPMissingPeerDependencyError } from './errors.js'; + +export interface MCPSdk { + Client: typeof Client; + SSEClientTransport: typeof SSEClientTransport; + StreamableHTTPClientTransport: typeof StreamableHTTPClientTransport; + UnauthorizedError: typeof UnauthorizedError; +} + +let sdkPromise: Promise | undefined; + +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' || code === 'MODULE_NOT_FOUND') && + (specifier === '@modelcontextprotocol/client' || + specifier?.startsWith('@modelcontextprotocol/client/') === true) + ) { + return true; + } + current = current.cause; + } + return false; +} + +/** Load the optional MCP client only when a connection is actually requested. */ +export function loadMcpSdk(): Promise { + sdkPromise ??= import('@modelcontextprotocol/client') + .then((client) => ({ + Client: client.Client, + SSEClientTransport: client.SSEClientTransport, + StreamableHTTPClientTransport: client.StreamableHTTPClientTransport, + UnauthorizedError: client.UnauthorizedError, + })) + .catch((cause: unknown) => { + sdkPromise = undefined; + if (isMissingSdk(cause)) { + throw new MCPMissingPeerDependencyError({ + cause, + }); + } + throw cause; + }); + return sdkPromise; +} diff --git a/packages/mcp/src/rehydrate.ts b/packages/agent/src/mcp/rehydrate.ts similarity index 99% rename from packages/mcp/src/rehydrate.ts rename to packages/agent/src/mcp/rehydrate.ts index 452688b8..66092d42 100644 --- a/packages/mcp/src/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,10 +617,15 @@ 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, effectiveAuth) + !isAuthFailure({ + err, + auth: effectiveAuth, + UnauthorizedErrorType: UnauthorizedError, + }) ) { return freshConnect(createOptions, url, cacheKey); } diff --git a/packages/mcp/src/resource-tools.ts b/packages/agent/src/mcp/resource-tools.ts similarity index 98% rename from packages/mcp/src/resource-tools.ts rename to packages/agent/src/mcp/resource-tools.ts index 0e3a7774..4ce8cfb2 100644 --- a/packages/mcp/src/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/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 97% rename from packages/mcp/src/tool-wrapper.ts rename to packages/agent/src/mcp/tool-wrapper.ts index 92729dfb..95b76bc2 100644 --- a/packages/mcp/src/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/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..ceadfaee --- /dev/null +++ b/packages/agent/src/mcp/types.ts @@ -0,0 +1,133 @@ +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, + MCPProtocolRevision, + 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; + /** 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; + /** 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; +} + +/** + * 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/mcp/src/version.ts b/packages/agent/src/mcp/version.ts similarity index 56% rename from packages/mcp/src/version.ts rename to packages/agent/src/mcp/version.ts index 89f5b872..67ad4938 100644 --- a/packages/mcp/src/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 = '1.0.0'; +export const PACKAGE_VERSION = '0.9.0'; 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..00515945 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<{ @@ -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; @@ -255,7 +264,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..597e24b0 --- /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 but not the removed protocol session 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).toBeUndefined(); + }); +}); + +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/mcp/tests/unit/call-tool-shape.test.ts b/packages/agent/tests/unit/mcp/call-tool-shape.test.ts similarity index 97% rename from packages/mcp/tests/unit/call-tool-shape.test.ts rename to packages/agent/tests/unit/mcp/call-tool-shape.test.ts index fe6a74d4..54ddfcbf 100644 --- a/packages/mcp/tests/unit/call-tool-shape.test.ts +++ b/packages/agent/tests/unit/mcp/call-tool-shape.test.ts @@ -1,6 +1,6 @@ import type { Client } from '@modelcontextprotocol/client'; import { describe, expect, it } from 'vitest'; -import { wrapMcpTool } from '../../src/tool-wrapper.js'; +import { wrapMcpTool } from '../../../src/mcp/tool-wrapper.js'; // Regression guard for the SDK v2 `callTool` signature change. // 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..f1fcd45b --- /dev/null +++ b/packages/agent/tests/unit/mcp/create-mcp-tools.test.ts @@ -0,0 +1,342 @@ +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); + } + }); + + /** + * 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/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/mcp-connection.test.ts b/packages/agent/tests/unit/mcp/mcp-connection.test.ts similarity index 98% rename from packages/mcp/tests/unit/mcp-connection.test.ts rename to packages/agent/tests/unit/mcp/mcp-connection.test.ts index e71627cf..e7c4376a 100644 --- a/packages/mcp/tests/unit/mcp-connection.test.ts +++ b/packages/agent/tests/unit/mcp/mcp-connection.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MCPConnectionError } from '../../src/errors.js'; +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 // Streamable HTTP -> SSE fallback — by faking the SDK's transports and client @@ -203,7 +204,7 @@ vi.mock('@modelcontextprotocol/client', () => ({ }, })); -const { connect } = await import('../../src/mcp-connection.js'); +const { connect } = await import('../../../src/mcp/mcp-connection.js'); const URL_UNDER_TEST = new URL('https://example.invalid/mcp'); @@ -577,7 +578,7 @@ describe('protocol negotiation', () => { */ describe('connect clientInfo', () => { it('self-reports the package name and generated version by default', async () => { - const { PACKAGE_VERSION } = await import('../../src/version.js'); + const { PACKAGE_VERSION } = await import('../../../src/mcp/version.js'); const conn = await connect({ url: URL_UNDER_TEST, @@ -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(); }); @@ -612,7 +614,7 @@ describe('connect clientInfo', () => { it('carries the same clientInfo onto the SSE fallback client', async () => { state.failing.add('streamableHttp'); - const { PACKAGE_VERSION } = await import('../../src/version.js'); + const { PACKAGE_VERSION } = await import('../../../src/mcp/version.js'); const conn = await connect({ url: URL_UNDER_TEST, @@ -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, }, ]); @@ -836,7 +838,7 @@ describe('legacy degradation under an implicit auto default', () => { 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 srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'src', 'mcp'); const conn = await connect({ url: URL_UNDER_TEST, 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); + }); +}); diff --git a/packages/mcp/tests/unit/protocol-era.test.ts b/packages/agent/tests/unit/mcp/protocol-era.test.ts similarity index 96% rename from packages/mcp/tests/unit/protocol-era.test.ts rename to packages/agent/tests/unit/mcp/protocol-era.test.ts index fdb7b45a..74c38024 100644 --- a/packages/mcp/tests/unit/protocol-era.test.ts +++ b/packages/agent/tests/unit/mcp/protocol-era.test.ts @@ -1,7 +1,7 @@ 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'; +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` @@ -413,7 +413,7 @@ describe('tools/list_changed dispatch', () => { fired: () => number; seen: string[]; }> { - const { makeClientForTest } = await import('../../src/mcp-connection.js'); + const { makeClientForTest } = await import('../../../src/mcp/mcp-connection.js'); const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); const seen: string[] = []; startFakeServer(serverSide, { @@ -425,7 +425,7 @@ describe('tools/list_changed dispatch', () => { }); let count = 0; - const client = makeClientForTest( + const client = await makeClientForTest( { url: new URL('https://example.invalid/mcp'), }, @@ -545,8 +545,8 @@ describe('tools/list_changed dispatch', () => { */ 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 { 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, { @@ -556,7 +556,7 @@ describe('listToolDefs bypasses the SDK response cache', () => { toolsListTtlMs: 60_000, }); - const client = makeClientForTest( + const client = await makeClientForTest( { url: new URL('https://example.invalid/mcp'), }, @@ -612,14 +612,14 @@ describe('listToolDefs bypasses the SDK response cache', () => { */ 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 { makeClientForTest } = await import('../../../src/mcp/mcp-connection.js'); const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); startFakeServer(serverSide, { modern: false, seen: [], }); - const client = makeClientForTest( + const client = await makeClientForTest( { url: new URL('https://example.invalid/mcp'), protocolNegotiation: 'legacy', @@ -634,14 +634,14 @@ describe('probe options under legacy mode', () => { 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 { 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 = makeClientForTest( + const client = await makeClientForTest( { url: new URL('https://example.invalid/mcp'), probeTimeoutMs: 150, diff --git a/packages/mcp/tests/unit/rehydrate.test.ts b/packages/agent/tests/unit/mcp/rehydrate.test.ts similarity index 97% rename from packages/mcp/tests/unit/rehydrate.test.ts rename to packages/agent/tests/unit/mcp/rehydrate.test.ts index e2042eb2..b6371682 100644 --- a/packages/mcp/tests/unit/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,10 +25,10 @@ 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 => + isAuthFailure: ({ err }: { err: unknown }): boolean => typeof err === 'object' && err !== null && 'isFakeAuthFailure' in err, connect: (options: ConnectOptions): Promise => { connectCalls.push(options); @@ -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/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/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/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-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..f15bc7a3 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 { 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/index.js'; -import { createToolSet } from '../../src/index.js'; +} from '../../src/lib/tool-set-types.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 67% 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 index bcb520e4..387883e6 100644 --- a/packages/agent-tool-set/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/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..39828ff8 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -1,7 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "esm" + "outDir": "esm", + "rootDir": "src", + // Resolve deps via published exports, not the repo-wide "source" condition: + // the optional peer "@modelcontextprotocol/client" (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..cf236ab7 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -1,10 +1,21 @@ { "extends": "./tsconfig.json", - "compilerOptions": { "noEmit": true, "rootDir": "." }, + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, "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/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 7c826875..7f748980 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,5 +1,11 @@ # @openrouter/mcp +> [!NOTE] +> 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 [`@openrouter/agent`](https://www.npmjs.com/package/@openrouter/agent)'s `callModel`. @@ -9,25 +15,46 @@ 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/mcp @openrouter/agent +pnpm add @openrouter/agent @modelcontextprotocol/client ``` +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 +``` + +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 ```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 +86,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 +106,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 +117,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 +141,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 +150,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/package.json b/packages/mcp/package.json index dc759dba..8b03adf0 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,17 +60,14 @@ "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" + "typecheck": "pnpm run build && tsc --noEmit -p tsconfig.typecheck.json", + "compile": "tsc" }, "dependencies": { "@modelcontextprotocol/client": "^2.0.0", - "@openrouter/agent": "workspace:*", - "zod": "^4.0.0" + "@openrouter/agent": "workspace:*" } } 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/cache.ts b/packages/mcp/src/cache.ts new file mode 100644 index 00000000..ec7a78ae --- /dev/null +++ b/packages/mcp/src/cache.ts @@ -0,0 +1,6 @@ +/** + * @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 114a1a55..0b2ce91f 100644 --- a/packages/mcp/src/create-mcp-tools.ts +++ b/packages/mcp/src/create-mcp-tools.ts @@ -1,112 +1,7 @@ -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. + * @deprecated Import from `@openrouter/agent/mcp/create-mcp-tools` instead. + * This compatibility subpath remains available for migration. */ -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..8357373c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,39 +1,39 @@ -// 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'; +/** + * 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, + ElicitationResponse, + MCPAuth, + MCPCacheStore, + MCPOAuthClientProvider, + MCPProtocolNegotiation, + MCPProtocolRevision, + 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, + MCPMissingPeerDependencyError, 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..8990fa55 --- /dev/null +++ b/packages/mcp/src/schema.ts @@ -0,0 +1,6 @@ +/** + * @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 6adfc30d..cb619e0d 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -1,167 +1,14 @@ -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 { +/** + * @deprecated Import from `@openrouter/agent/mcp/types` instead. + * This compatibility subpath remains available for migration. + */ +export type { + CreateMCPToolsOptions, + ElicitationHandler, + ElicitationResponse, MCPProtocolNegotiation, MCPProtocolRevision, + 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..8eec7579 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 { 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'; +import * as wrapperCache from '../../src/cache.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..d182667f 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 { 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', () => { + 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..4ec61274 --- /dev/null +++ b/packages/mcp/tests/unit/index.test.ts @@ -0,0 +1,45 @@ +import * as agentMcp from '@openrouter/agent/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 +// 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], + ); + } + }); +}); + +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 new file mode 100644 index 00000000..fea8f149 --- /dev/null +++ b/packages/mcp/tests/unit/schema.test.ts @@ -0,0 +1,23 @@ +import * as agentSchema from '@openrouter/agent/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', () => { + 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-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/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/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"] +} diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index e757a909..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/**/*.test.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 +40,6 @@ export default defineConfig({ hookTimeout: 10000, }, }, - { - extends: true, - test: { - name: 'e2e', - include: ['tests/e2e/**/*.test.ts'], - testTimeout: 30000, - hookTimeout: 30000, - }, - }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 924c9783..6c5c086d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,15 +47,10 @@ importers: zod: 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 + devDependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 packages/mcp: dependencies: @@ -65,9 +60,6 @@ importers: '@openrouter/agent': specifier: workspace:* version: link:../agent - zod: - specifier: ^4.0.0 - version: 4.3.6 packages: diff --git a/scripts/verify-package-boundaries.mjs b/scripts/verify-package-boundaries.mjs new file mode 100644 index 00000000..9d713826 --- /dev/null +++ b/scripts/verify-package-boundaries.mjs @@ -0,0 +1,205 @@ +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(packDir, 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[0], + ], + cwd: consumerDir, + }); + + 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/client')) throw error; +} +`; + run({ + command: 'node', + 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, + }); + + 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, + }); +}