Skip to content

feat(mcp): add @openrouter/mcp package#56

Merged
mattapperson merged 11 commits into
mainfrom
worktree-openrouter-mcp-package
Jun 30, 2026
Merged

feat(mcp): add @openrouter/mcp package#56
mattapperson merged 11 commits into
mainfrom
worktree-openrouter-mcp-package

Conversation

@mattapperson

Copy link
Copy Markdown
Collaborator

Summary

Adds a new @openrouter/mcp package that turns a remote MCP server (non-stdio: Streamable HTTP or SSE) into tools consumable by @openrouter/agent's callModel({ tools }).

  • createMCPTools({ url, auth }) connects, authenticates once, discovers tools, and returns a handle whose .tools drop straight into callModel. The same auth is reused for discovery and every tool call. Supports static bearer token, custom headers, or a pluggable OAuthClientProvider.
  • Faithful JSON-Schema → Zod v4 conversion (convertMcpInputSchema) via the built-in z.fromJSONSchema, with a per-property looseLeaf fallback for unrepresentable keywords. This is required, not optional: the agent SDK derives the model-facing parameters solely from the tool's Zod inputSchema, so a passthrough schema would make the model call tools blind.
  • Serializable, rehydratable cache: serialize() / rehydrateMCPTools() / pluggable MCPCacheStore + InMemoryMCPCacheStore. The happy path skips listTools() and (opt-in) re-auth; falls back to a full reconnect on token expiry / missing creds / connection failure. Credential caching is off by default.
  • MCP feature support: tool output schemas, progress notifications surfaced as generator-tool events, tools/list_changed auto-refresh, cancellation via an abort signal, resources exposed as synthetic list_resources/read_resource tools, and elicitation with an optional handler (auto-declines when none provided).

Built on the official @modelcontextprotocol/sdk (v1). Mirrors @openrouter/agent conventions (ESM, tsc→esm, biome, vitest unit+e2e, strict types — no any/as, every MCP-JSON boundary guarded with a typeguard).

Note

The plan assumed the abort signal would arrive via callModel's tool context, but the agent SDK's ToolExecuteContext doesn't expose one — so signal is a createMCPTools option (passed once, like auth) threaded into every callTool.

Test plan

  • pnpm turbo run build typecheck lint test green across the monorepo
  • 27 unit tests pass (schema conversion incl. looseLeaf fallback, cache round-trip + credential gating + snapshot validation, result mapping, elicitation accept/decline/cancel + auto-decline, tool wrapping + prefix/filter/collision)
  • No any / as in src/
  • e2e tests (tests/e2e/, gated on MCP_TEST_URL) run against a real remote MCP server

Expose remote MCP server tools (Streamable HTTP / SSE) as callModel tools,
with pluggable auth, faithful JSON-Schema->Zod conversion, and a
serializable, rehydratable cache.

@perry-the-pr-reviewer perry-the-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perry's Review

New @openrouter/mcp package that exposes a remote (Streamable HTTP / SSE) MCP server's tools as @openrouter/agent callModel tools, with JSON-Schema→Zod conversion, a serializable/rehydratable cache, and pluggable auth. The core wrapping, schema conversion, and result-mapping are solid and well-tested — but the cache rehydration path has three correctness blockers that defeat the headline caching feature.

Verdict: 🔁 Needs changes

Details

Risk: 🔴 High — human approval required (touches credential handling/persistence — Step 6c categories 1 & 7 — and the confirmed bugs are squarely in that auth/cache path)

CI: ⚠️ 1 failing: structural-gate — informational, not a review gate. Root cause confirmed: it reports Cycles 1 → 3, driven by the new create-mcp-tools.ts ⇄ rehydrate.ts circular dependency (worked around with a lazy dynamic import of rehydrate at create-mcp-tools.ts:112). Worth breaking the cycle (e.g. a shared internals module) so the gate passes.

