Skip to content

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

Open
mattapperson wants to merge 9 commits into
mainfrom
worktree-openrouter-mcp-package
Open

feat(mcp): add @openrouter/mcp package#56
mattapperson wants to merge 9 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 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.

⚠️ APPROVE unavailable on this installation — the maintainer GitHub App is not configured with pull_requests:write for OpenRouterTeam, so the verdict below is posted as COMMENT. Event-level approval (for branch protection) must be added out-of-band. My intended verdict is ✅ LGTM.

Perry's Review

Incremental re-review of the openrouter MCP package PR. Since my last review at 1eb5184, the only net-new change is a single test case covering the serializeServer write-side negative-cachedAt fallback — exactly the forward-looking suggestion I raised on the prior head. Verified correct and behaviour-preserving.

Verdict: ✅ LGTM

Details

Risk: 🟢 Low — test-only addition (14 lines, one new test case); no production code path changed since the last reviewed SHA.

CI: all passing ✅ (5 of 5)

Scope: incremental — 14 new lines since prior review (1 test case)

What changed since 1eb5184: one new test in the serializeServer suite asserts that a negative cachedAt input is rejected by isFiniteEpoch and replaced with a current timestamp, and that the resulting snapshot passes the read-side validator. This closes the write-side coverage gap I flagged previously (the read-side reject was already covered). Confirmed the assertion holds: isFiniteEpoch rejects negatives via its value greater-than-or-equal-to-zero clause, the serializer falls back to a current timestamp which is always a positive epoch, so the snapshot validates.

Findings: none blocking. One forward-looking test-tightening suggestion posted inline (non-blocking).

Codex (HEAVY_SECONDARY_MODEL): no additional valid findings.

Research: confirmed via MDN that the current-time millisecond clock always returns a positive epoch (the Unix epoch is in the past), so the fallback reliably produces a non-negative value — the new test assertion is robust.

Security: no concerns — no secret patterns in the diff; the delta is test-only.

Test coverage: the new test fills the write-side fallback gap. Read-side and write-side negative-epoch handling are now both covered.

Unresolved threads: none — all 12 prior threads resolved.

Review: tier=large · model=claude-opus-latest · score=?

});
// isFiniteEpoch rejects the negative input, so serializeServer falls back to
// a current timestamp — the resulting snapshot must pass the read validator.
expect(snap.cachedAt).toBeGreaterThanOrEqual(0);

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] expect(snap.cachedAt).toBeGreaterThanOrEqual(0) — tighten this to prove a fresh timestamp was substituted, not merely any non-negative value.

Details

Why: The fallback path replaces the rejected -1 with Date.now(). toBeGreaterThanOrEqual(0) confirms the value is a valid epoch, but it would also pass if the serializer substituted 0 (or any other arbitrary non-negative constant) instead of the current time. Asserting the value is close to now pins the actual intended behaviour — that the serializer used a current timestamp — and would catch a regression where the fallback silently changed to a hard-coded sentinel.

Fix:

const before = Date.now();
const snap = await serializeServer({ /* ...cachedAt: -1 */ });
expect(snap.cachedAt).toBeGreaterThanOrEqual(before);
expect(snap.cachedAt).toBeLessThanOrEqual(Date.now());
expect(isSerializedMCPServer(snap)).toBe(true);

Ref: MDN — Date.now() (always returns a positive epoch for current time).

Prompt for agents
In packages/mcp/tests/unit/cache.test.ts around line 62, the test 'replaces a negative cachedAt with a valid epoch' asserts snap.cachedAt >= 0, which would also pass if the serializer substituted a hard-coded constant rather than a current timestamp. Capture const before = Date.now() immediately before calling serializeServer, then replace the >= 0 assertion with: expect(snap.cachedAt).toBeGreaterThanOrEqual(before) and expect(snap.cachedAt).toBeLessThanOrEqual(Date.now()). Keep the existing isSerializedMCPServer(snap) assertion. This pins the intended Date.now() fallback behaviour rather than merely non-negativity.

Reviewed at 38bf22d

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