refactor(agent): nest tool-set and MCP under agent subpaths - #102
refactor(agent): nest tool-set and MCP under agent subpaths#102LukasParke wants to merge 3 commits into
Conversation
| "./tool-set": { | ||
| "types": "./esm/lib/tool-set.d.ts", | ||
| "default": "./esm/lib/tool-set.js" | ||
| }, |
There was a problem hiding this comment.
🟡 Documented tool-set helper types cannot be imported from the new package entry point
The new tool-set entry point is wired to a module that publishes only five of its type helpers ("./tool-set" at packages/agent/package.json:95-98), so the inference helpers this PR's own documentation tells people to import are not reachable from any public entry.
Impact: Users following the README get an unresolved-import error when they try to use the documented tool-set helper types.
Missing re-exports on the `@openrouter/agent/tool-set` entry module
The deleted @openrouter/agent-tool-set package's src/index.ts re-exported ~30 types (InferAllIds, InferEnabledIds, InferDisabledIds, InferConditionalIds, InferToolSet, ResolvedToolSnapshot, ActivationInput, ActivationPredicate, SituationConfig, StatusByToolMap, ToolSetLike, …). The replacement entry packages/agent/src/lib/tool-set.ts only re-exports ClientToolNamesOfTuple, FilterToolsByIds, ServerToolIdsOfTuple, ToolIdOf, ToolIdsOfTuple (see its trailing export type { … } block), and packages/agent/src/index.ts exports none of them either. There is no ./tool-set-types entry in the exports map.
The README added by this PR documents exactly the unavailable names:
import {
createToolSet,
type InferEnabledIds,
type InferDisabledIds,
type InferConditionalIds,
type InferAllIds,
} from '@openrouter/agent/tool-set';(packages/agent/README.md:798-804, plus the InferToolSet<TTools> section at packages/agent/README.md:955.)
The repo's own migrated test has to reach into internals instead (packages/agent/tests/unit/tool-set.test.ts imports these from ../../src/lib/tool-set-types.js), which is not an option for external consumers.
Secondary consequence: WidenedPartition / WidenedSituationMap now appear in the public return types of createToolSet({ mutable: true }) and clone({ mutable: true }) but live in the unexported tool-set-types module, so downstream declaration emit that must name those types has no importable specifier under the exports map.
Prompt for agents
The `@openrouter/agent/tool-set` subpath (packages/agent/package.json exports -> ./tool-set -> esm/lib/tool-set.js) is the only public entry for the tool-set feature, but packages/agent/src/lib/tool-set.ts re-exports just five type helpers (ClientToolNamesOfTuple, FilterToolsByIds, ServerToolIdsOfTuple, ToolIdOf, ToolIdsOfTuple). The removed @openrouter/agent-tool-set package exported the full set from its index (InferAllIds, InferEnabledIds, InferDisabledIds, InferConditionalIds, InferToolSet, ResolvedToolSnapshot, ActivationInput, ActivationPredicate, SituationConfig, SituationConditionalRule, SituationMap, SituationNames, StatusByToolMap, StatusReason, ToolStatusEntry, ToolById, ToolSetLike, Partition/EmptyPartition/InitialPartition/Activate/Deactivate/Conditional/ApplySituation partitions, ClientToolName, ServerToolIdOf, EmptySituations, InferSituationEntry, InferSituationMap), and this PR's new packages/agent/README.md documents importing InferEnabledIds/InferDisabledIds/InferConditionalIds/InferAllIds/InferToolSet from '@openrouter/agent/tool-set'. Restore the full public type surface — e.g. re-export everything the old index.ts exported (plus the new WidenedPartition/WidenedSituationMap, which now appear in public return types) from src/lib/tool-set.ts, or add an additional exports-map entry for the types module — and verify by importing each documented name from the package entry rather than a relative source path.
Was this helpful? React with 👍 or 👎 to provide feedback.
| import * as agentMcp from '@openrouter/agent/mcp'; | ||
| import * as wrapperMcp from '@openrouter/mcp'; | ||
| import { describe, expect, it } from 'vitest'; |
There was a problem hiding this comment.
🔍 Facade tests import the package's own built output, which its test task never builds
These new parity tests import @openrouter/mcp (and @openrouter/mcp/cache, /schema, /create-mcp-tools) as a self-reference. Those specifiers resolve through packages/mcp/package.json's exports map to ./esm/*.js, i.e. compiled output. Turbo's test task only declares dependsOn: ["^build"] (turbo.json), which builds dependencies (@openrouter/agent) but not the package under test, so on a clean checkout pnpm run test (the CI unit-tests job runs exactly that with no prior build) has no packages/mcp/esm to resolve. Worth verifying locally with rm -rf packages/mcp/esm && pnpm run test; if it fails, either add build to the test task's dependsOn or import the sources relatively.
Was this helpful? React with 👍 or 👎 to provide feedback.
| import { | ||
| createMCPTools, | ||
| InMemoryMCPCacheStore, | ||
| rehydrateMCPTools, | ||
| } from '../../../src/mcp/index.js'; |
There was a problem hiding this comment.
🔍 packages/mcp no longer has any e2e test files but still runs an e2e project
This file was the only test under packages/mcp/tests/e2e, and it moved to the agent package. packages/mcp/package.json still defines "test:e2e": "vitest --run --project e2e" and packages/mcp/vitest.config.ts still declares the e2e project with include: ['tests/e2e/**/*.test.ts']. With no matching files, vitest exits non-zero unless passWithNoTests is set, so the CI e2e-tests job (which runs pnpm run test:e2e when OPENROUTER_API_KEY is present) would fail on the facade package. Consider dropping the e2e project/script from the facade.
Was this helpful? React with 👍 or 👎 to provide feedback.
| * | ||
| * Optional here for source compatibility with legacy hand-constructed | ||
| * `ServerToolBase` values that predate this field. `ServerTool<T, TId>` | ||
| * (the type returned by {@link serverTool}) still requires it as a | ||
| * literal `TId` via interface narrowing below. | ||
| */ | ||
| readonly id: string; | ||
| readonly id?: string; |
There was a problem hiding this comment.
🟡 Server tools with a custom identifier stop being addressable once their type is generalized
Making the server-tool identifier optional (readonly id?: string at packages/agent/src/lib/tool-types.ts:530) prevents the tool-set identity lookup from recognizing a generalized server tool, so activating or deactivating such a tool by its real name is rejected while a name it never had is offered instead.
Impact: Developers who pass a custom-named server tool through a general type get build errors when switching it on or off, and are steered toward an identifier that does not exist at runtime.
Interaction between the optional `id` field and `ServerToolIdOf`'s widening branch
ServerToolIdOf (packages/agent/src/lib/tool-set-types.ts:25-43) was changed in this PR so that a ServerToolBase-typed value whose id is only known as string widens to string (the sound choice, per its own doc comment). That branch is T extends { readonly id: infer Id extends string }.
In the same PR, ServerToolBase.id became optional (readonly id?: string). With exactOptionalPropertyTypes: true (set in tsconfig.base.json), a type with id?: string is not assignable to { readonly id: string }, so the first conditional branch no longer matches. Resolution falls through to the second branch and synthesizes `server:${config.type}` from the config union — exactly the unsound result the widening was added to avoid.
Consequence for the scenario the new test in packages/agent/tests/unit/tool-set.test.ts:1003-1060 describes: for const erased: ServerToolBase = serverTool({ type: 'web_search_2025_08_26' }, { id: 'server:public_search' }), ToolIdsOfTuple yields the synthesized server:${...} ids rather than string, so ts.deactivate('server:public_search') (the real runtime id) is a type error, and InferAllIds no longer equals 'a' | string as the test asserts.
This is invisible to CI: packages/agent/tsconfig.typecheck.json only includes src/**/*.ts plus specific *.test-d.ts files, and vitest's typechecker only compiles **/*.test-d.ts, so the expectTypeOf assertions inside tool-set.test.ts are never type-checked.
Fix options: keep id required on ServerToolBase, or make ServerToolIdOf also match an optional id (e.g. probe T extends { readonly id?: infer Id } before falling back to the synthesized default).
Prompt for agents
ServerToolIdOf in packages/agent/src/lib/tool-set-types.ts was updated so that a server tool whose `id` is only statically known as `string` widens to `string` instead of synthesizing `server:${config.type}`. Its first conditional branch is `T extends { readonly id: infer Id extends string }`. In the same change set, `ServerToolBase.id` in packages/agent/src/lib/tool-types.ts was made optional (`readonly id?: string`). Because the repo enables exactOptionalPropertyTypes, a type with an optional `id` is not assignable to `{ readonly id: string }`, so that branch never matches for a `ServerToolBase`-typed value and resolution falls back to the synthesized `server:${config.type}` union — the exact unsound behavior the widening was meant to remove. The new test block 'custom-ID server tool erased to ServerToolBase' in packages/agent/tests/unit/tool-set.test.ts asserts the widened behavior, but its type assertions are never type-checked (tsconfig.typecheck.json only includes src plus selected *.test-d.ts, and vitest typecheck only compiles *.test-d.ts), so the regression is silent. Decide whether `id` really needs to be optional; if it does, teach ServerToolIdOf to handle an optional `id` before falling back to the synthesized default, and move the type assertions into a *.test-d.ts file that is actually type-checked.
Was this helpful? React with 👍 or 👎 to provide feedback.
| import { | ||
| createMCPTools, | ||
| InMemoryMCPCacheStore, | ||
| rehydrateMCPTools, | ||
| } from '../../../src/mcp/index.js'; |
There was a problem hiding this comment.
🟡 End-to-end test command fails for the compatibility package because it no longer has any tests
The only end-to-end test of the compatibility package was moved into the agent package (packages/agent/tests/e2e/mcp/mcp-tools.e2e.test.ts:1-6) without removing that package's end-to-end command, so running the repository's end-to-end suite now fails with "no test files found".
Impact: The end-to-end continuous-integration job errors out even when every test that exists passes.
Empty vitest project after the e2e test relocation
packages/mcp/vitest.config.ts still declares an e2e project with include: ['tests/e2e/**/*.test.ts'], and packages/mcp/package.json still defines "test:e2e": "vitest --run --project e2e". After this PR, packages/mcp/tests/ contains only unit/, so the e2e project matches zero files. Vitest exits non-zero when no test files are found unless passWithNoTests is set.
The root pnpm run test:e2e fans out via turbo to every package (turbo.json defines a test:e2e task), so the CI e2e-tests job (.github/workflows/ci.yaml) will fail once OPENROUTER_API_KEY is present.
Fix: drop the test:e2e script and the e2e project from packages/mcp, or set passWithNoTests: true for it.
Prompt for agents
The MCP e2e test was relocated from packages/mcp/tests/e2e to packages/agent/tests/e2e/mcp, leaving packages/mcp with an e2e vitest project (packages/mcp/vitest.config.ts) and a `test:e2e` script (packages/mcp/package.json) that match no files. Vitest exits non-zero when no test files are found, and the root `pnpm run test:e2e` fans out to every package through turbo, so the CI e2e job will fail. Either remove the `test:e2e` script and the `e2e` project from the compatibility package, or configure it to pass when there are no tests.
Was this helpful? React with 👍 or 👎 to provide feedback.
Move the tool-set and MCP implementations into @openrouter/agent subpath exports while retaining @openrouter/mcp as a compatibility facade. Keep the MCP SDK optional so base agent installs stay lean. Co-Authored-By: Claude <noreply@anthropic.com>
Load the optional MCP SDK only when a connection is requested and surface an actionable missing-peer error without pulling MCP into root imports. Mark the legacy MCP package as a migration facade and document the complete subpath surface. Add packed-install, export-map, dependency-version, resolution-condition, and architecture boundary checks. Also preserve widened ToolSet elements in dynamic FilterToolsByIds arrays and activate the maintained declaration fixtures. Co-Authored-By: Claude <noreply@anthropic.com>
Preserve current main's async-agent behavior while moving ToolSet and the MCP v2 client implementation under agent subpaths. Keep the MCP client as a lazily loaded optional peer, retain protocol negotiation behavior, and relocate implementation tests to the canonical package. Co-Authored-By: Claude <noreply@anthropic.com>
d6b0253 to
ba48ee0
Compare
There was a problem hiding this comment.
🔍 Large README sections removed alongside the move, but the features still exist
The README loses documentation for getUsage(), async tools (lifecycle: 'background' | 'deferred', task() check-ins, steering, agent tools), forced tool choices, strict schemas, per-tool timeouts/concurrency, and the enriched doom-loop round-set/per-call streak semantics. Those features are still implemented — e.g. getUsage is referenced throughout packages/agent/src/lib/model-result.ts, and packages/agent/src/lib/tool-task.ts / tool-check.ts / agent-tool.ts are present. Together with the deleted subpath exports (reported separately), this strongly suggests the second commit (fix(stack): resolve main and parent integration) reverted content from the parent branch rather than merging it. Worth re-doing the rebase against main/toolkits and diffing the README and package.json#exports explicitly.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
🔍 makeClientForTest became async; only remaining caller was in a deleted test
makeClientForTest now returns Promise<Client> because it awaits loadMcpSdk(). Its only consumers were packages/mcp/tests/unit/protocol-era.test.ts, which this PR deletes, so the documented "seam that lets a test drive the real handler wiring over InMemoryTransport" is now exported for no one. Either port those tests to packages/agent/tests/unit/mcp/ (they were the only end-to-end coverage of tools/list_changed dispatch and SDK response-cache eviction) or drop the seam.
(Refers to lines 163-171)
Was this helpful? React with 👍 or 👎 to provide feedback.
| "./tool-concurrency": { | ||
| "types": "./esm/lib/tool-concurrency.d.ts", | ||
| "default": "./esm/lib/tool-concurrency.js" | ||
| "./mcp": { | ||
| "types": "./esm/mcp/index.d.ts", | ||
| "default": "./esm/mcp/index.js" | ||
| }, | ||
| "./async-tool-registry": { | ||
| "types": "./esm/lib/async-tool-registry.d.ts", | ||
| "default": "./esm/lib/async-tool-registry.js" | ||
| "./mcp/create-mcp-tools": { | ||
| "types": "./esm/mcp/create-mcp-tools.d.ts", | ||
| "default": "./esm/mcp/create-mcp-tools.js" | ||
| }, | ||
| "./resume-tool-results": { | ||
| "types": "./esm/inner-loop/resume-tool-results.d.ts", | ||
| "default": "./esm/inner-loop/resume-tool-results.js" | ||
| "./mcp/types": { | ||
| "types": "./esm/mcp/types.d.ts", | ||
| "default": "./esm/mcp/types.js" | ||
| }, | ||
| "./tool-task": { | ||
| "types": "./esm/lib/tool-task.d.ts", | ||
| "default": "./esm/lib/tool-task.js" | ||
| "./mcp/schema": { | ||
| "types": "./esm/mcp/schema/json-schema-to-zod.d.ts", | ||
| "default": "./esm/mcp/schema/json-schema-to-zod.js" | ||
| }, | ||
| "./tool-check": { | ||
| "types": "./esm/lib/tool-check.d.ts", | ||
| "default": "./esm/lib/tool-check.js" | ||
| "./mcp/cache": { | ||
| "types": "./esm/mcp/cache/cache-store.d.ts", | ||
| "default": "./esm/mcp/cache/cache-store.js" | ||
| }, | ||
| "./agent-tool": { | ||
| "types": "./esm/lib/agent-tool.d.ts", | ||
| "default": "./esm/lib/agent-tool.js" |
There was a problem hiding this comment.
🔴 Several advertised import paths of the agent package stop working
Six published entry points are deleted from the package's list of importable paths (the removed ./tool-concurrency, ./async-tool-registry, ./resume-tool-results, ./tool-task, ./tool-check, ./agent-tool entries at packages/agent/package.json:100-122) even though the code behind them is still shipped, so any app importing them fails to load.
Impact: Existing applications that import those paths break immediately on upgrade with a module-resolution error.
Removed export map entries vs. still-present source modules
The modules still exist and are compiled into the tarball: packages/agent/src/lib/tool-concurrency.ts, packages/agent/src/lib/async-tool-registry.ts, packages/agent/src/inner-loop/resume-tool-results.ts, packages/agent/src/lib/tool-task.ts, packages/agent/src/lib/tool-check.ts, packages/agent/src/lib/agent-tool.ts. Because Node enforces the exports map strictly, import { ToolTask } from '@openrouter/agent/tool-task' now throws ERR_PACKAGE_PATH_NOT_EXPORTED. The PR's stated intent is only to add /tool-set and /mcp subpaths; these deletions look like a bad merge/rebase (the same commit also strips getUsage(), async-tool, and forced-tool-choice sections from packages/agent/README.md while the implementations remain in packages/agent/src/lib/model-result.ts).
Prompt for agents
packages/agent/package.json lost six subpath exports (./tool-concurrency, ./async-tool-registry, ./resume-tool-results, ./tool-task, ./tool-check, ./agent-tool) while the corresponding source modules (src/lib/tool-concurrency.ts, src/lib/async-tool-registry.ts, src/inner-loop/resume-tool-results.ts, src/lib/tool-task.ts, src/lib/tool-check.ts, src/lib/agent-tool.ts) still exist and are built. This appears to be an unintended merge/rebase regression, since the PR only intends to add ./tool-set and ./mcp. Restore the removed exports alongside the new ones, and re-check the README sections that were removed in the same commit (getUsage(), async tools, forced tool choices, per-tool timeouts) against what the code still implements.
Was this helpful? React with 👍 or 👎 to provide feedback.
| readonly packageName = '@modelcontextprotocol/sdk'; | ||
|
|
||
| constructor(options?: { | ||
| cause?: unknown; | ||
| }) { | ||
| super( | ||
| 'MCP support requires the optional peer "@modelcontextprotocol/sdk". ' + | ||
| 'Install it alongside @openrouter/agent (for example: pnpm add @modelcontextprotocol/sdk).', |
There was a problem hiding this comment.
🔴 Missing-dependency error tells users to install a package that does not satisfy the requirement
The failure raised when MCP support is used without its optional dependency names the wrong package ('@modelcontextprotocol/sdk' at packages/agent/src/mcp/errors.ts:141-148), while the code actually requires @modelcontextprotocol/client, so following the instructions leaves MCP still broken.
Impact: Users who follow the error message (and the README install command) install the wrong package and MCP keeps failing with the same error.
Package-name mismatch across the peer declaration, loader, error, docs and CI check
packages/agent/package.json:155-165declares the optional peer as@modelcontextprotocol/client.packages/agent/src/mcp/mcp-sdk.ts:35dynamically imports@modelcontextprotocol/clientandmcp-sdk.ts:24matches that name to decide the module is missing.- The thrown error says
@modelcontextprotocol/sdkand itspackageNamefield is@modelcontextprotocol/sdk. packages/agent/README.md(pnpm add @openrouter/agent @modelcontextprotocol/sdk),packages/mcp/README.md, and.changeset/agent-mcp-subpath.mdrepeat the wrong name.scripts/verify-package-boundaries.mjs:158asserts the message containspnpm add @modelcontextprotocol/sdk, so CI locks in the wrong name instead of catching it.
| readonly packageName = '@modelcontextprotocol/sdk'; | |
| constructor(options?: { | |
| cause?: unknown; | |
| }) { | |
| super( | |
| 'MCP support requires the optional peer "@modelcontextprotocol/sdk". ' + | |
| 'Install it alongside @openrouter/agent (for example: pnpm add @modelcontextprotocol/sdk).', | |
| 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).', |
Was this helpful? React with 👍 or 👎 to provide feedback.
| !isAuthFailure({ | ||
| err, | ||
| auth: effectiveAuth, | ||
| UnauthorizedErrorType: undefined, | ||
| }) |
There was a problem hiding this comment.
🔴 Rebuilding tools from a cached server snapshot can restart a login flow after credentials were rejected
The credential-rejection check is called without the type it needs to recognise an authorization failure (UnauthorizedErrorType: undefined at packages/agent/src/mcp/rehydrate.ts:622-626), so a rejected login is treated as an ordinary connection error and a second connection attempt is made.
Impact: A user whose credentials were refused can be sent through the sign-in redirect a second time, overwriting the stored login state that the first attempt saved.
Loss of the UnauthorizedError branch in isAuthFailure
Before this PR, isAuthFailure(err, auth) (packages/agent/src/mcp/mcp-connection.ts) checked err instanceof UnauthorizedError unconditionally — that branch is documented as the primary signal, since the SDK only throws it from the provider-wrapped fetch and the authorization flow. The refactor makes the error class a parameter, and rehydrate.ts passes undefined for it, so only the narrower isOAuthAuth(auth) && status === 401 branch can ever match there. A replay connect that fails with UnauthorizedError (wrapped in MCPConnectionError) now falls through to freshConnect, re-driving redirectToAuthorization / saveCodeVerifier — the exact duplicated side effect the guard exists to prevent (and the concern the surrounding comment still documents). rehydrate.ts can obtain the real class by awaiting loadMcpSdk() (packages/agent/src/mcp/mcp-sdk.ts:34), which is already resolved by the time a connect has failed.
Prompt for agents
packages/agent/src/mcp/rehydrate.ts calls isAuthFailure with UnauthorizedErrorType: undefined, which disables the `err instanceof UnauthorizedError` branch of the guard in packages/agent/src/mcp/mcp-connection.ts. The result is that a replay connect rejected for credentials is no longer recognised as an auth failure, so the freshConnect fallback runs and re-drives the OAuth authorization flow. Make the real UnauthorizedError constructor available at this call site — e.g. by awaiting loadMcpSdk() (packages/agent/src/mcp/mcp-sdk.ts) before the guard, or by having mcp-connection export a helper that loads the SDK itself — so the check behaves as it did before the lazy-loading refactor.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // DO NOT EDIT — generated from package.json by scripts/gen-version.mjs. | ||
| // Run `pnpm --filter @openrouter/mcp gen:version` after bumping the version. | ||
|
|
||
| /** This package's version, self-reported to MCP servers as `clientInfo`. */ | ||
| export const PACKAGE_VERSION = '0.0.1'; |
There was a problem hiding this comment.
🟡 MCP servers are told a hard-coded, wrong client version
The version this library reports to remote servers is hard-coded to '0.0.1' (packages/agent/src/mcp/version.ts:5) and nothing regenerates it any more, so every server sees a version that has no relationship to the shipped release.
Impact: Server-side logging, analytics, and any version-dependent behaviour see a permanently incorrect client version.
Generator and drift test both removed
PACKAGE_VERSION is consumed as clientInfo in packages/agent/src/mcp/mcp-connection.ts:41-46. The file's header comment tells the reader to run pnpm --filter @openrouter/mcp gen:version, but this PR deletes that script from packages/mcp/package.json (gen:version, and the node scripts/gen-version.mjs prefix on build/compile), and the agent package has no equivalent script. The drift guard packages/mcp/tests/unit/version.test.ts was deleted as well, so nothing detects the mismatch. The value is now inherited from the old facade package rather than from @openrouter/agent's own 0.8.0.
Prompt for agents
packages/agent/src/mcp/version.ts hard-codes PACKAGE_VERSION = '0.0.1' and its comment points at `pnpm --filter @openrouter/mcp gen:version`, a script this PR removed from packages/mcp/package.json. The constant is sent to every MCP server as clientInfo (packages/agent/src/mcp/mcp-connection.ts DEFAULT_CLIENT_INFO). Either move the generator (scripts/gen-version.mjs) into packages/agent so the constant tracks the agent package version and wire it into the agent build/gen:version tasks (the root `version` script already runs `turbo run gen:version`), or read the version another way. Also restore an equivalent drift test to the one deleted at packages/mcp/tests/unit/version.test.ts.
Was this helpful? React with 👍 or 👎 to provide feedback.
| --- | ||
| "@openrouter/agent": minor | ||
| "@openrouter/mcp": minor | ||
| --- |
There was a problem hiding this comment.
🟡 Release notes declare a compatible update even though public entry points are removed
The release note marks both packages as a backward-compatible feature release ("@openrouter/agent": minor at .changeset/agent-mcp-subpath.md:1-4) while the change deletes published entry points and exported error types.
Impact: Consumers get a breaking change delivered as a routine minor upgrade, with no migration guidance in the changelog.
Repository bump-type rule
.agents/skills/changeset-versioning/SKILL.md states: "major — Breaking API changes (e.g. changing callModel signature, removing exports)". This PR removes six subpath exports from packages/agent/package.json (see the ./tool-concurrency, ./tool-task, ./agent-tool, … entries) and removes MCPCacheWriteError, MCPStaleSnapshotError, MCPOAuthClientProvider, MCPProtocolNegotiation, and MCPProtocolRevision from the @openrouter/mcp surface, so a major bump (or restoration of those exports — see the other findings) is required.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function pack(packageDir) { | ||
| const output = run({ | ||
| command: 'pnpm', | ||
| args: [ | ||
| 'pack', | ||
| '--pack-destination', | ||
| packDir, | ||
| '--json', | ||
| ], | ||
| cwd: join(root, packageDir), | ||
| capture: true, | ||
| }); | ||
| const result = JSON.parse(output); | ||
| const filename = Array.isArray(result) ? result[0]?.filename : result.filename; | ||
| if (typeof filename !== 'string') { | ||
| throw new Error(`Could not determine tarball name for ${packageDir}`); | ||
| } | ||
| return resolve(join(root, packageDir), filename); | ||
| } |
There was a problem hiding this comment.
🔍 pnpm pack --json filename resolution may point at the wrong directory
pack() runs pnpm pack --pack-destination <packDir> --json with cwd set to the package directory, then returns resolve(join(root, packageDir), filename). That is only correct if pnpm reports an absolute path (in which case resolve ignores the base) — if it reports a bare tarball basename, the resolved path points into the package directory while the tarball actually lives in packDir, and the subsequent tar -tzf fails. Since this script is now a required CI job (package-boundaries in .github/workflows/ci.yaml), it is worth confirming the output shape for the pinned pnpm version, or simply resolving against packDir.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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<string, unknown>)[key]).toBe( | ||
| (agentMcp as Record<string, unknown>)[key], | ||
| ); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🔍 Facade tests only assert parity with the (already reduced) agent entry point
The new facade tests compare Object.keys(@openrouter/mcp) against Object.keys(@openrouter/agent/mcp) and check reference identity. This design can never detect an export that was dropped from both sides — which is exactly what happened to MCPCacheWriteError / MCPStaleSnapshotError / MCPOAuthClientProvider. A snapshot of the previously published export list (or an explicit expected-names array) would make the compatibility promise in the README enforceable. Likewise, the deleted packages/mcp/tests/unit/mcp-connection.test.ts, protocol-era.test.ts, and call-tool-shape.test.ts were not re-added under packages/agent/tests/unit/mcp/, so the transport ladder, legacy-retry degradation, auth-failure suppression, and callTool arity are now untested — notable given the auth-guard regression introduced by the lazy SDK loading.
Was this helpful? React with 👍 or 👎 to provide feedback.
| !isAuthFailure({ | ||
| err, | ||
| auth: effectiveAuth, | ||
| UnauthorizedErrorType: undefined, | ||
| }) |
There was a problem hiding this comment.
🟨 Rehydrate no longer detects rejected credentials, re-driving the OAuth authorization flow
packages/agent/src/mcp/rehydrate.ts:622-626 calls isAuthFailure with UnauthorizedErrorType: undefined, disabling the err instanceof UnauthorizedError branch of the guard defined in packages/agent/src/mcp/mcp-connection.ts:427-486. A replay connect rejected for credentials therefore falls through to freshConnect, which re-enters the SDK's OAuth path: a second redirectToAuthorization and a second saveCodeVerifier that overwrites the PKCE verifier saved by the first attempt. The surrounding comment still documents that this must not happen.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Perry's Review
Verdict: 🔁 Needs changes
Risk: 🔴 High
Risk assessment:
| Dimension | Severity | Risk | Reasoning |
|---|---|---|---|
| Implementation risk | 🟥🟥🟥 | High | CI is red on 3 of 6 checks (typecheck, unit-tests, e2e-tests); the package does not compile and tests do not pass. |
| Premise risk | 🟨🟨 | Medium | The refactoring premise (nesting tool-set + MCP under agent subpaths, optional MCP peer) is sound, but the execution has gaps from the move. |
| Estimated impact | 🟥🟥🟥 | High | If merged as-is the agent package fails to compile; a user-facing error message directs users to install the wrong npm package, leaving them stuck. |
| Risk Factor | Severity | Risk | Reasoning |
|---|---|---|---|
| Reversibility | 🟩 | Low | The change is a refactor; reverting restores prior state. |
| Detectability | 🟨🟨 | Medium | The package-name bug is only visible when the MCP peer is missing (not the CI environment). |
| Blast radius | 🟥🟥🟥 | High | Every consumer of @openrouter/agent and @openrouter/mcp is affected by the broken build and facade. |
| Data integrity | 🟩 | Low | No persisted state is touched. |
| Financial exposure | 🟩 | Low | No billing or payment paths. |
| Security and privacy exposure | 🟩 | Low | No auth, credential, or tenant-isolation surfaces changed. |
| Propagation | 🟥🟥🟥 | High | Downstream consumers absorb the broken build and misleading error message. |
| Availability | 🟨🟨 | Medium | Consumers depending on the MCP subpath cannot build or run. |
| Recovery cost | 🟩 | Low | A fix commit addresses all findings. |
| Time to correct | 🟩 | Low | The fixes are localized (import paths, error string, dead config). |
Findings
CI is red — 3 of 6 checks failing
The PR description marks pnpm typecheck, pnpm test, and pnpm verify:packages as passing, but CI on this head SHA (ba48ee0d) fails typecheck, unit-tests, and e2e-tests. The PR is not merge-ready.
🔴 Blocker 1 — typecheck: MCP code self-imports the agent's own package subpaths (TS2307)
The MCP implementation was moved from the standalone @openrouter/mcp package into packages/agent/src/mcp/, but three moved files still import the agent's own subpaths via the package name rather than relative paths:
packages/agent/src/mcp/build-tools.ts—import type { Tool, ToolLoopKey } from '@openrouter/agent/tool-types'packages/agent/src/mcp/resource-tools.ts—import { markMcp, tool } from '@openrouter/agent/tool'andimport type { McpBranded } from '@openrouter/agent/tool-types'packages/agent/src/mcp/tool-wrapper.ts— same two@openrouter/agent/tooland@openrouter/agent/tool-typesimports
These were valid cross-package imports when the code lived in @openrouter/mcp (the agent was built first via ^build). Now that the code is inside the agent package, they are self-references. The agent's tsconfig.json sets customConditions: [] (overriding the base ["source"]), so tsc resolves them through the exports map → ./esm/..., which does not exist during typecheck (the typecheck task depends on ^build, not a self-build). Result: error TS2307: Cannot find module '@openrouter/agent/tool-types' (and tool).
The sibling file packages/agent/src/mcp/types.ts (newly written for this move) already uses the correct relative form import type { Tool, ToolLoopKey } from '../lib/tool-types.js'. Fix: change the five import lines to ../lib/tool.js and ../lib/tool-types.js.
🔴 Blocker 2 — unit-tests: facade parity tests can't resolve @openrouter/mcp/* (ERR_MODULE_NOT_FOUND)
The new facade parity tests import @openrouter/mcp/cache, @openrouter/mcp, @openrouter/mcp/schema, and @openrouter/mcp/create-mcp-tools (package self-imports that resolve through the mcp package's exports map to ./esm/*.js). turbo's test task depends on ^build only — the mcp package's own esm/ is never built before its tests run — so vite resolves these to nonexistent files and throws ERR_MODULE_NOT_FOUND. The @openrouter/agent/mcp/* imports in the same tests succeed because the agent IS built first. Fix: either configure vite to resolve workspace self-imports to source, import from relative source paths in the tests, or add a self-build dependency to the mcp test task.
🔴 Blocker 3 — e2e-tests: orphaned test:e2e in the mcp package ("No test files found")
The e2e test was moved to packages/agent/tests/e2e/mcp/mcp-tools.e2e.test.ts, but the mcp package still has the test:e2e script (vitest --run --project e2e) and the e2e vitest project (include tests/e2e/**/*.test.ts). The mcp package's e2e test directory is now empty → vitest exits 1 with "No test files found". Remove the test:e2e script from packages/mcp/package.json and the e2e project from the mcp vitest config.
🔴 Blocker 4 — MCPMissingPeerDependencyError tells users to install the wrong package
packages/agent/src/mcp/errors.ts hardcodes packageName = '@modelcontextprotocol/sdk' and the message says "Install ... @modelcontextprotocol/sdk". But the actual dynamic import (mcp-sdk.ts: import('@modelcontextprotocol/client')), the package.json peer/dev dependency, and the lockfile all use @modelcontextprotocol/client. The @modelcontextprotocol/sdk package is not even in the lockfile. A user who hits the missing-peer error installs @modelcontextprotocol/sdk as instructed, which does not satisfy import('@modelcontextprotocol/client') — the error persists and the user is stuck. This is the "actionable" error the PR highlights, and it points at the wrong package. Fix: change packageName and the message to @modelcontextprotocol/client. The same mismatch appears in the changeset and the tsconfig.json comment — update those too.
🟡 Suggestion — dead code: the mcp package's close-quietly.ts is an orphaned duplicate
The mcp facade still contains a close-quietly.ts that is not imported by anything in that package (the live implementation moved to packages/agent/src/mcp/close-quietly.ts). It is dead code in the facade package — safe to delete.
Notes
- The sentrux boundary rules and the new
verify-package-boundariesCI job are well done — the inner-loop/lib → mcp isolation is enforced, andpackage-boundariespassed. - The
model-result.tschange adding thesource: 'mcp' | 'client'discriminant to tool-result broadcasts is a reasonable approach for the type-level tests inmcp-result-discrimination.test-d.ts. - Removing the
@openrouter/agent-tool-setpackage (version0.0.0, never published) and the stale./tool-concurrency,./async-tool-registry,./resume-tool-results,./tool-taskexports (no internal consumers) is clean.
| "build": "node scripts/gen-version.mjs && tsc", | ||
| "build": "tsc", | ||
| "test": "vitest --run --project unit", | ||
| "test:e2e": "vitest --run --project e2e", |
There was a problem hiding this comment.
🔴 Blocker — orphaned test:e2e breaks e2e CI.
The e2e test was moved to packages/agent/tests/e2e/mcp/mcp-tools.e2e.test.ts, but this test:e2e script and the e2e project in vitest.config.ts remain. The mcp package's tests/e2e/ directory is now empty, so vitest --run --project e2e exits 1 with "No test files found" — this is the e2e-tests CI failure.
Remove the test:e2e script here and the e2e project from packages/mcp/vitest.config.ts.
▶ Prompt for agents: Delete the test:e2e line from packages/mcp/package.json and remove the e2e project entry from the projects array in packages/mcp/vitest.config.ts.
| * Raised when MCP support is used without its optional SDK peer installed. | ||
| */ | ||
| export class MCPMissingPeerDependencyError extends MCPConnectionError { | ||
| readonly packageName = '@modelcontextprotocol/sdk'; |
There was a problem hiding this comment.
🔴 Blocker — wrong package name in the missing-peer error.
packageName and the message reference @modelcontextprotocol/sdk, but the actual dynamic import is import('@modelcontextprotocol/client') (mcp-sdk.ts), and package.json declares @modelcontextprotocol/client as the peer dependency. The @modelcontextprotocol/sdk package is not in the lockfile.
A user who hits this error installs @modelcontextprotocol/sdk as instructed — it does not satisfy import('@modelcontextprotocol/client'), so the error persists and the user is stuck.
Change packageName and the install instructions to @modelcontextprotocol/client.
▶ Prompt for agents: In packages/agent/src/mcp/errors.ts, replace both occurrences of @modelcontextprotocol/sdk with @modelcontextprotocol/client (the packageName field and the error message string).
| @@ -1,204 +1,27 @@ | |||
| import * as agentCache from '@openrouter/agent/mcp/cache'; | |||
| import * as wrapperCache from '@openrouter/mcp/cache'; | |||
There was a problem hiding this comment.
🔴 Blocker — facade self-import can't resolve at test time (ERR_MODULE_NOT_FOUND).
This test imports @openrouter/mcp/cache (package self-import → exports map → ./esm/cache.js). turbo test depends on ^build only, so the mcp package's own esm/ is never built before its tests run — vite resolves to a nonexistent file and throws ERR_MODULE_NOT_FOUND. The same failure hits the @openrouter/mcp, /schema, and /create-mcp-tools imports in the other facade parity tests.
Fix one of: (a) configure vite to resolve workspace self-imports to source (e.g. resolve.conditions: ['source'] or server.deps.inline), (b) import from relative source paths (../../src/cache.js), or (c) add a self-build dependency to the mcp test task.
▶ Prompt for agents: Either add resolve.conditions: ['source'] (or server: { deps: { inline: ['@openrouter/mcp'] } }) to packages/mcp/vitest.config.ts, or change the facade parity test imports from @openrouter/mcp/* to relative source paths like ../../src/cache.js.
| const result = callModel(new OpenRouter(), { | ||
| model: 'openai/gpt-4o-mini', | ||
| input: 'Use the remote tools.', | ||
| tools: mcp.tools, |
There was a problem hiding this comment.
🔴 Blocker — changeset directs users to the wrong package.
This line tells users to install @modelcontextprotocol/sdk, but the actual peer dependency and import is @modelcontextprotocol/client (see packages/agent/package.json peerDependencies and mcp-sdk.ts). Same mismatch as errors.ts. Users who follow this install the wrong package.
Update both references in this changeset (line 7 and line 16) to @modelcontextprotocol/client.
▶ Prompt for agents: In .changeset/agent-mcp-subpath.md, replace @modelcontextprotocol/sdk with @modelcontextprotocol/client in the prose and the install instruction.
Summary
@openrouter/agent/tool-setsubpath and removes the unpublished standalone@openrouter/agent-tool-setpackage.@openrouter/agent/mcp, with nested/create-mcp-tools,/types,/schema, and/cacheexports.@modelcontextprotocol/sdkas an optional peer of@openrouter/agent, so base agent installs do not pull MCP dependencies.@openrouter/mcppackage into a thin compatibility facade over the canonical agent subpaths.API example
Consumers of
/mcpinstall the optional peer alongside agent:Existing
@openrouter/mcproot and nested imports continue to work.Package size / tree shaking
@openrouter/agentremainssideEffects: false./tool-setentries do not statically import MCP modules./tool-setwithout installing the MCP SDK.Verification
pnpm buildpnpm lintpnpm typecheckpnpm testpnpm changeset status/tool-set, no MCP SDK installed/mcpwith optional peer installed@openrouter/mcproot + all legacy nested exports🤖 Generated with Claude Code
Package-boundary hardening
@modelcontextprotocol/sdklazily and reports an actionableMCPMissingPeerDependencyErroronly when an MCP connection actually needs a missing peer.@openrouter/mcpand its nested paths as migration facades while preserving every existing import.pnpm verify:packages, which packs both packages, validates every export target and tarball allowlist, installs them into a clean consumer, checks optional-peer isolation, and rejects duplicate/version-skewed agent resolution.FilterToolsByIds<readonly Tool[]>fallback so widened tool IDs do not collapse tonever[].Verification
pnpm buildpnpm lintpnpm typecheckpnpm test— 779 agent tests and 17 facade testspnpm verify:packagessentrux gategit diff --check