Findings (see inline comments for full context):

  • 🔴 rehydrate.ts:108 — expiry/missing-cred fallback re-enters the same cache → unbounded recursion (fresh-by-maxAge + token-expired snapshot)
  • 🔴 rehydrate.ts:112 — snapshot auth never replayed into the reconnect; cacheCredentials snapshots reconnect unauthenticated (contradicts README)
  • 🔴 create-mcp-tools.ts:113 — cache-hit rehydration drops toolNamePrefix/includeTools/excludeTools/resources/emitProgress/staleness/… → cached handle ≠ fresh handle
  • 🟡 create-mcp-tools.ts:28 — listTools not paginated; paginated servers expose only page 1
  • 🟡 resource-tools.ts:35 — list_resources not paginated; resources and templates silently truncated
  • 🟡 create-mcp-tools.ts:84 — connection leaked if initial listTools/writeCache throws
  • 🟡 create-mcp-tools.ts:221 — list_changed refresh is fire-and-forget; rejection is unhandled
  • nit: isOAuthAuth (auth-types.ts:26) is exported but unused anywhere in the package — dead code.

Codex (HEAVY_SECONDARY_MODEL): independently surfaced all three blockers (recursion, dropped cached creds, dropped caller options) plus the two pagination gaps, the connection leak, and the unhandled refresh rejection. All verified against source before posting.

Research: MCP TypeScript SDK — confirmed the tools-list, resources-list, and resource-templates-list endpoints are cursor-paginated (nextCursor, loop until absent); confirmed StreamableHTTPClientTransport replays static creds via requestInit headers and OAuth via authProvider, so the snapshot→reconnect credential gap at rehydrate.ts:112 is a real omission, not an SDK limitation.

Security: Credential handling reviewed (cat. 1) — cacheCredentials defaults to false, the cache-store JSDoc + README correctly warn to treat the store as a secret store and namespace by principal, and no secrets are committed. No exploitable issue found, but the auth-replay bug (rehydrate.ts:112) means the secure feature is currently non-functional rather than unsafe.

Test coverage: Strong unit coverage for schema conversion, result mapping, build-tools, elicitation, and serialize. Gap: the rehydration paths (rehydrate.ts, tryCacheHit) and the mcp-connection fallback have no unit tests — exactly the code where all three blockers live. The e2e tests are skipped without a live MCP_TEST_URL. Adding rehydrate unit tests with a seeded InMemoryMCPCacheStore (expired-token + cacheCredentials cases) would have caught all three.

Unresolved threads: none (first review)

Scope: first review (full)
Review: tier=large · model=claude-opus-latest · score=?

Comment thread packages/mcp/src/rehydrate.ts Outdated
Comment thread packages/mcp/src/rehydrate.ts
Comment thread packages/mcp/src/create-mcp-tools.ts
Comment thread packages/mcp/src/create-mcp-tools.ts Outdated
Comment thread packages/mcp/src/resource-tools.ts Outdated
Comment thread packages/mcp/src/create-mcp-tools.ts Outdated
Comment thread packages/mcp/src/create-mcp-tools.ts Outdated
…pagination

- freshConnect breaks the recursive cache-fallback loop (no-auth/expired
  snapshots no longer re-read their own cache and re-enter rehydrate)
- reconstruct MCPAuth from a credential-bearing snapshot so reconnects
  authenticate when no explicit auth is passed
- thread toolNamePrefix/include/exclude/resources/emitProgress/
  autoRefresh/cacheCredentials through cache hits so filters and prefixes
  survive rehydration
- follow nextCursor in tools/list, resources/list, and templates/list
… thread clientInfo

Addresses remaining Perry review threads:
- freshConnect tears down the connection if listToolDefs or the initial
  writeCache throws, so a failed setup never leaks the open transport
- the tools/list_changed auto-refresh now .catch()es, so a failed refresh
  cannot escape as an unhandled rejection; listeners keep the last good set
- thread clientInfo through the cache-hit/rehydrate path so a warm handle
  initializes with the same client identity as a cold one
The sentrux structural gate failed on cycle + quality + complexity
regressions. Resolved without weakening the baseline:

- extract makeHandle/freshConnect/listToolDefs into a leaf handle.ts so
  create-mcp-tools and rehydrate no longer form a cycle
- move MCPTransportKind to a leaf transport-types.ts, breaking the
  types <-> cache-types cycle
- import Tool/tool from @openrouter/agent's deep subpaths
  (/tool, /tool-types) instead of the barrel, so mcp doesn't absorb the
  agent lib SCC
- drop the unused ExecuteToolsResult interface in agent's tool-types.ts,
  which removes the tool-types -> model-result edge and breaks agent's
  pre-existing lib cycle (this is what mcp was being dragged into)
- collapse tryCacheHit's option forwarding into a data-driven helper to
  drop it back under the max-cyclomatic-complexity threshold

