feat(mcp): add @openrouter/mcp package#56
Conversation
Expose remote MCP server tools (Streamable HTTP / SSE) as callModel tools, with pluggable auth, faithful JSON-Schema->Zod conversion, and a serializable, rehydratable cache.
There was a problem hiding this comment.
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:
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=?
…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.
Superseded by updated Perry review
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.
…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.
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.
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.
There was a problem hiding this comment.
⚠️ 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); |
There was a problem hiding this comment.
[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
Summary
Adds a new
@openrouter/mcppackage that turns a remote MCP server (non-stdio: Streamable HTTP or SSE) into tools consumable by@openrouter/agent'scallModel({ tools }).createMCPTools({ url, auth })connects, authenticates once, discovers tools, and returns a handle whose.toolsdrop straight intocallModel. The same auth is reused for discovery and every tool call. Supports static bearer token, custom headers, or a pluggableOAuthClientProvider.convertMcpInputSchema) via the built-inz.fromJSONSchema, with a per-propertylooseLeaffallback for unrepresentable keywords. This is required, not optional: the agent SDK derives the model-facing parameters solely from the tool's ZodinputSchema, so a passthrough schema would make the model call tools blind.serialize()/rehydrateMCPTools()/ pluggableMCPCacheStore+InMemoryMCPCacheStore. The happy path skipslistTools()and (opt-in) re-auth; falls back to a full reconnect on token expiry / missing creds / connection failure. Credential caching is off by default.tools/list_changedauto-refresh, cancellation via an abort signal, resources exposed as syntheticlist_resources/read_resourcetools, and elicitation with an optional handler (auto-declines when none provided).Built on the official
@modelcontextprotocol/sdk(v1). Mirrors@openrouter/agentconventions (ESM, tsc→esm, biome, vitest unit+e2e, strict types — noany/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'sToolExecuteContextdoesn't expose one — sosignalis acreateMCPToolsoption (passed once, like auth) threaded into everycallTool.Test plan
pnpm turbo run build typecheck lint testgreen across the monorepoany/asinsrc/tests/e2e/, gated onMCP_TEST_URL) run against a real remote MCP server