Gate now: cycles 1->1, quality 0.51->0.52, no degradation.
Extract the buildTools-args and serializeServer-input assembly out of
makeHandle into buildToolsArgs/serializeArgs helpers, dropping makeHandle
from cc=17 to under the gate's threshold of 15. No behavior change.
@perry-the-pr-reviewer perry-the-pr-reviewer Bot dismissed their stale review June 26, 2026 16:55

Superseded by updated Perry review

perry-the-pr-reviewer[bot]

This comment was marked as outdated.

A poisoned snapshot with NaN/Infinity cachedAt passed isSerializedMCPServer
(typeof NaN === 'number'), making tryCacheHit's staleness comparison always
false so the entry never expired. Mirror serialize.ts's isExpiry guard with a
Number.isFinite check. Also document that includeTools/excludeTools apply to
discovered MCP tools only; resource tools are gated by the resources option.
perry-the-pr-reviewer[bot]

This comment was marked as outdated.

…ializer

Extract the 'valid cachedAt' predicate into a single exported isFiniteEpoch
in cache-types.ts, consumed by both the read-side isSerializedMCPServer
validator and the write-side serializer, so the two can't drift if the rule
is later tightened. No behavior change.
perry-the-pr-reviewer[bot]

This comment was marked as outdated.

An epoch (ms) is non-negative by definition; rejecting negatives validates
the invariant at the snapshot boundary instead of relying on downstream
maxAge arithmetic to fail-safe on a clock-skewed/garbage value.
perry-the-pr-reviewer[bot]

This comment was marked as outdated.

isFiniteEpoch's value>=0 tightening means serializeServer now substitutes
Date.now() for a negative cachedAt; assert that branch produces a valid,
non-negative epoch that passes the read validator.
perry-the-pr-reviewer[bot]

This comment was marked as outdated.

…'t untype client tools

Adds a `source: 'client' | 'mcp'` discriminant to ToolExecutionResult and the
streaming ToolResultEvent. An MCP tool's `unknown` output no longer collapses the
whole result union — narrowing on source keeps client-tool results precise and
isolates MCP results as unknown.

- agent: McpBranded type, isMcpTool guard, markMcp helper; source threaded through
  ToolExecutionResult(Union), the broadcaster channels, and tool.result events;
  source stamped at every result-construction site (executor + orchestrator).
- mcp: wrapMcpTool and buildResourceTools brand their tools via markMcp.
- ToolExecutionResult.toolName now infers the literal name where available.
- New mcp-result-discrimination.test-d.ts locks the guarantee.

Breaking: the tool.result streaming payload now includes `source`.

Verified: full monorepo build/typecheck/lint/test green (295 agent + 39 mcp tests,
incl. type-level tests); sentrux structural gate passes (cycles 1->1, quality up,
no complex-function regression).
Tighten the fallback test to pin Date.now() substitution (before <= cachedAt <=
now) rather than mere non-negativity, catching a regression to a hard-coded
sentinel.

@perry-the-pr-reviewer perry-the-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perry's Review

Incremental re-review: this push adds a source discriminant (client vs mcp) to tool results — plus markMcp(), isMcpTool() and the McpBranded type — so an untyped MCP tool no longer collapses the precise result types of fully-typed client tools.

Verdict: 💬 Comments / questions

Details

Risk: 🟢 Low

CI: all passing ✅ (typecheck, lint, unit-tests, e2e-tests, structural-gate)

Findings (see inline comment):

  • 🟡 tool-types.ts:844 — the streaming tool-result event gains the source field but its result stays flat, so the streaming surface does not get the type-level discrimination the non-streaming result path got.

What I verified (incremental scope vs prior reviewed SHA 38bf22d):

  • The brand-first ordering in the non-streaming result type is correct — the MCP brand is tested before the execute/generator branch, isolating MCP unknown under source mcp while typed tools keep precise results under source client. Confirmed by tsc --noEmit (exit 0) and the new type-level test mcp-result-discrimination (vitest --typecheck: 0 type errors).
  • Blast radius: every tool-execution-result construction site got the new required source field — all 7 executor returns (regular/generator/HITL, success + catch), both orchestrator sites, and the model-result toolResults mapper + broadcastToolResult. A missing site would fail typecheck; CI typecheck is green.
  • Runtime / type-level consistency: markMcp shallow-copies preserving the client-tool structure, and isClientTool (not server-tool) still matches MCP-branded tools, so the find lookups in model-result resolve MCP tools. The orchestrator hardcoded client at line 95 is the tool-not-found path (no tool object to inspect — correct default); the rejected path at line 153 correctly resolves via isMcpTool(rejectedTool).
  • The cache test negative-cachedAt assertion tightening verified (7/7 pass) — it now asserts the fallback is a fresh Date.now(), catching a regression to a hard-coded sentinel.

Codex (gpt-5.5): 1 in-delta finding surfaced (the streaming-event parity gap, posted inline). Its other findings target rehydrate / create-mcp-tools / mcp-connection — outside this incremental delta (prior-reviewed PR content), several already resolved in earlier rounds.

Research: Confirmed against TypeScript narrowing semantics (microsoft/TypeScript issue 55425; the TS narrowing handbook) — an unknown or imprecise union member poisons sibling members, and the tool() factory widens the name literal to string, so a literal source discriminant (not toolName) is the correct design. The approach is sound.

Security: no concerns — type-system and tool-result plumbing only; no secrets, auth, SSE, logging, or rendering touched in the delta.

Test coverage: the new type-level test covers the non-streaming result discrimination well. The streaming-event discrimination is not type-tested (see inline).

Unresolved threads: none — all 13 prior threads (Perry's own, from earlier rounds) are resolved and fixed by the author.

Scope: incremental — 353 new lines since prior review (38bf22d)
Review: tier=large · model=claude-opus-latest · score=?

* consumers discriminate so an MCP result's `unknown` doesn't force callers to
* treat every tool result as untyped.
*/
source: 'client' | 'mcp';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion][codex] streaming ToolResultEvent gains source but its result: TResult is not discriminated by it — narrowing a streamed tool.result on source === 'client' does NOT recover precise client result types the way the non-streaming ToolExecutionResult does.

Details

Why: ToolExecutionResult['result'] was upgraded to a conditional that is brand-first (MCP → unknown, else zodInfer<O>), so a mixed tuple narrowed on source === 'client' yields the precise union of typed results. ToolResultEvent (line 835) instead keeps a single flat result: TResult independent of source. On the streaming surface TResult is instantiated as InferToolOutputsUnion<TTools> (see getFullResponsesStream / ResponseStreamEvent), which already collapses to unknown the moment any MCP tool is present in the array — exactly the poisoning this PR set out to prevent for the non-streaming path. So a consumer of getFullResponsesStream() / getToolStream() that narrows a tool.result event on source === 'client' still sees result: unknown.

This is a parity gap, not a correctness bug: source is genuinely useful for runtime discrimination on the stream (and the changeset documents it as such). But the changeset's framing — that ToolResultEvent "gains the same source field" — implies callers get the same type-level recovery, which they do not.

Fix: make the event's result conditional on source, mirroring ToolExecutionResult — e.g. split into a { source: 'client'; result: <typed union> } | { source: 'mcp'; result: unknown } discriminated shape, and thread the per-tool result type through ResponseStreamEvent so the client arm carries InferToolOutput of the non-MCP tools rather than the whole collapsed union. If type-level recovery on the streaming path is explicitly out of scope, consider a one-line note in the changeset clarifying that source on the event is runtime-only.

Prompt for agents
In packages/agent/src/lib/tool-types.ts, ToolResultEvent (around line 835) added a `source: 'client' | 'mcp'` field but keeps a single flat `result: TResult`, so streaming consumers that narrow on source === 'client' still get `result: unknown` (TResult is instantiated as InferToolOutputsUnion<TTools>, which collapses to unknown when any MCP tool is present). Mirror the ToolExecutionResult fix: change ToolResultEvent into a discriminated union on `source` — `{ type:'tool.result'; toolCallId; source:'client'; result:<precise client result union>; timestamp; preliminaryResults? } | { ...; source:'mcp'; result: unknown }` — and thread the per-tool result type through ResponseStreamEvent / getFullResponsesStream / getToolStream so the client arm carries the typed union and the mcp arm carries unknown. Add a type-level test in tests/unit asserting that for a mixed [typedTool, mcpTool] array, a tool.result event narrowed on source==='client' has a precise result type, not unknown. If type-level recovery on the streaming path is intentionally out of scope, instead add a sentence to .changeset/mcp-result-discrimination.md clarifying that source on ToolResultEvent is for runtime discrimination only.

Reviewed at d3539ee

@mattapperson mattapperson merged commit 4e1f98d into main Jun 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants