diff --git a/.changeset/mcp-result-discrimination.md b/.changeset/mcp-result-discrimination.md new file mode 100644 index 0000000..4d4b01f --- /dev/null +++ b/.changeset/mcp-result-discrimination.md @@ -0,0 +1,13 @@ +--- +"@openrouter/agent": minor +"@openrouter/mcp": minor +--- + +Add a `source` discriminant to tool results so untyped MCP tools no longer collapse the type safety of typed tools. + +Previously, mixing an MCP tool (whose output schema is `unknown`) with fully-typed tools in one `callModel({ tools })` array collapsed the entire result union to `unknown` — one untyped tool poisoned every other tool's result type. + +- `ToolExecutionResult` (and `ToolExecutionResultUnion`) now carry `source: 'client' | 'mcp'`. Narrowing on `source === 'client'` recovers the precise, schema-derived results for your own tools; MCP results stay isolated as `unknown` under `source === 'mcp'`. +- `ToolResultEvent` (streaming: `getFullResponsesStream`, `getToolStream`) gains the same `source` field. **Breaking:** the `tool.result` event payload now includes `source`; consumers that constructed or exhaustively matched these events may need to account for it. +- `@openrouter/agent` exports a `markMcp()` helper, an `isMcpTool()` guard, and the `McpBranded` type. `@openrouter/mcp` brands every wrapped tool (including synthetic `list_resources`/`read_resource`) so the discrimination is automatic — callers just spread `mcp.tools` as before. +- MCP tools continue to execute locally and serialize to the wire as `type: 'function'`; the brand is purely informational and does not change runtime behavior. diff --git a/.changeset/openrouter-mcp-initial.md b/.changeset/openrouter-mcp-initial.md new file mode 100644 index 0000000..cf23a14 --- /dev/null +++ b/.changeset/openrouter-mcp-initial.md @@ -0,0 +1,10 @@ +--- +"@openrouter/mcp": minor +--- + +Add `@openrouter/mcp`: expose remote MCP server tools (Streamable HTTP / SSE) as `callModel` tools. + +- `createMCPTools()` connects to a non-stdio MCP server, authenticates once (bearer token, custom headers, or a pluggable `OAuthClientProvider`), and returns a handle whose `.tools` drop straight into `callModel({ tools })`. The same auth is reused for tool discovery and every tool call. +- Faithful runtime JSON-Schema → Zod v4 conversion (`convertMcpInputSchema`) so the model sees real parameters; tool output schemas are mapped too. +- Serializable, rehydratable cache (`serialize()` / `rehydrateMCPTools()` / pluggable `MCPCacheStore` + `InMemoryMCPCacheStore`) that skips re-listing and, opt-in, re-authentication. Credential caching is off by default. +- MCP feature support: 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 is provided). diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 4540e07..3694482 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -127,7 +127,7 @@ export { hasUnsupportedContent, } from './lib/stream-transformers.js'; // Tool creation helpers -export { serverTool, tool } from './lib/tool.js'; +export { markMcp, serverTool, tool } from './lib/tool.js'; export type { ContextInput } from './lib/tool-context.js'; // Tool context helpers export { buildToolExecuteContext, ToolContextStore } from './lib/tool-context.js'; @@ -147,6 +147,7 @@ export type { InferToolOutput, InferToolOutputsUnion, ManualTool, + McpBranded, NextTurnParamsContext, NextTurnParamsFunctions, ParsedToolCall, @@ -192,6 +193,7 @@ export { isGeneratorTool, isHITLTool, isManualTool, + isMcpTool, isRegularExecuteTool, isServerTool, isToolCallOutputEvent, diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 4ca5fc9..e9f2bff 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -75,6 +75,7 @@ import type { import { isAutoResolvableTool, isClientTool, + isMcpTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js'; @@ -200,6 +201,7 @@ export class ModelResult< | { type: 'tool_result'; toolCallId: string; + source: 'client' | 'mcp'; result: InferToolOutputsUnion; preliminaryResults?: InferToolEventsUnion[]; } @@ -368,18 +370,30 @@ export class ModelResult< return completedResponse; } + /** + * Resolve a tool's result `source` from its call name by looking it up in the + * configured tools. Used where the concrete tool reference isn't in scope + * (e.g. a rejected execution). Defaults to `'client'` when not found. + */ + private toolSourceByName(name: string): 'client' | 'mcp' { + const matched = this.options.tools?.find((t) => isClientTool(t) && t.function.name === name); + return matched !== undefined && isMcpTool(matched) ? 'mcp' : 'client'; + } + /** * Push a tool result event to both the legacy tool event broadcaster * and the unified turn broadcaster. */ private broadcastToolResult( toolCallId: string, + source: 'client' | 'mcp', result: InferToolOutputsUnion, preliminaryResults?: InferToolEventsUnion[], ): void { this.toolEventBroadcaster?.push({ type: 'tool_result' as const, toolCallId, + source, result, ...(preliminaryResults?.length && { preliminaryResults, @@ -388,6 +402,7 @@ export class ModelResult< this.turnBroadcaster?.push({ type: 'tool.result' as const, toolCallId, + source, result, timestamp: Date.now(), ...(preliminaryResults?.length && { @@ -607,11 +622,21 @@ export class ModelResult< // `toolResults` is client-tool-centric; server-tool output items are // surfaced on `serverToolResults` so stop conditions can react to // either class of result. - toolResults: round.toolResults.filter(isFunctionCallOutput).map((tr) => ({ - toolCallId: tr.callId, - toolName: round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '', - result: typeof tr.output === 'string' ? JSON.parse(tr.output) : tr.output, - })), + toolResults: round.toolResults.filter(isFunctionCallOutput).map((tr) => { + const toolName = round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? ''; + const matchedTool = this.options.tools?.find( + (t) => isClientTool(t) && t.function.name === toolName, + ); + return { + toolCallId: tr.callId, + toolName, + source: + matchedTool !== undefined && isMcpTool(matchedTool) + ? ('mcp' as const) + : ('client' as const), + result: typeof tr.output === 'string' ? JSON.parse(tr.output) : tr.output, + }; + }), serverToolResults: round.toolResults.filter(isServerToolResult), response: round.response, usage: round.response.usage, @@ -893,7 +918,7 @@ export class ModelResult< `Raw arguments received: "${rawArgs}". ` + 'Please provide valid JSON arguments for this tool call.'; - this.broadcastToolResult(toolCall.id, { + this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', { error: errorMessage, } as InferToolOutputsUnion); @@ -963,9 +988,13 @@ export class ModelResult< const errorMessage = settled.reason instanceof Error ? settled.reason.message : String(settled.reason); - this.broadcastToolResult(originalToolCall.id, { - error: errorMessage, - } as InferToolOutputsUnion); + this.broadcastToolResult( + originalToolCall.id, + this.toolSourceByName(originalToolCall.name), + { + error: errorMessage, + } as InferToolOutputsUnion, + ); const rejectedOutput: models.FunctionCallOutputItem = { type: 'function_call_output' as const, @@ -1017,6 +1046,7 @@ export class ModelResult< ) as InferToolOutputsUnion; this.broadcastToolResult( value.toolCall.id, + isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined, ); @@ -2131,7 +2161,7 @@ export class ModelResult< getFullResponsesStream(): AsyncIterableIterator< ResponseStreamEvent, InferToolOutputsUnion> > { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { @@ -2164,7 +2194,7 @@ export class ModelResult< * including text from follow-up responses in multi-turn tool loops. */ getTextStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { @@ -2233,7 +2263,7 @@ export class ModelResult< return false; }; - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { @@ -2402,7 +2432,7 @@ export class ModelResult< getNewMessagesStream(): AsyncIterableIterator< models.OutputMessage | models.FunctionCallOutputItem | models.OutputFunctionCallItem > { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { @@ -2470,7 +2500,7 @@ export class ModelResult< * including reasoning from follow-up responses in multi-turn tool loops. */ getReasoningStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { @@ -2503,7 +2533,7 @@ export class ModelResult< * - Preliminary results as { type: "preliminary_result", toolCallId, result } */ getToolStream(): AsyncIterableIterator>> { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { @@ -2584,7 +2614,7 @@ export class ModelResult< * Each iteration yields a complete tool call with parsed arguments. */ getToolCallsStream(): AsyncIterableIterator> { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream && !this.finalResponse) { diff --git a/packages/agent/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 572fa0d..05a3269 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -19,6 +19,7 @@ import { hasExecuteFunction, isGeneratorTool, isHITLTool, + isMcpTool, isRegularExecuteTool, isServerTool, } from './tool-types.js'; @@ -208,6 +209,8 @@ export async function executeRegularTool( ); } + const source = isMcpTool(tool) ? 'mcp' : 'client'; + try { const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); @@ -222,6 +225,7 @@ export async function executeRegularTool( return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result: validatedOutput, }; } @@ -229,12 +233,14 @@ export async function executeRegularTool( return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result, }; } catch (error) { return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result: null, error: error instanceof Error ? error : new Error(String(error)), }; @@ -260,6 +266,8 @@ export async function executeGeneratorTool( throw new Error(`Tool "${toolCall.name}" is not a generator tool`); } + const source = isMcpTool(tool) ? 'mcp' : 'client'; + try { const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); @@ -312,6 +320,7 @@ export async function executeGeneratorTool( return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result: finalResult, preliminaryResults, }; @@ -319,6 +328,7 @@ export async function executeGeneratorTool( return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result: null, error: error instanceof Error ? error : new Error(String(error)), }; @@ -345,6 +355,8 @@ export async function executeHITLTool( throw new Error(`Tool "${toolCall.name}" is not a HITL tool`); } + const source = isMcpTool(tool) ? 'mcp' : 'client'; + try { const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); @@ -363,12 +375,14 @@ export async function executeHITLTool( return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result: validatedOutput, }; } catch (error) { return { toolCallId: toolCall.id, toolName: toolCall.name, + source, result: null, error: error instanceof Error ? error : new Error(String(error)), }; diff --git a/packages/agent/src/lib/tool-orchestrator.ts b/packages/agent/src/lib/tool-orchestrator.ts index c27d3e2..287a55c 100644 --- a/packages/agent/src/lib/tool-orchestrator.ts +++ b/packages/agent/src/lib/tool-orchestrator.ts @@ -7,7 +7,7 @@ import { extractToolCallsFromResponse, responseHasToolCalls } from './stream-tra import { isFunctionCallItem } from './stream-type-guards.js'; import { executeTool, findToolByName } from './tool-executor.js'; import type { APITool, Tool, ToolExecutionResult } from './tool-types.js'; -import { isAutoResolvableTool } from './tool-types.js'; +import { isAutoResolvableTool, isMcpTool } from './tool-types.js'; import { buildTurnContext } from './turn-context.js'; /** @@ -95,6 +95,7 @@ export async function executeToolLoop( return { toolCallId: toolCall.id, toolName: toolCall.name, + source: 'client', result: null, error: new Error(`Tool "${toolCall.name}" not found in tool definitions`), } as ToolExecutionResult; @@ -153,9 +154,11 @@ export async function executeToolLoop( } } else { // Promise rejected - create error result + const rejectedTool = findToolByName(tools, toolCall.name); roundResults.push({ toolCallId: toolCall.id, toolName: toolCall.name, + source: rejectedTool !== undefined && isMcpTool(rejectedTool) ? 'mcp' : 'client', result: null, error: settled.reason instanceof Error ? settled.reason : new Error(String(settled.reason)), diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index e768358..8e89016 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1,7 +1,6 @@ import type * as models from '@openrouter/sdk/models'; import type { StreamEvents } from '@openrouter/sdk/models'; import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; -import type { ModelResult } from './model-result.js'; /** * Tool type enum for enhanced tools @@ -565,6 +564,33 @@ export function isServerTool(tool: Tool): tool is ServerTool { return tool._brand === 'server-tool'; } +/** + * A client tool additionally branded as originating from an MCP server. The + * `_mcp` marker is purely informational: it does NOT change how the tool is + * executed (MCP tools keep their local `execute` fn and run through the normal + * client-tool path) or serialized (they go on the wire as `type: 'function'`). + * It exists only so result types can discriminate MCP results — whose output + * schema is `unknown` at compile time — from precisely-typed client tools, + * preventing a single MCP tool from collapsing the whole result union. + */ +export type McpBranded = T & { + readonly _mcp: true; +}; + +/** + * Type guard: true if the tool carries the additive MCP brand (see + * {@link McpBranded}). Structural check on `_mcp`, so no cast is needed. + */ +export function isMcpTool(tool: Tool): tool is McpBranded { + if (typeof tool !== 'object' || tool === null) { + return false; + } + if (!('_mcp' in tool)) { + return false; + } + return tool._mcp === true; +} + /** * Type guard: true if the tool is a client-executed tool (function, generator, or manual). */ @@ -647,23 +673,71 @@ export interface ParsedToolCall { } /** - * Result of tool execution + * Result of tool execution. + * + * The `_mcp` brand is tested BEFORE the execute/generator branch: an MCP tool + * is structurally also a `ToolWithExecute`, so checking the brand last would let + * its `unknown` output flow through and collapse the result union. Brand-first + * isolates MCP results as `unknown` under `source: 'mcp'` while every other tool + * keeps its precise, schema-derived result under `source: 'client'`. Consumers + * narrow on `source` (narrowing on the `toolName` literal alone does not exclude + * the MCP branch, whose `toolName` is `string`). + * * @template T - The tool type to infer result types from */ export interface ToolExecutionResult { toolCallId: string; - toolName: string; - result: T extends - | ToolWithExecute<$ZodObject<$ZodShape>, infer O> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> - ? zodInfer - : unknown; // Final result (sent to model) + toolName: T extends { + function: { + name: infer N extends string; + }; + } + ? N + : string; + source: ToolSource; + result: T extends { + readonly _mcp: true; + } + ? unknown + : [ + Tool, + ] extends [ + T, + ] + ? unknown // wide `Tool`: result not statically known + : T extends + | ToolWithExecute<$ZodObject<$ZodShape>, infer O> + | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> + ? zodInfer + : unknown; // Final result (sent to model) preliminaryResults?: T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> ? zodInfer[] : undefined; // All yielded values from generator error?: Error; } +/** + * The `source` discriminant for a tool result. A specific MCP-branded tool is + * `'mcp'`; a specific client tool is `'client'`; the wide `Tool` (used by the + * internal executor before the concrete tool type is known) is the full union, + * so a runtime-computed `'client' | 'mcp'` assigns into it. + */ +export type ToolSource = [ + Tool, +] extends [ + T, +] + ? 'client' | 'mcp' // wide `Tool`: source not statically known + : [ + T, + ] extends [ + { + readonly _mcp: true; + }, + ] + ? 'mcp' + : 'client'; + /** * Warning from step execution */ @@ -727,21 +801,6 @@ export type StopWhen = | StopCondition | ReadonlyArray>; -/** - * Result of executeTools operation - */ -export interface ExecuteToolsResult { - finalResponse: ModelResult; - allResponses: ModelResult[]; - toolResults: Map< - string, - { - result: unknown; - preliminaryResults?: unknown[]; - } - >; -} - /** * Standard tool format for OpenRouter API (JSON Schema based) * Matches ResponsesRequestToolFunction structure @@ -776,6 +835,13 @@ export type ToolPreliminaryResultEvent = { export type ToolResultEvent = { type: 'tool.result'; toolCallId: string; + /** + * Origin of the tool: `'mcp'` for tools wrapped from a remote MCP server + * (whose `result` is `unknown`), `'client'` for locally-defined tools. Lets + * consumers discriminate so an MCP result's `unknown` doesn't force callers to + * treat every tool result as untyped. + */ + source: 'client' | 'mcp'; result: TResult; timestamp: number; preliminaryResults?: TPreliminaryResults[]; diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 2d09bae..6a43ece 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -2,6 +2,7 @@ import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/ import type { HITLTool, ManualTool, + McpBranded, NextTurnParamsFunctions, ServerTool, ServerToolConfig, @@ -480,4 +481,18 @@ export function serverTool( }; } +/** + * Add the additive MCP brand to an already-built client tool (see + * {@link McpBranded}). Non-mutating: returns a shallow copy carrying `_mcp`, so + * the tool's runtime behavior and wire shape are unchanged — only its type (and + * the runtime {@link isMcpTool} check) now identify it as MCP-originated. Used + * by `@openrouter/mcp` to mark wrapped remote tools. + */ +export function markMcp(toolToMark: T): McpBranded { + return { + ...toolToMark, + _mcp: true as const, + }; +} + //#endregion diff --git a/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts b/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts new file mode 100644 index 0000000..62c185d --- /dev/null +++ b/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts @@ -0,0 +1,112 @@ +/** + * Type-level tests proving that mixing an MCP-branded tool (whose output is + * `unknown`) with fully-typed client tools does NOT collapse the result types + * to `unknown`. The `source` discriminant isolates the MCP branch so typed + * tools keep their precise, schema-derived results. + * + * Assertions go through `ToolExecutionResultUnion`, NOT `InferToolOutput`: the + * `tool()` factory widens a tool value's standalone output type, but + * `ToolExecutionResult['result']` infers precisely from + * `ToolWithExecute<…, infer O>`. Using real factory values is the point — it + * mirrors what callers (and `wrapMcpTool`) actually build. + * + * Note on `toolName`: the `tool()` factory also widens the `name` literal to + * `string` (the same widening documented in has-approval-tools.test-d.ts), so + * discrimination is by `source`, not by `toolName`. That is exactly why the + * discriminant added to the result types is `source`. + */ + +import { expectTypeOf } from 'vitest'; +import * as z from 'zod'; +import { markMcp, tool } from '../../src/lib/tool.js'; +import type { ToolExecutionResult, ToolExecutionResultUnion } from '../../src/lib/tool-types.js'; + +const weather = tool({ + name: 'weather', + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + tempC: z.number(), + }), + execute: async () => ({ + tempC: 20, + }), +}); + +const db = tool({ + name: 'db', + inputSchema: z.object({ + q: z.string(), + }), + outputSchema: z.object({ + rows: z.number(), + }), + execute: async () => ({ + rows: 3, + }), +}); + +// An MCP-wrapped tool: unknown output schema, then marked with the MCP brand — +// exactly what `wrapMcpTool` produces. +const mcpTool = markMcp( + tool({ + name: 'mcp_search', + inputSchema: z.object({}).catchall(z.unknown()), + outputSchema: z.unknown(), + execute: async () => ({}) as unknown, + }), +); + +type Weather = typeof weather; +type Db = typeof db; +type Mcp = typeof mcpTool; + +// --- Baseline: a typed tool's result is precise, source is 'client' ---------- +expectTypeOf['result']>().toEqualTypeOf<{ + tempC: number; +}>(); +expectTypeOf['source']>().toEqualTypeOf<'client'>(); + +// --- The MCP-branded tool is isolated ---------------------------------------- +expectTypeOf['source']>().toEqualTypeOf<'mcp'>(); +expectTypeOf['result']>().toEqualTypeOf(); + +// --- Mixed tuple: narrowing by `source` keeps client results precise --------- +type MixedResults = ToolExecutionResultUnion< + readonly [ + Weather, + Db, + Mcp, + ] +>; + +// The client branch is the union of the typed tools' precise results — NOT +// `unknown`. If the MCP `unknown` had leaked across the union this would be +// `unknown` (or the Extract would be `never`). +type ClientResults = Extract< + MixedResults, + { + source: 'client'; + } +>; +expectTypeOf().toEqualTypeOf< + | { + tempC: number; + } + | { + rows: number; + } +>(); + +// The MCP branch stays opaque, on its own discriminated arm. +type McpResults = Extract< + MixedResults, + { + source: 'mcp'; + } +>; +expectTypeOf().toEqualTypeOf(); + +// Sanity: the client branch is actually inhabited (Extract did not collapse it). +expectTypeOf().not.toEqualTypeOf(); diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..5a45400 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,127 @@ +# @openrouter/mcp + +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`. + +- Connect to a non-stdio MCP server, authenticate **once**, and reuse that auth for tool + discovery and every tool call. +- 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. + +> stdio servers are intentionally out of scope. + +## Install + +```bash +pnpm add @openrouter/mcp @openrouter/agent +``` + +## Quick start + +```ts +import { OpenRouter } from '@openrouter/agent'; +import { callModel } from '@openrouter/agent/call-model'; +import { createMCPTools } from '@openrouter/mcp'; + +const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); + +const mcp = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + auth: { kind: 'bearer', token: process.env.MCP_TOKEN }, +}); + +const result = callModel(client, { + model: 'anthropic/claude-opus-4-8', + input: 'What are my three most recently updated issues?', + tools: mcp.tools, +}); + +console.log(await result.getText()); +await mcp.close(); +``` + +## Authentication + +Auth is supplied once and reused for discovery and every call: + +```ts +// Static bearer token +auth: { kind: 'bearer', token } +// Arbitrary headers +auth: { kind: 'headers', headers: { 'X-API-Key': key } } +// Pluggable OAuth (you own token refresh/storage) +auth: { kind: 'oauth', provider } +``` + +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'; + +const mcp = await createMCPTools({ url, auth, cacheCredentials: true }); +const snapshot = await mcp.serialize(); // plain JSON — store anywhere +await mcp.close(); + +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'; + +const store = new InMemoryMCPCacheStore(); // or your own Redis/DB-backed MCPCacheStore +const mcp = await createMCPTools({ + url, + auth, + cache: { store, key: `mcp:${userId}` }, + staleness: { maxAgeMs: 60 * 60 * 1000 }, +}); +``` + +> **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. + +## Multiple servers + +```ts +const [github, linear] = await Promise.all([ + createMCPTools({ url: githubUrl, auth: gh, toolNamePrefix: 'github_' }), + createMCPTools({ url: linearUrl, auth: ln, toolNamePrefix: 'linear_' }), +]); + +const result = callModel(client, { + model, + input: 'Find the Linear issue linked to GitHub PR #42.', + tools: [...github.tools, ...linear.tools], +}); +``` + +## Options + +| Option | Description | +| --- | --- | +| `url` | Remote MCP server endpoint. | +| `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. | +| `onUnconvertibleSchema` | `'looseLeaf'` (default) or `'throw'` for exotic JSON Schema. | +| `cache` / `cacheCredentials` / `staleness` | Caching controls. | +| `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 server elicitation requests; auto-declines when omitted. | +| `signal` | Abort signal threaded into every tool call. | + +## License + +Apache-2.0 diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..d276fba --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,72 @@ +{ + "name": "@openrouter/mcp", + "version": "0.1.0", + "author": "OpenRouter", + "description": "Expose remote MCP server tools (Streamable HTTP / SSE) as tools for @openrouter/agent's callModel, with serializable caching and pluggable auth.", + "keywords": [ + "openrouter", + "mcp", + "model-context-protocol", + "agent", + "typescript", + "ai", + "tools", + "llm" + ], + "license": "Apache-2.0", + "type": "module", + "main": "./esm/index.js", + "exports": { + ".": { + "types": "./esm/index.d.ts", + "default": "./esm/index.js" + }, + "./create-mcp-tools": { + "types": "./esm/create-mcp-tools.d.ts", + "default": "./esm/create-mcp-tools.js" + }, + "./types": { + "types": "./esm/types.d.ts", + "default": "./esm/types.js" + }, + "./schema": { + "types": "./esm/schema/json-schema-to-zod.d.ts", + "default": "./esm/schema/json-schema-to-zod.js" + }, + "./cache": { + "types": "./esm/cache/cache-store.d.ts", + "default": "./esm/cache/cache-store.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/OpenRouterTeam/typescript-agent.git", + "directory": "packages/mcp" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "files": [ + "esm", + "package.json", + "README.md" + ], + "scripts": { + "lint": "biome check src tests", + "lint:fix": "biome check --write src tests", + "build": "tsc", + "test": "vitest --run --project unit", + "test:e2e": "vitest --run --project e2e", + "test:watch": "vitest --watch --project unit", + "typecheck": "tsc --noEmit", + "compile": "tsc" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@openrouter/agent": "workspace:*", + "zod": "^4.0.0" + } +} diff --git a/packages/mcp/src/auth/auth-resolver.ts b/packages/mcp/src/auth/auth-resolver.ts new file mode 100644 index 0000000..d824465 --- /dev/null +++ b/packages/mcp/src/auth/auth-resolver.ts @@ -0,0 +1,40 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { MCPAuth } from './auth-types.js'; + +/** + * The transport-level pieces derived from an {@link MCPAuth}: static headers to + * merge into `requestInit`, and/or an OAuth provider to hand to the transport. + * Both flow into the same connected client, so discovery and tool calls share + * one authenticated session. + */ +export interface ResolvedAuth { + headers: Record; + authProvider?: OAuthClientProvider; +} + +export function resolveAuth(auth: MCPAuth | undefined): ResolvedAuth { + if (auth === undefined) { + return { + headers: {}, + }; + } + switch (auth.kind) { + case 'bearer': + return { + headers: { + Authorization: `Bearer ${auth.token}`, + }, + }; + case 'headers': + return { + headers: { + ...auth.headers, + }, + }; + case 'oauth': + return { + headers: {}, + authProvider: auth.provider, + }; + } +} diff --git a/packages/mcp/src/auth/auth-types.ts b/packages/mcp/src/auth/auth-types.ts new file mode 100644 index 0000000..75647bf --- /dev/null +++ b/packages/mcp/src/auth/auth-types.ts @@ -0,0 +1,31 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; + +/** + * Authentication for a remote MCP server. Supplied once and reused by the + * connected client for tool discovery and every subsequent tool call. + * + * - `bearer`: a static bearer token sent as `Authorization: Bearer `. + * - `headers`: arbitrary static headers (e.g. API keys, custom auth schemes). + * - `oauth`: a user-supplied {@link OAuthClientProvider} that owns token + * acquisition/refresh. Preferred over caching static tokens. + */ +export type MCPAuth = + | { + kind: 'bearer'; + token: string; + } + | { + kind: 'headers'; + headers: Readonly>; + } + | { + kind: 'oauth'; + provider: OAuthClientProvider; + }; + +export function isOAuthAuth(auth: MCPAuth | undefined): auth is { + kind: 'oauth'; + provider: OAuthClientProvider; +} { + return auth?.kind === 'oauth'; +} diff --git a/packages/mcp/src/build-tools.ts b/packages/mcp/src/build-tools.ts new file mode 100644 index 0000000..5104ca5 --- /dev/null +++ b/packages/mcp/src/build-tools.ts @@ -0,0 +1,112 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { Tool } from '@openrouter/agent/tool-types'; +import { MCPError } from './errors.js'; +import { buildResourceTools } from './resource-tools.js'; +import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; +import type { McpToolDef } from './tool-wrapper.js'; +import { wrapMcpTool } from './tool-wrapper.js'; +import type { ResourcesOption } from './types.js'; + +export interface BuildToolsOptions { + client: Client; + toolDefs: readonly McpToolDef[]; + namePrefix?: string; + includeTools?: readonly string[]; + excludeTools?: readonly string[]; + schemaMode?: UnconvertibleSchemaMode; + emitProgress?: boolean; + signal?: AbortSignal; + resources?: ResourcesOption; + /** Whether the server advertised the resources capability. */ + serverHasResources: boolean; +} + +function resourcesEnabled(option: ResourcesOption | undefined): boolean { + if (option === undefined) { + return true; + } + return option === true || (typeof option === 'object' && option !== null); +} + +/** Apply allow/deny filters to discovered MCP tool definitions. */ +export function filterToolDefs( + defs: readonly McpToolDef[], + includeTools?: readonly string[], + excludeTools?: readonly string[], +): McpToolDef[] { + const include = includeTools !== undefined ? new Set(includeTools) : undefined; + const exclude = new Set(excludeTools ?? []); + return defs.filter((def) => { + if (include !== undefined && !include.has(def.name)) { + return false; + } + return !exclude.has(def.name); + }); +} + +/** + * Build the full ordered tool list for a handle: filtered + wrapped MCP tools, + * plus synthetic resource tools when enabled. Throws on a name collision so two + * tools never silently shadow each other in `callModel`. + */ +export function buildTools(options: BuildToolsOptions): Tool[] { + const filtered = filterToolDefs(options.toolDefs, options.includeTools, options.excludeTools); + + const wrapOptions = { + client: options.client, + ...(options.namePrefix !== undefined && { + namePrefix: options.namePrefix, + }), + ...(options.schemaMode !== undefined && { + schemaMode: options.schemaMode, + }), + ...(options.emitProgress !== undefined && { + emitProgress: options.emitProgress, + }), + ...(options.signal !== undefined && { + signal: options.signal, + }), + }; + + const tools: Tool[] = filtered.map((def) => wrapMcpTool(def, wrapOptions)); + + if (options.serverHasResources && resourcesEnabled(options.resources)) { + tools.push( + ...buildResourceTools({ + client: options.client, + ...(options.namePrefix !== undefined && { + namePrefix: options.namePrefix, + }), + ...(options.signal !== undefined && { + signal: options.signal, + }), + }), + ); + } + + assertNoDuplicateNames(tools); + return tools; +} + +function toolName(tool: Tool): string | undefined { + if ('function' in tool && typeof tool.function.name === 'string') { + return tool.function.name; + } + return undefined; +} + +function assertNoDuplicateNames(tools: readonly Tool[]): void { + const seen = new Set(); + for (const tool of tools) { + const name = toolName(tool); + if (name === undefined) { + continue; + } + if (seen.has(name)) { + throw new MCPError( + `Duplicate tool name "${name}". Use toolNamePrefix or excludeTools to disambiguate.`, + ); + } + seen.add(name); + } +} diff --git a/packages/mcp/src/cache/cache-store.ts b/packages/mcp/src/cache/cache-store.ts new file mode 100644 index 0000000..ab428b4 --- /dev/null +++ b/packages/mcp/src/cache/cache-store.ts @@ -0,0 +1,37 @@ +import type { SerializedMCPServer } from './cache-types.js'; + +/** + * Pluggable cache for MCP server snapshots. Implement this to back the cache + * with Redis, a database, or the filesystem. All methods may be async. + * + * SECURITY: when `cacheCredentials` is enabled, stored snapshots contain bearer + * tokens/headers — treat the store as a secret store (encrypt at rest, scope + * access) and namespace keys by principal in multi-tenant setups. + */ +export interface MCPCacheStore { + get(key: string): Promise | SerializedMCPServer | null; + set(key: string, value: SerializedMCPServer): Promise | void; + delete?(key: string): Promise | void; +} + +/** Default in-process cache backed by a `Map`. Not shared across processes. */ +export class InMemoryMCPCacheStore implements MCPCacheStore { + private readonly entries = new Map(); + + get(key: string): SerializedMCPServer | null { + return this.entries.get(key) ?? null; + } + + set(key: string, value: SerializedMCPServer): void { + this.entries.set(key, value); + } + + delete(key: string): void { + this.entries.delete(key); + } +} + +/** Default cache key for a server URL. Override via `cache.key` for multi-tenant. */ +export function defaultCacheKey(url: string): string { + return `openrouter-mcp:${url}`; +} diff --git a/packages/mcp/src/cache/cache-types.ts b/packages/mcp/src/cache/cache-types.ts new file mode 100644 index 0000000..98858b1 --- /dev/null +++ b/packages/mcp/src/cache/cache-types.ts @@ -0,0 +1,88 @@ +import { isJsonSchemaObject } from '../schema/json-schema-guards.js'; +import type { MCPTransportKind } from '../transport-types.js'; + +/** A discovered tool definition as stored in a cache snapshot. */ +export interface SerializedMCPToolDef { + name: string; + description?: string; + inputSchema: Readonly>; + outputSchema?: Readonly>; +} + +/** OAuth/bearer token material, persisted only when `cacheCredentials` is on. */ +export interface SerializedTokenSet { + accessToken: string; + tokenType?: string; + refreshToken?: string; + /** Epoch ms; absence means unknown/no declared expiry. */ + expiresAt?: number; + scope?: string; +} + +/** + * Serializable snapshot of a connected MCP server: enough to rebuild the tool + * set (and, opt-in, reconnect with cached credentials) without re-listing or + * re-authenticating. + */ +export interface SerializedMCPServer { + version: 1; + url: string; + transport: MCPTransportKind; + serverInfo?: { + name?: string; + version?: string; + }; + capabilities?: Readonly>; + sessionId?: string; + tools: SerializedMCPToolDef[]; + auth?: { + headers?: Readonly>; + tokens?: SerializedTokenSet; + }; + cachedAt: number; +} + +function isTransportKind(value: unknown): value is MCPTransportKind { + return value === 'streamableHttp' || value === 'sse'; +} + +/** + * The shared "valid `cachedAt`" rule: a finite, non-negative epoch (ms). + * Consumed by both the read-side snapshot validator and the write-side + * serializer so the two can't drift. Rejecting negatives keeps the invariant + * honest at the boundary rather than relying on downstream maxAge arithmetic to + * fail-safe on a clock-skewed or garbage value. + */ +export function isFiniteEpoch(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSerializedToolDef(value: unknown): value is SerializedMCPToolDef { + return ( + isJsonSchemaObject(value) && + typeof value['name'] === 'string' && + isJsonSchemaObject(value['inputSchema']) + ); +} + +/** + * Validate an untrusted value as a {@link SerializedMCPServer}. Cache stores may + * be backed by a DB or another org member's data, so snapshots are checked + * structurally before use — never trusted by shape alone. + */ +export function isSerializedMCPServer(value: unknown): value is SerializedMCPServer { + if (!isJsonSchemaObject(value)) { + return false; + } + if (value['version'] !== 1) { + return false; + } + if (typeof value['url'] !== 'string' || !isTransportKind(value['transport'])) { + return false; + } + if (!isFiniteEpoch(value['cachedAt'])) { + return false; + } + const { tools } = value; + return Array.isArray(tools) && tools.every(isSerializedToolDef); +} diff --git a/packages/mcp/src/cache/serialize.ts b/packages/mcp/src/cache/serialize.ts new file mode 100644 index 0000000..7e70f14 --- /dev/null +++ b/packages/mcp/src/cache/serialize.ts @@ -0,0 +1,112 @@ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import { resolveAuth } from '../auth/auth-resolver.js'; +import type { MCPAuth } from '../auth/auth-types.js'; +import type { McpToolDef } from '../tool-wrapper.js'; +import type { MCPTransportKind } from '../types.js'; +import type { SerializedMCPServer, SerializedTokenSet } from './cache-types.js'; +import { isFiniteEpoch } from './cache-types.js'; + +export interface SerializeInput { + url: string; + transport: MCPTransportKind; + toolDefs: readonly McpToolDef[]; + serverInfo?: { + name?: string; + version?: string; + }; + capabilities?: Readonly>; + sessionId?: string; + auth?: MCPAuth; + cacheCredentials: boolean; + cachedAt: number; +} + +/** Pull a serializable token set from an OAuth provider, if it has tokens. */ +async function tokensFromProvider( + provider: OAuthClientProvider, +): Promise { + const tokens = await provider.tokens(); + if (tokens === undefined) { + return undefined; + } + const expiresInMs = typeof tokens.expires_in === 'number' ? tokens.expires_in * 1000 : undefined; + return { + accessToken: tokens.access_token, + ...(typeof tokens.token_type === 'string' && { + tokenType: tokens.token_type, + }), + ...(typeof tokens.refresh_token === 'string' && { + refreshToken: tokens.refresh_token, + }), + ...(typeof tokens.scope === 'string' && { + scope: tokens.scope, + }), + ...(expiresInMs !== undefined && { + expiresAt: Date.now() + expiresInMs, + }), + }; +} + +async function buildAuthBlock(auth: MCPAuth | undefined): Promise { + if (auth === undefined) { + return undefined; + } + if (auth.kind === 'oauth') { + const tokens = await tokensFromProvider(auth.provider); + return tokens !== undefined + ? { + tokens, + } + : undefined; + } + const { headers } = resolveAuth(auth); + return Object.keys(headers).length > 0 + ? { + headers, + } + : undefined; +} + +/** + * Build a serializable snapshot. Credentials (tokens/headers, session id) are + * included only when `cacheCredentials` is true; otherwise the snapshot holds + * just the structural data needed to rebuild the tool set after a fresh auth. + */ +export async function serializeServer(input: SerializeInput): Promise { + const tools = input.toolDefs.map((def) => ({ + name: def.name, + ...(def.description !== undefined && { + description: def.description, + }), + inputSchema: def.inputSchema, + ...(def.outputSchema !== undefined && { + outputSchema: def.outputSchema, + }), + })); + + const snapshot: SerializedMCPServer = { + version: 1, + url: input.url, + transport: input.transport, + ...(input.serverInfo !== undefined && { + serverInfo: input.serverInfo, + }), + ...(input.capabilities !== undefined && { + capabilities: input.capabilities, + }), + tools, + cachedAt: isFiniteEpoch(input.cachedAt) ? input.cachedAt : Date.now(), + }; + + if (input.cacheCredentials) { + const auth = await buildAuthBlock(input.auth); + if (auth !== undefined) { + snapshot.auth = auth; + } + if (input.sessionId !== undefined) { + snapshot.sessionId = input.sessionId; + } + } + + return snapshot; +} diff --git a/packages/mcp/src/create-mcp-tools.ts b/packages/mcp/src/create-mcp-tools.ts new file mode 100644 index 0000000..062e6e9 --- /dev/null +++ b/packages/mcp/src/create-mcp-tools.ts @@ -0,0 +1,92 @@ +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, +// and credential-caching behavior as a cold one. +const FORWARDED_REHYDRATE_KEYS = [ + 'auth', + 'fetch', + 'clientInfo', + 'onUnconvertibleSchema', + 'onElicitation', + 'signal', + 'toolNamePrefix', + 'includeTools', + 'excludeTools', + 'resources', + 'emitProgress', + 'autoRefreshOnListChanged', + 'cacheCredentials', +] 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 { + const snapshot = await store.get(cacheKey); + 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/mcp/src/elicitation.ts new file mode 100644 index 0000000..9ee8545 --- /dev/null +++ b/packages/mcp/src/elicitation.ts @@ -0,0 +1,67 @@ +import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/sdk/types.js'; +import { isJsonSchemaObject } from './schema/json-schema-guards.js'; +import type { ElicitationHandler } from './types.js'; + +/** Primitive value types the MCP elicitation result `content` map permits. */ +type ElicitContentValue = string | number | boolean | string[]; + +/** A form-mode elicitation request carries a `requestedSchema`. */ +function hasRequestedSchema(params: ElicitRequest['params']): params is ElicitRequest['params'] & { + requestedSchema: unknown; +} { + return 'requestedSchema' in params; +} + +/** Narrow handler-returned content to the primitive record the spec allows. */ +function toElicitContent(content: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(content)) { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + (Array.isArray(value) && value.every((v): v is string => typeof v === 'string')) + ) { + out[key] = value; + } + } + return out; +} + +/** + * Build the `elicitation/create` request handler registered on the MCP client. + * Delegates to the caller's {@link ElicitationHandler}; when none is supplied, + * auto-declines so a tool call awaiting input fails gracefully instead of + * hanging. The MCP spec forbids eliciting sensitive/PII data — that contract is + * the server's responsibility; callers should treat requested fields as such. + */ +export function makeElicitationRequestHandler( + handler: ElicitationHandler | undefined, +): (request: ElicitRequest) => Promise { + return async (request: ElicitRequest): Promise => { + if (handler === undefined) { + return { + action: 'decline', + }; + } + + const { params } = request; + const requestedSchema = hasRequestedSchema(params) ? params.requestedSchema : undefined; + const schema = isJsonSchemaObject(requestedSchema) ? requestedSchema : {}; + + const response = await handler({ + message: params.message, + requestedSchema: schema, + }); + + if (response.action === 'accept') { + return { + action: 'accept', + content: toElicitContent(response.content), + }; + } + return { + action: response.action, + }; + }; +} diff --git a/packages/mcp/src/errors.ts b/packages/mcp/src/errors.ts new file mode 100644 index 0000000..4f73354 --- /dev/null +++ b/packages/mcp/src/errors.ts @@ -0,0 +1,64 @@ +/** + * Base error for all @openrouter/mcp failures. + */ +export class MCPError extends Error { + constructor( + message: string, + options?: { + cause?: unknown; + }, + ) { + super(message, options); + this.name = 'MCPError'; + } +} + +/** + * Raised when an MCP tool call returns `isError: true` or when the result + * cannot be mapped to a usable model output. + */ +export class MCPToolCallError extends MCPError { + readonly toolName: string; + + constructor( + toolName: string, + message: string, + options?: { + cause?: unknown; + }, + ) { + super(message, options); + this.name = 'MCPToolCallError'; + this.toolName = toolName; + } +} + +/** + * Raised when a cached snapshot cannot be validated or rehydrated. + */ +export class MCPCacheError extends MCPError { + constructor( + message: string, + options?: { + cause?: unknown; + }, + ) { + super(message, options); + this.name = 'MCPCacheError'; + } +} + +/** + * Raised when connecting to the MCP server fails across all attempted transports. + */ +export class MCPConnectionError extends MCPError { + constructor( + message: string, + options?: { + cause?: unknown; + }, + ) { + super(message, options); + this.name = 'MCPConnectionError'; + } +} diff --git a/packages/mcp/src/handle.ts b/packages/mcp/src/handle.ts new file mode 100644 index 0000000..01db068 --- /dev/null +++ b/packages/mcp/src/handle.ts @@ -0,0 +1,287 @@ +import type { Tool } from '@openrouter/agent/tool-types'; +import type { BuildToolsOptions } from './build-tools.js'; +import { buildTools } from './build-tools.js'; +import type { SerializedMCPServer } from './cache/cache-types.js'; +import type { SerializeInput } from './cache/serialize.js'; +import { serializeServer } from './cache/serialize.js'; +import type { MCPConnection } from './mcp-connection.js'; +import { connect } from './mcp-connection.js'; +import type { McpToolDef } from './tool-wrapper.js'; +import type { MCPTransportKind } from './transport-types.js'; +import type { CreateMCPToolsOptions, MCPToolsHandle } from './types.js'; + +export function normalizeUrl(url: string | URL): URL { + return url instanceof URL ? url : new URL(url); +} + +// Hard cap on pagination pages. A well-behaved server terminates the cursor +// chain by omitting `nextCursor`; this bounds a misbehaving one that never does. +const MAX_LIST_PAGES = 1000; + +/** + * Normalize a paginated `nextCursor` field: treat anything that is not a + * non-empty string as "no more pages" so a malformed cursor terminates the loop. + */ +function nextCursorOrUndefined(value: string | undefined): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Read the discovered tools off the live connection into our internal shape. + * + * `tools/list` is paginated: each response may carry a `nextCursor` that must be + * passed back as `{ cursor }` to fetch the next page. We accumulate every page so + * servers that paginate their tool list aren't silently truncated. + */ +export async function listToolDefs( + connection: MCPConnection, + signal: AbortSignal | undefined, +): Promise { + const requestOptions = + signal !== undefined + ? { + signal, + } + : undefined; + const collected: McpToolDef[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_LIST_PAGES; page += 1) { + const params = + cursor !== undefined + ? { + cursor, + } + : undefined; + const { tools, nextCursor } = await connection.client.listTools(params, requestOptions); + for (const t of tools) { + collected.push({ + name: t.name, + ...(t.description !== undefined && { + description: t.description, + }), + inputSchema: t.inputSchema, + ...(t.outputSchema !== undefined && { + outputSchema: t.outputSchema, + }), + }); + } + const next = nextCursorOrUndefined(nextCursor); + // Stop at the end of the chain, or if the server echoes the same cursor + // (which would otherwise spin forever). + if (next === undefined || next === cursor) { + break; + } + cursor = next; + } + return collected; +} + +function serverHasResources(connection: MCPConnection): boolean { + const caps = connection.client.getServerCapabilities(); + return caps?.resources !== undefined; +} + +interface HandleContext { + url: URL; + transport: MCPTransportKind; + cacheKey: string; +} + +export interface MakeHandleArgs { + connection: MCPConnection; + options: CreateMCPToolsOptions; + context: HandleContext; + initialToolDefs: McpToolDef[]; +} + +/** + * Connect, discover tools via `listTools()`, and build a handle WITHOUT + * consulting the cache for a hit. The cache (when present on `options`) is still + * written through `makeHandle`, so refreshes persist. This is the cold cache-miss + * path, and is also called directly by `rehydrateMCPTools`'s fallback: routing + * the fallback here instead of back through `createMCPTools` is what prevents the + * cache-fallback loop — re-reading the same snapshot would re-enter rehydrate and + * recurse without bound. + */ +export async function freshConnect( + options: CreateMCPToolsOptions, + url: URL, + cacheKey: string, +): Promise { + const connection = await connect({ + url, + ...(options.transport !== undefined && { + transport: options.transport, + }), + ...(options.auth !== undefined && { + auth: options.auth, + }), + ...(options.fetch !== undefined && { + fetch: options.fetch, + }), + ...(options.clientInfo !== undefined && { + clientInfo: options.clientInfo, + }), + ...(options.onElicitation !== undefined && { + onElicitation: options.onElicitation, + }), + }); + + // Tear the connection down if discovery or the initial cache write throws — + // otherwise the open transport (HTTP keep-alive / SSE stream) leaks. + try { + const initialToolDefs = await listToolDefs(connection, options.signal); + return await makeHandle({ + connection, + options, + context: { + url, + transport: connection.transport, + cacheKey, + }, + initialToolDefs, + }); + } catch (err) { + await connection.close().catch(() => {}); + throw err; + } +} + +/** + * Construct an {@link MCPToolsHandle} around a live connection, wiring refresh, + * serialize, list_changed listeners, and cache writes. + */ +export async function makeHandle(args: MakeHandleArgs): Promise { + const { connection, options, context, initialToolDefs } = args; + const listeners = new Set<(tools: readonly Tool[]) => void>(); + let toolDefs = initialToolDefs; + const serverInfo = connection.client.getServerVersion(); + + const rebuild = (): Tool[] => buildTools(buildToolsArgs(connection, toolDefs, options)); + + let tools: readonly Tool[] = rebuild(); + + const snapshot = (): Promise => + serializeServer( + serializeArgs({ + connection, + context, + toolDefs, + serverInfo, + options, + }), + ); + + const writeCache = async (): Promise => { + const store = options.cache?.store; + if (store === undefined) { + return; + } + await store.set(context.cacheKey, await snapshot()); + }; + + const refresh = async (): Promise => { + toolDefs = await listToolDefs(connection, options.signal); + tools = rebuild(); + await writeCache(); + return tools; + }; + + if (options.autoRefreshOnListChanged ?? true) { + connection.setToolListChangedHandler(() => { + // Fire-and-forget, but never let a failed refresh escape as an unhandled + // rejection. On failure listeners keep the last good tool set. + void refresh() + .then((next) => { + for (const listener of listeners) { + listener(next); + } + }) + .catch(() => {}); + }); + } + + await writeCache(); + + return { + get tools() { + return tools; + }, + ...(serverInfo !== undefined && { + serverInfo, + }), + serialize: snapshot, + refresh, + onToolsChanged: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + close: () => connection.close(), + }; +} + +/** Assemble the {@link buildTools} arguments, threading only the defined options. */ +function buildToolsArgs( + connection: MCPConnection, + toolDefs: McpToolDef[], + options: CreateMCPToolsOptions, +): BuildToolsOptions { + return { + client: connection.client, + toolDefs, + emitProgress: options.emitProgress ?? true, + serverHasResources: serverHasResources(connection), + ...(options.toolNamePrefix !== undefined && { + namePrefix: options.toolNamePrefix, + }), + ...(options.includeTools !== undefined && { + includeTools: options.includeTools, + }), + ...(options.excludeTools !== undefined && { + excludeTools: options.excludeTools, + }), + ...(options.onUnconvertibleSchema !== undefined && { + schemaMode: options.onUnconvertibleSchema, + }), + ...(options.signal !== undefined && { + signal: options.signal, + }), + ...(options.resources !== undefined && { + resources: options.resources, + }), + }; +} + +interface SerializeArgsInput { + connection: MCPConnection; + context: HandleContext; + toolDefs: McpToolDef[]; + serverInfo: + | { + name?: string; + version?: string; + } + | undefined; + options: CreateMCPToolsOptions; +} + +/** Assemble the {@link serializeServer} input, threading only the defined fields. */ +function serializeArgs(args: SerializeArgsInput): SerializeInput { + const { connection, context, toolDefs, serverInfo, options } = args; + return { + url: context.url.href, + transport: connection.transport, + toolDefs, + cacheCredentials: options.cacheCredentials ?? false, + cachedAt: Date.now(), + ...(serverInfo !== undefined && { + serverInfo, + }), + ...(connection.sessionId !== undefined && { + sessionId: connection.sessionId, + }), + ...(options.auth !== undefined && { + auth: options.auth, + }), + }; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..1450001 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,35 @@ +// Main factory + rehydration + +// Auth +export type { MCPAuth } 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, + MCPConnectionError, + MCPError, + 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, + MCPToolsHandle, + MCPTransportKind, + ResourcesOption, +} from './types.js'; diff --git a/packages/mcp/src/mcp-connection.ts b/packages/mcp/src/mcp-connection.ts new file mode 100644 index 0000000..6810575 --- /dev/null +++ b/packages/mcp/src/mcp-connection.ts @@ -0,0 +1,207 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +// SSEClientTransport is deprecated upstream but intentionally supported here for +// legacy MCP servers that haven't migrated to Streamable HTTP. +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { + ElicitRequestSchema, + ToolListChangedNotificationSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { resolveAuth } from './auth/auth-resolver.js'; +import type { MCPAuth } from './auth/auth-types.js'; +import { makeElicitationRequestHandler } from './elicitation.js'; +import { MCPConnectionError } from './errors.js'; +import type { ElicitationHandler, MCPTransportKind } from './types.js'; + +const DEFAULT_CLIENT_INFO = { + name: '@openrouter/mcp', + version: '0.1.0', +}; + +export interface ConnectOptions { + url: URL; + transport?: MCPTransportKind; + auth?: MCPAuth; + fetch?: typeof fetch; + clientInfo?: { + name: string; + version: string; + }; + sessionId?: string; + onElicitation?: ElicitationHandler; +} + +export interface MCPConnection { + client: Client; + transport: MCPTransportKind; + sessionId?: string; + /** + * Register a callback for `tools/list_changed`. Settable after connect so the + * handle can wire it to its own `refresh()`. Replaces any prior handler. + */ + setToolListChangedHandler(handler: () => void): void; + close(): Promise; +} + +function buildStreamableHttp(options: ConnectOptions): StreamableHTTPClientTransport { + const { headers, authProvider } = resolveAuth(options.auth); + return new StreamableHTTPClientTransport(options.url, { + requestInit: { + headers, + }, + ...(authProvider !== undefined && { + authProvider, + }), + ...(options.fetch !== undefined && { + fetch: options.fetch, + }), + ...(options.sessionId !== undefined && { + sessionId: options.sessionId, + }), + }); +} + +function buildSse(options: ConnectOptions): SSEClientTransport { + const { headers, authProvider } = resolveAuth(options.auth); + return new SSEClientTransport(options.url, { + requestInit: { + headers, + }, + ...(authProvider !== undefined && { + authProvider, + }), + ...(options.fetch !== undefined && { + fetch: options.fetch, + }), + }); +} + +/** + * Runtime narrowing to `Transport`. The SDK's transport classes implement the + * interface, but their `.d.ts` types `sessionId` as `string | undefined` rather + * than `sessionId?: string`, which `exactOptionalPropertyTypes` rejects at the + * `connect()` call site. We confirm the structural contract at runtime instead + * of asserting past the variance with `as`. + */ +function isTransport(value: { start: unknown; send: unknown; close: unknown }): value is Transport { + return ( + typeof value.start === 'function' && + typeof value.send === 'function' && + typeof value.close === 'function' + ); +} + +interface MutableListChanged { + handler: (() => void) | undefined; +} + +function makeClient(options: ConnectOptions, listChanged: MutableListChanged): Client { + const client = new Client(options.clientInfo ?? DEFAULT_CLIENT_INFO, { + capabilities: { + elicitation: {}, + }, + }); + + client.setRequestHandler( + ElicitRequestSchema, + makeElicitationRequestHandler(options.onElicitation), + ); + + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + listChanged.handler?.(); + }); + + return client; +} + +async function connectWith( + client: Client, + transport: StreamableHTTPClientTransport | SSEClientTransport, +): Promise { + if (!isTransport(transport)) { + throw new MCPConnectionError('MCP transport does not implement the Transport contract'); + } + await client.connect(transport); +} + +/** + * Connect a `Client` to the MCP server. Defaults to Streamable HTTP and falls + * back to SSE on connection failure (legacy servers), unless a transport is + * pinned explicitly. Auth, the elicitation handler, and the list_changed + * subscription are wired into the single connected client so they apply to + * discovery and every tool call. + */ +export async function connect(options: ConnectOptions): Promise { + const preferred = options.transport ?? 'streamableHttp'; + const listChanged: MutableListChanged = { + handler: undefined, + }; + + if (preferred === 'sse') { + const client = makeClient(options, listChanged); + await connectWith(client, buildSse(options)); + return wrap({ + client, + transport: 'sse', + listChanged, + }); + } + + // Streamable HTTP, with SSE fallback when the transport wasn't pinned. + const client = makeClient(options, listChanged); + try { + const http = buildStreamableHttp(options); + await connectWith(client, http); + return wrap({ + client, + transport: 'streamableHttp', + listChanged, + ...(http.sessionId !== undefined && { + sessionId: http.sessionId, + }), + }); + } catch (httpErr) { + if (options.transport === 'streamableHttp') { + throw new MCPConnectionError('Failed to connect over Streamable HTTP', { + cause: httpErr, + }); + } + // Fall back to SSE on a fresh client (the failed one may be half-initialized). + const sseClient = makeClient(options, listChanged); + try { + await connectWith(sseClient, buildSse(options)); + return wrap({ + client: sseClient, + transport: 'sse', + listChanged, + }); + } catch (sseErr) { + throw new MCPConnectionError('Failed to connect over Streamable HTTP and SSE', { + cause: sseErr, + }); + } + } +} + +interface WrapArgs { + client: Client; + transport: MCPTransportKind; + listChanged: MutableListChanged; + sessionId?: string; +} + +function wrap(args: WrapArgs): MCPConnection { + const { client, transport, listChanged, sessionId } = args; + return { + client, + transport, + ...(sessionId !== undefined && { + sessionId, + }), + setToolListChangedHandler: (handler: () => void) => { + listChanged.handler = handler; + }, + close: () => client.close(), + }; +} diff --git a/packages/mcp/src/rehydrate.ts b/packages/mcp/src/rehydrate.ts new file mode 100644 index 0000000..b8dc83e --- /dev/null +++ b/packages/mcp/src/rehydrate.ts @@ -0,0 +1,228 @@ +import type { MCPAuth } from './auth/auth-types.js'; +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 { MCPCacheError } from './errors.js'; +import { freshConnect, makeHandle } from './handle.js'; +import { connect } from './mcp-connection.js'; +import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; +import type { McpToolDef } from './tool-wrapper.js'; +import type { + CreateMCPToolsOptions, + ElicitationHandler, + MCPToolsHandle, + ResourcesOption, +} from './types.js'; + +/** Clock skew (ms) treated as "already expired" when checking cached tokens. */ +const EXPIRY_SKEW_MS = 30_000; + +export interface RehydrateMCPToolsOptions { + snapshot: SerializedMCPServer; + /** Required when the snapshot carries no cached credentials. */ + auth?: MCPAuth; + fetch?: typeof fetch; + onUnconvertibleSchema?: UnconvertibleSchemaMode; + onElicitation?: ElicitationHandler; + signal?: AbortSignal; + /** Cache to refresh on reconnect/fallback. */ + cache?: { + store: MCPCacheStore; + key?: string; + }; + /** On expiry / missing creds / connection failure, do a full reconnect. Default true. */ + reconnectOnExpiry?: boolean; + // Tool-shaping + caching options threaded through from `createMCPTools` so a + // cache hit applies the same filters/prefix as the original cold call. + toolNamePrefix?: string; + includeTools?: readonly string[]; + excludeTools?: readonly string[]; + resources?: ResourcesOption; + emitProgress?: boolean; + autoRefreshOnListChanged?: boolean; + cacheCredentials?: boolean; + clientInfo?: { + name: string; + version: string; + }; +} + +function snapshotToToolDefs(snapshot: SerializedMCPServer): McpToolDef[] { + return snapshot.tools.map((t) => ({ + name: t.name, + ...(t.description !== undefined && { + description: t.description, + }), + inputSchema: { + ...t.inputSchema, + }, + ...(t.outputSchema !== undefined && { + outputSchema: { + ...t.outputSchema, + }, + }), + })); +} + +/** Cached tokens are unusable if they have a known expiry within the skew window. */ +function tokensExpired(snapshot: SerializedMCPServer): boolean { + const expiresAt = snapshot.auth?.tokens?.expiresAt; + if (expiresAt === undefined) { + return false; + } + return expiresAt - Date.now() <= EXPIRY_SKEW_MS; +} + +/** + * Reconstruct an {@link MCPAuth} from credentials persisted in a snapshot (only + * present when it was serialized with `cacheCredentials: true`). Prefer static + * headers when present; otherwise fall back to the OAuth/bearer access token. + * Returns undefined when the snapshot carries no usable credentials. + */ +function authFromSnapshot(snapshot: SerializedMCPServer): MCPAuth | undefined { + const auth = snapshot.auth; + if (auth === undefined) { + return undefined; + } + if (auth.headers !== undefined && Object.keys(auth.headers).length > 0) { + return { + kind: 'headers', + headers: auth.headers, + }; + } + if (auth.tokens?.accessToken !== undefined) { + return { + kind: 'bearer', + token: auth.tokens.accessToken, + }; + } + return undefined; +} + +function toCreateOptions( + options: RehydrateMCPToolsOptions, + snapshot: SerializedMCPServer, + effectiveAuth: MCPAuth | undefined, +): CreateMCPToolsOptions { + return { + url: snapshot.url, + transport: snapshot.transport, + ...(effectiveAuth !== undefined && { + auth: effectiveAuth, + }), + ...(options.fetch !== undefined && { + fetch: options.fetch, + }), + ...(options.onUnconvertibleSchema !== undefined && { + onUnconvertibleSchema: options.onUnconvertibleSchema, + }), + ...(options.onElicitation !== undefined && { + onElicitation: options.onElicitation, + }), + ...(options.signal !== undefined && { + signal: options.signal, + }), + ...(options.clientInfo !== undefined && { + clientInfo: options.clientInfo, + }), + ...(options.toolNamePrefix !== undefined && { + toolNamePrefix: options.toolNamePrefix, + }), + ...(options.includeTools !== undefined && { + includeTools: options.includeTools, + }), + ...(options.excludeTools !== undefined && { + excludeTools: options.excludeTools, + }), + ...(options.resources !== undefined && { + resources: options.resources, + }), + ...(options.emitProgress !== undefined && { + emitProgress: options.emitProgress, + }), + ...(options.autoRefreshOnListChanged !== undefined && { + autoRefreshOnListChanged: options.autoRefreshOnListChanged, + }), + ...(options.cacheCredentials !== undefined && { + cacheCredentials: options.cacheCredentials, + }), + ...(options.cache !== undefined && { + cache: options.cache, + }), + }; +} + +/** + * Rebuild an {@link MCPToolsHandle} from a cached snapshot. On the happy path we + * reconnect the transport and rebuild tools directly from the snapshot — + * skipping `listTools()`. If cached tokens are expired, credentials are missing, + * or the connection fails, we transparently fall back to a full + * {@link createMCPTools} (unless `reconnectOnExpiry` is false). + */ +export async function rehydrateMCPTools( + options: RehydrateMCPToolsOptions, +): Promise { + const { snapshot } = options; + if (!isSerializedMCPServer(snapshot)) { + throw new MCPCacheError('Invalid MCP snapshot: failed structural validation'); + } + + const reconnectOnExpiry = options.reconnectOnExpiry ?? true; + const url = new URL(snapshot.url); + const cacheKey = options.cache?.key ?? defaultCacheKey(url.href); + // Fall back to credentials cached in the snapshot when the caller didn't pass + // any — otherwise a credential-bearing snapshot would reconnect unauthenticated. + const effectiveAuth = options.auth ?? authFromSnapshot(snapshot); + const hasCredentials = effectiveAuth !== undefined; + // Route the fallback through `freshConnect`, NOT `createMCPTools`: the latter + // would re-read this same snapshot and re-enter rehydrate, recursing without + // bound on any no-credential / expired-token snapshot. `freshConnect` still + // writes the refreshed result back to the cache via `makeHandle`. + const createOptions = toCreateOptions(options, snapshot, effectiveAuth); + + if ((tokensExpired(snapshot) || !hasCredentials) && reconnectOnExpiry) { + return freshConnect(createOptions, url, cacheKey); + } + + try { + const connection = await connect({ + url, + transport: snapshot.transport, + ...(effectiveAuth !== undefined && { + auth: effectiveAuth, + }), + ...(options.fetch !== undefined && { + fetch: options.fetch, + }), + ...(options.clientInfo !== undefined && { + clientInfo: options.clientInfo, + }), + ...(snapshot.sessionId !== undefined && { + sessionId: snapshot.sessionId, + }), + ...(options.onElicitation !== undefined && { + onElicitation: options.onElicitation, + }), + }); + + // Rebuild tools from the snapshot — no listTools() round-trip. + return makeHandle({ + connection, + options: createOptions, + context: { + url, + transport: connection.transport, + cacheKey, + }, + initialToolDefs: snapshotToToolDefs(snapshot), + }); + } catch (err) { + if (reconnectOnExpiry) { + return freshConnect(createOptions, url, cacheKey); + } + throw new MCPCacheError('Failed to rehydrate MCP connection from snapshot', { + cause: err, + }); + } +} diff --git a/packages/mcp/src/resource-tools.ts b/packages/mcp/src/resource-tools.ts new file mode 100644 index 0000000..41f5743 --- /dev/null +++ b/packages/mcp/src/resource-tools.ts @@ -0,0 +1,189 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { markMcp, tool } from '@openrouter/agent/tool'; +import type { McpBranded } from '@openrouter/agent/tool-types'; +import * as z from 'zod'; + +export interface ResourceToolsOptions { + client: Client; + namePrefix?: string; + signal?: AbortSignal; +} + +// Hard cap on pagination pages. A well-behaved server terminates the cursor +// chain by omitting `nextCursor`; this bounds a misbehaving one that never does. +const MAX_LIST_PAGES = 1000; + +type RequestOptions = + | { + signal: AbortSignal; + } + | undefined; + +// Minimal page shapes: each list endpoint returns its items plus an optional +// `nextCursor`. We accept extra fields the SDK includes and read only these. +interface ResourcePage { + resources: ListedResource[]; + nextCursor?: string | undefined; +} +interface ResourceTemplatePage { + resourceTemplates: ListedResourceTemplate[]; + nextCursor?: string | undefined; +} +type ListedResource = Awaited>['resources'][number]; +type ListedResourceTemplate = Awaited< + ReturnType +>['resourceTemplates'][number]; + +/** + * Normalize a paginated `nextCursor` field: treat anything that is not a + * non-empty string as "no more pages" so a malformed cursor terminates the loop. + */ +function nextCursorOrUndefined(value: string | undefined): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Walk a paginated MCP list endpoint, following `nextCursor` (passed back as + * `{ cursor }`) until exhausted, and return the flattened items. Stops when the + * server omits `nextCursor`, repeats the same cursor, or the page cap is hit. + */ +async function collectPages( + fetchPage: ( + params: + | { + cursor: string; + } + | undefined, + options: RequestOptions, + ) => Promise<{ + items: Item[]; + nextCursor: string | undefined; + }>, + requestOptions: RequestOptions, +): Promise { + const collected: Item[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_LIST_PAGES; page += 1) { + const params = + cursor !== undefined + ? { + cursor, + } + : undefined; + const { items, nextCursor } = await fetchPage(params, requestOptions); + collected.push(...items); + const next = nextCursorOrUndefined(nextCursor); + if (next === undefined || next === cursor) { + break; + } + cursor = next; + } + return collected; +} + +/** + * Build synthetic tools that let the model browse and read MCP resources + * through the normal tool loop: + * - `list_resources`: concrete resources plus resource templates. + * - `read_resource`: fetch a resource's contents by URI. + * + * Only call this when the server advertises the `resources` capability. + */ +export function buildResourceTools(options: ResourceToolsOptions): McpBranded[] { + const prefix = options.namePrefix ?? ''; + const requestOptions = + options.signal !== undefined + ? { + signal: options.signal, + } + : undefined; + + const listResources = tool({ + name: `${prefix}list_resources`, + description: 'List the resources and resource templates exposed by the MCP server.', + inputSchema: z.object({}), + execute: async () => { + const [resources, resourceTemplates] = await Promise.all([ + collectPages(async (params, opts) => { + const page: ResourcePage = await options.client.listResources(params, opts); + return { + items: page.resources, + nextCursor: page.nextCursor, + }; + }, requestOptions), + // Templates are optional: a server may not support them. The whole + // paginated fetch degrades gracefully to an empty list on any error. + collectPages(async (params, opts) => { + const page: ResourceTemplatePage = await options.client.listResourceTemplates( + params, + opts, + ); + return { + items: page.resourceTemplates, + nextCursor: page.nextCursor, + }; + }, requestOptions).catch(() => []), + ]); + return { + resources: resources.map((r) => ({ + uri: r.uri, + name: r.name, + ...(r.description !== undefined && { + description: r.description, + }), + ...(r.mimeType !== undefined && { + mimeType: r.mimeType, + }), + })), + resourceTemplates: resourceTemplates.map((t) => ({ + uriTemplate: t.uriTemplate, + name: t.name, + ...(t.description !== undefined && { + description: t.description, + }), + })), + }; + }, + }); + + const readResource = tool({ + name: `${prefix}read_resource`, + description: 'Read the contents of an MCP resource by its URI.', + inputSchema: z.object({ + uri: z.string().describe('Resource URI to read'), + }), + execute: async (args: { uri: string }) => { + const result = await options.client.readResource( + { + uri: args.uri, + }, + requestOptions, + ); + return { + contents: result.contents.map((c) => { + if ('text' in c) { + return { + uri: c.uri, + text: c.text, + ...(c.mimeType !== undefined && { + mimeType: c.mimeType, + }), + }; + } + return { + uri: c.uri, + blob: c.blob, + ...(c.mimeType !== undefined && { + mimeType: c.mimeType, + }), + }; + }), + }; + }, + }); + + return [ + markMcp(listResources), + markMcp(readResource), + ]; +} diff --git a/packages/mcp/src/result-mapper.ts b/packages/mcp/src/result-mapper.ts new file mode 100644 index 0000000..3b29288 --- /dev/null +++ b/packages/mcp/src/result-mapper.ts @@ -0,0 +1,56 @@ +import { MCPToolCallError } from './errors.js'; +import { isJsonSchemaObject } from './schema/json-schema-guards.js'; + +/** + * The subset of an MCP `CallToolResult` we read. The SDK types it more richly, + * but we narrow defensively from `unknown`-ish content rather than trusting the + * wire shape. + */ +export interface RawCallToolResult { + content?: unknown; + structuredContent?: unknown; + isError?: boolean; + [key: string]: unknown; +} + +function isTextBlock(block: unknown): block is { + type: 'text'; + text: string; +} { + return isJsonSchemaObject(block) && block['type'] === 'text' && typeof block['text'] === 'string'; +} + +/** Collapse the content array into a single string for the model. */ +function contentToText(content: unknown): string { + if (!Array.isArray(content)) { + return ''; + } + const parts: string[] = []; + for (const block of content) { + if (isTextBlock(block)) { + parts.push(block.text); + } else if (isJsonSchemaObject(block) && typeof block['type'] === 'string') { + // Non-text blocks (image/audio/resource) aren't passed through as media + // in v1 — surface a typed placeholder so the model knows it exists. + parts.push(`[${block['type']} content]`); + } + } + return parts.join('\n'); +} + +/** + * Map an MCP tool result into a value `callModel` can hand back to the model. + * Prefers `structuredContent` (already JSON), otherwise the collapsed text + * content. Throws {@link MCPToolCallError} when the server flags `isError`, so + * the agent loop reports the failure instead of treating error text as success. + */ +export function mapCallToolResult(toolName: string, result: RawCallToolResult): unknown { + if (result.isError === true) { + const message = contentToText(result.content) || 'MCP tool returned an error'; + throw new MCPToolCallError(toolName, message); + } + if (result.structuredContent !== undefined) { + return result.structuredContent; + } + return contentToText(result.content); +} diff --git a/packages/mcp/src/schema/json-schema-guards.ts b/packages/mcp/src/schema/json-schema-guards.ts new file mode 100644 index 0000000..ca430c6 --- /dev/null +++ b/packages/mcp/src/schema/json-schema-guards.ts @@ -0,0 +1,25 @@ +/** + * Typeguards over the raw JSON-Schema-shaped values that arrive from an MCP + * server. MCP tool input/output schemas are untrusted `unknown` data, so we + * narrow them with runtime checks rather than `as` casts. + */ + +/** A non-null, non-array object — the shape every JSON Schema node takes. */ +export function isJsonSchemaObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** A JSON Schema whose top-level `type` is `"object"`. */ +export function isObjectJsonSchema(value: unknown): value is Record & { + type: 'object'; +} { + return isJsonSchemaObject(value) && value['type'] === 'object'; +} + +/** The `properties` map of an object schema, when present and well-formed. */ +export function getSchemaProperties( + schema: Record, +): Record | undefined { + const { properties } = schema; + return isJsonSchemaObject(properties) ? properties : undefined; +} diff --git a/packages/mcp/src/schema/json-schema-to-zod.ts b/packages/mcp/src/schema/json-schema-to-zod.ts new file mode 100644 index 0000000..7763fa6 --- /dev/null +++ b/packages/mcp/src/schema/json-schema-to-zod.ts @@ -0,0 +1,90 @@ +import * as z from 'zod'; +import type { $ZodObject, $ZodType } from 'zod/v4/core'; +import { MCPError } from '../errors.js'; +import { getSchemaProperties, isJsonSchemaObject } from './json-schema-guards.js'; + +/** + * How to handle a JSON Schema (sub)schema that Zod cannot represent. + * - `looseLeaf` (default): substitute `z.unknown()` for only the offending + * property so the rest of the tool's parameters stay faithful. + * - `throw`: surface the conversion failure to the caller. + */ +export type UnconvertibleSchemaMode = 'looseLeaf' | 'throw'; + +/** Narrow a Zod schema to an object schema via its runtime type tag (no `as`). */ +function isZodObject(schema: $ZodType): schema is $ZodObject { + return schema._zod.def.type === 'object'; +} + +/** The `required` array of an object schema, filtered to string keys. */ +function requiredKeys(schema: Record): Set { + const { required } = schema; + if (!Array.isArray(required)) { + return new Set(); + } + return new Set(required.filter((key): key is string => typeof key === 'string')); +} + +/** + * Build a Zod object by converting each property independently, substituting + * `z.unknown()` for any property Zod cannot represent. Always yields a + * `$ZodObject`, which is what `callModel`'s `tool()` factory requires. + */ +function buildObjectFromProperties( + jsonSchema: Record, + mode: UnconvertibleSchemaMode, +): $ZodObject { + const properties = getSchemaProperties(jsonSchema) ?? {}; + const required = requiredKeys(jsonSchema); + const shape: Record = {}; + + for (const [key, propSchema] of Object.entries(properties)) { + let zodProp: $ZodType; + try { + zodProp = isJsonSchemaObject(propSchema) ? z.fromJSONSchema(propSchema) : z.unknown(); + } catch (err) { + if (mode === 'throw') { + throw new MCPError(`Cannot convert JSON Schema for property "${key}" to Zod`, { + cause: err, + }); + } + zodProp = z.unknown(); + } + shape[key] = required.has(key) ? zodProp : z.optional(zodProp); + } + + return z.object(shape); +} + +/** + * Convert an MCP tool's JSON Schema (input or output) into a Zod v4 object + * schema. `callModel` derives the model-facing parameters from this Zod schema, + * so the conversion must be faithful — a permissive passthrough would make the + * model call the tool blind. + * + * Strategy: attempt a holistic `z.fromJSONSchema` conversion first (handles + * `$ref`/`$defs`, `anyOf`, formats, nesting); on failure, fall back per the + * chosen `mode`. + */ +export function convertMcpInputSchema( + jsonSchema: Record, + mode: UnconvertibleSchemaMode = 'looseLeaf', +): $ZodObject { + try { + const converted = z.fromJSONSchema(jsonSchema); + if (isZodObject(converted)) { + return converted; + } + // MCP guarantees object-typed tool schemas; if a server sends something + // else, degrade to a per-property object rather than handing callModel a + // non-object schema it cannot use. + return buildObjectFromProperties(jsonSchema, mode); + } catch (err) { + if (mode === 'throw') { + throw new MCPError('Cannot convert MCP JSON Schema to Zod', { + cause: err, + }); + } + return buildObjectFromProperties(jsonSchema, mode); + } +} diff --git a/packages/mcp/src/tool-wrapper.ts b/packages/mcp/src/tool-wrapper.ts new file mode 100644 index 0000000..072adc1 --- /dev/null +++ b/packages/mcp/src/tool-wrapper.ts @@ -0,0 +1,164 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { Progress } from '@modelcontextprotocol/sdk/types.js'; +import { markMcp, tool } from '@openrouter/agent/tool'; +import type { McpBranded } from '@openrouter/agent/tool-types'; +import * as z from 'zod'; +import type { RawCallToolResult } from './result-mapper.js'; +import { mapCallToolResult } from './result-mapper.js'; +import { isJsonSchemaObject } from './schema/json-schema-guards.js'; +import type { UnconvertibleSchemaMode } from './schema/json-schema-to-zod.js'; +import { convertMcpInputSchema } from './schema/json-schema-to-zod.js'; + +/** A discovered MCP tool definition (the fields we consume). */ +export interface McpToolDef { + name: string; + description?: string; + inputSchema: Record; + outputSchema?: Record; +} + +export interface WrapToolOptions { + client: Client; + namePrefix?: string; + schemaMode?: UnconvertibleSchemaMode; + emitProgress?: boolean; + signal?: AbortSignal; +} + +/** Event yielded by a progress-emitting generator tool. */ +const progressEventSchema = z.object({ + type: z.literal('progress'), + progress: z.number(), + total: z.optional(z.number()), + message: z.optional(z.string()), +}); + +function callToolResultIsRaw(value: unknown): value is RawCallToolResult { + return isJsonSchemaObject(value); +} + +interface InvokeToolArgs { + options: WrapToolOptions; + mcpName: string; + args: Record; + onprogress?: (progress: Progress) => void; +} + +async function invokeTool(invokeArgs: InvokeToolArgs): Promise { + const { options, mcpName, args, onprogress } = invokeArgs; + const result = await options.client.callTool( + { + name: mcpName, + arguments: args, + }, + undefined, + { + ...(options.signal !== undefined && { + signal: options.signal, + }), + ...(onprogress !== undefined && { + onprogress, + }), + }, + ); + if (!callToolResultIsRaw(result)) { + return ''; + } + return mapCallToolResult(mcpName, result); +} + +/** + * Wrap one discovered MCP tool as an `@openrouter/agent` tool. Emits a + * generator tool (streaming progress events) when `emitProgress` is on, + * otherwise a regular tool. The input schema (and output schema, when declared) + * are converted to Zod so the model sees faithful parameters. The abort signal, + * if supplied, is threaded into the underlying `callTool`. + */ +export function wrapMcpTool(def: McpToolDef, options: WrapToolOptions): McpBranded { + const name = `${options.namePrefix ?? ''}${def.name}`; + const inputSchema = convertMcpInputSchema(def.inputSchema, options.schemaMode); + const outputSchema = + def.outputSchema !== undefined + ? convertMcpInputSchema(def.outputSchema, options.schemaMode) + : undefined; + + if (options.emitProgress === true) { + return markMcp( + tool({ + name, + ...(def.description !== undefined && { + description: def.description, + }), + inputSchema, + eventSchema: progressEventSchema, + outputSchema: outputSchema ?? z.unknown(), + execute: async function* (args: Record) { + const queue: Array> = []; + let notify: (() => void) | undefined; + const onprogress = (p: Progress): void => { + queue.push({ + type: 'progress', + progress: p.progress, + ...(p.total !== undefined && { + total: p.total, + }), + ...(typeof p.message === 'string' && { + message: p.message, + }), + }); + notify?.(); + }; + + const resultPromise = invokeTool({ + options, + mcpName: def.name, + args, + onprogress, + }); + let done = false; + const finalize = resultPromise.finally(() => { + done = true; + notify?.(); + }); + + while (!done || queue.length > 0) { + while (queue.length > 0) { + const event = queue.shift(); + if (event !== undefined) { + yield event; + } + } + if (done) { + break; + } + await new Promise((resolve) => { + notify = resolve; + }); + notify = undefined; + } + + return await finalize; + }, + }), + ); + } + + return markMcp( + tool({ + name, + ...(def.description !== undefined && { + description: def.description, + }), + inputSchema, + ...(outputSchema !== undefined && { + outputSchema, + }), + execute: (args: Record) => + invokeTool({ + options, + mcpName: def.name, + args, + }), + }), + ); +} diff --git a/packages/mcp/src/transport-types.ts b/packages/mcp/src/transport-types.ts new file mode 100644 index 0000000..ae88e97 --- /dev/null +++ b/packages/mcp/src/transport-types.ts @@ -0,0 +1,2 @@ +/** Non-stdio MCP transports this package supports. */ +export type MCPTransportKind = 'streamableHttp' | 'sse'; diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 0000000..351b5d0 --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,114 @@ +import type { Tool } 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 { MCPTransportKind } from './transport-types.js'; + +export type { 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; + /** Auto-refresh tools on `tools/list_changed`. Default true when connected. */ + autoRefreshOnListChanged?: boolean; + /** Handler for server-initiated elicitation; auto-declines when omitted. */ + onElicitation?: ElicitationHandler; + /** 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/tests/e2e/mcp-tools.e2e.test.ts b/packages/mcp/tests/e2e/mcp-tools.e2e.test.ts new file mode 100644 index 0000000..b3ffe06 --- /dev/null +++ b/packages/mcp/tests/e2e/mcp-tools.e2e.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { createMCPTools, InMemoryMCPCacheStore, rehydrateMCPTools } from '../../src/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. +const MCP_TEST_URL = process.env.MCP_TEST_URL; +const MCP_TEST_TOKEN = process.env.MCP_TEST_TOKEN; + +const maybe = MCP_TEST_URL !== undefined ? describe : describe.skip; + +// Resolved to a concrete string for the guarded block; the empty fallback is +// never reached because `maybe` is `describe.skip` when the URL is absent. +const url = MCP_TEST_URL ?? ''; + +maybe('createMCPTools (e2e)', () => { + const auth = + MCP_TEST_TOKEN !== undefined + ? ({ + kind: 'bearer', + token: MCP_TEST_TOKEN, + } as const) + : undefined; + + it('connects, lists tools, and exposes them for callModel', async () => { + const mcp = await createMCPTools({ + url, + ...(auth !== undefined && { + auth, + }), + }); + try { + expect(mcp.tools.length).toBeGreaterThan(0); + // Each wrapped tool carries a function name usable by callModel. + for (const t of mcp.tools) { + expect('function' in t).toBe(true); + } + } finally { + await mcp.close(); + } + }); + + it('serializes and rehydrates without re-listing', async () => { + const store = new InMemoryMCPCacheStore(); + const mcp = await createMCPTools({ + url, + ...(auth !== undefined && { + auth, + }), + cache: { + store, + }, + cacheCredentials: true, + }); + const snapshot = await mcp.serialize(); + await mcp.close(); + + expect(snapshot.tools.length).toBeGreaterThan(0); + + const rehydrated = await rehydrateMCPTools({ + snapshot, + ...(auth !== undefined && { + auth, + }), + }); + try { + expect(rehydrated.tools.length).toBe(snapshot.tools.length); + } finally { + await rehydrated.close(); + } + }); +}); diff --git a/packages/mcp/tests/unit/build-tools.test.ts b/packages/mcp/tests/unit/build-tools.test.ts new file mode 100644 index 0000000..353911e --- /dev/null +++ b/packages/mcp/tests/unit/build-tools.test.ts @@ -0,0 +1,124 @@ +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'; + +// 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. +function fakeClient(): import('@modelcontextprotocol/sdk/client/index.js').Client { + return {} as never; +} + +const defs: McpToolDef[] = [ + { + name: 'alpha', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'beta', + inputSchema: { + type: 'object', + properties: {}, + }, + }, +]; + +function nameOf(tool: unknown): string | undefined { + if ( + typeof tool === 'object' && + tool !== null && + 'function' in tool && + typeof tool.function === 'object' && + tool.function !== null && + 'name' in tool.function && + typeof tool.function.name === 'string' + ) { + return tool.function.name; + } + return undefined; +} + +describe('filterToolDefs', () => { + it('applies an allow-list', () => { + expect( + filterToolDefs(defs, [ + 'alpha', + ]).map((d) => d.name), + ).toEqual([ + 'alpha', + ]); + }); + it('applies a deny-list', () => { + expect( + filterToolDefs(defs, undefined, [ + 'alpha', + ]).map((d) => d.name), + ).toEqual([ + 'beta', + ]); + }); +}); + +describe('buildTools', () => { + it('wraps tools and applies a name prefix', () => { + const tools = buildTools({ + client: fakeClient(), + toolDefs: defs, + namePrefix: 'svc_', + serverHasResources: false, + }); + expect(tools.map(nameOf)).toEqual([ + 'svc_alpha', + 'svc_beta', + ]); + }); + + it('adds synthetic resource tools when the server supports resources', () => { + const tools = buildTools({ + client: fakeClient(), + toolDefs: defs, + serverHasResources: true, + }); + expect(tools.map(nameOf)).toContain('list_resources'); + expect(tools.map(nameOf)).toContain('read_resource'); + }); + + it('omits resource tools when disabled', () => { + const tools = buildTools({ + client: fakeClient(), + toolDefs: defs, + serverHasResources: true, + resources: false, + }); + expect(tools.map(nameOf)).not.toContain('list_resources'); + }); + + it('throws on a duplicate tool name', () => { + const dup: McpToolDef[] = [ + { + name: 'same', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'same', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ]; + expect(() => + buildTools({ + client: fakeClient(), + toolDefs: dup, + serverHasResources: false, + }), + ).toThrow(MCPError); + }); +}); diff --git a/packages/mcp/tests/unit/cache.test.ts b/packages/mcp/tests/unit/cache.test.ts new file mode 100644 index 0000000..4db3e27 --- /dev/null +++ b/packages/mcp/tests/unit/cache.test.ts @@ -0,0 +1,181 @@ +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'; + +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 + sessionId 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).toBe('sess-1'); + }); +}); + +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/create-mcp-tools.test.ts b/packages/mcp/tests/unit/create-mcp-tools.test.ts new file mode 100644 index 0000000..55ace7c --- /dev/null +++ b/packages/mcp/tests/unit/create-mcp-tools.test.ts @@ -0,0 +1,102 @@ +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); + } + }); +}); diff --git a/packages/mcp/tests/unit/elicitation.test.ts b/packages/mcp/tests/unit/elicitation.test.ts new file mode 100644 index 0000000..8665c2a --- /dev/null +++ b/packages/mcp/tests/unit/elicitation.test.ts @@ -0,0 +1,84 @@ +import type { ElicitRequest } from '@modelcontextprotocol/sdk/types.js'; +import { describe, expect, it } from 'vitest'; +import { makeElicitationRequestHandler } from '../../src/elicitation.js'; + +function formRequest(): ElicitRequest { + return { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Need your name', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }; +} + +describe('makeElicitationRequestHandler', () => { + it('auto-declines when no handler is provided', async () => { + const handle = makeElicitationRequestHandler(undefined); + expect(await handle(formRequest())).toEqual({ + action: 'decline', + }); + }); + + it('passes message + schema to the handler and returns accepted content', async () => { + const handle = makeElicitationRequestHandler((req) => { + expect(req.message).toBe('Need your name'); + expect(req.requestedSchema['type']).toBe('object'); + return { + action: 'accept', + content: { + name: 'Ada', + }, + }; + }); + expect(await handle(formRequest())).toEqual({ + action: 'accept', + content: { + name: 'Ada', + }, + }); + }); + + it('drops non-primitive values from accepted content', async () => { + const handle = makeElicitationRequestHandler(() => ({ + action: 'accept', + content: { + ok: 'yes', + nested: { + bad: true, + }, + count: 2, + }, + })); + expect(await handle(formRequest())).toEqual({ + action: 'accept', + content: { + ok: 'yes', + count: 2, + }, + }); + }); + + it('forwards decline and cancel actions', async () => { + const decline = makeElicitationRequestHandler(() => ({ + action: 'decline', + })); + const cancel = makeElicitationRequestHandler(() => ({ + action: 'cancel', + })); + expect(await decline(formRequest())).toEqual({ + action: 'decline', + }); + expect(await cancel(formRequest())).toEqual({ + action: 'cancel', + }); + }); +}); diff --git a/packages/mcp/tests/unit/json-schema-to-zod.test.ts b/packages/mcp/tests/unit/json-schema-to-zod.test.ts new file mode 100644 index 0000000..cdcf3f6 --- /dev/null +++ b/packages/mcp/tests/unit/json-schema-to-zod.test.ts @@ -0,0 +1,232 @@ +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'; + +describe('convertMcpInputSchema', () => { + it('converts primitives, enums, and required fields faithfully', () => { + const schema = convertMcpInputSchema({ + type: 'object', + properties: { + location: { + type: 'string', + description: 'city', + }, + count: { + type: 'integer', + minimum: 1, + }, + mode: { + type: 'string', + enum: [ + 'a', + 'b', + ], + }, + verbose: { + type: 'boolean', + }, + }, + required: [ + 'location', + ], + }); + + expect( + z.parse(schema, { + location: 'NYC', + count: 2, + mode: 'a', + }), + ).toEqual({ + location: 'NYC', + count: 2, + mode: 'a', + }); + // location is required + expect(() => + z.parse(schema, { + count: 1, + }), + ).toThrow(); + // enum is enforced + expect(() => + z.parse(schema, { + location: 'NYC', + mode: 'c', + }), + ).toThrow(); + }); + + it('handles nested objects and arrays', () => { + const schema = convertMcpInputSchema({ + type: 'object', + properties: { + filter: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }); + expect( + z.parse(schema, { + filter: { + tags: [ + 'x', + 'y', + ], + }, + }), + ).toEqual({ + filter: { + tags: [ + 'x', + 'y', + ], + }, + }); + }); + + it('handles anyOf unions', () => { + const schema = convertMcpInputSchema({ + type: 'object', + properties: { + value: { + anyOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }, + required: [ + 'value', + ], + }); + expect( + z.parse(schema, { + value: 'a', + }), + ).toEqual({ + value: 'a', + }); + expect( + z.parse(schema, { + value: 3, + }), + ).toEqual({ + value: 3, + }); + }); + + it('resolves $ref/$defs', () => { + const schema = convertMcpInputSchema({ + type: 'object', + properties: { + item: { + $ref: '#/$defs/Item', + }, + }, + $defs: { + Item: { + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + }, + }, + required: [ + 'item', + ], + }); + expect( + z.parse(schema, { + item: { + id: '1', + }, + }), + ).toEqual({ + item: { + id: '1', + }, + }); + }); + + it('produces an object schema for an empty-params tool', () => { + const schema = convertMcpInputSchema({ + type: 'object', + properties: {}, + }); + expect(schema._zod.def.type).toBe('object'); + expect(z.parse(schema, {})).toEqual({}); + }); + + it('looseLeaf: relaxes only the unconvertible property, keeps siblings faithful', () => { + const schema = convertMcpInputSchema({ + type: 'object', + properties: { + good: { + type: 'string', + }, + bad: { + not: { + type: 'string', + }, + }, + }, + required: [ + 'good', + ], + }); + // sibling still enforced + expect(() => + z.parse(schema, { + good: 123, + }), + ).toThrow(); + // unconvertible leaf accepts anything + expect( + z.parse(schema, { + good: 'ok', + bad: { + anything: true, + }, + }), + ).toEqual({ + good: 'ok', + bad: { + anything: true, + }, + }); + }); + + it('throw mode: surfaces an MCPError on unconvertible schema', () => { + expect(() => + convertMcpInputSchema( + { + type: 'object', + properties: { + bad: { + not: { + type: 'string', + }, + }, + }, + }, + 'throw', + ), + ).toThrow(MCPError); + }); +}); diff --git a/packages/mcp/tests/unit/list-tools-pagination.test.ts b/packages/mcp/tests/unit/list-tools-pagination.test.ts new file mode 100644 index 0000000..7b5ea8f --- /dev/null +++ b/packages/mcp/tests/unit/list-tools-pagination.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ConnectOptions, MCPConnection } from '../../src/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. +interface ToolPage { + tools: { + name: string; + inputSchema: { + type: 'object'; + properties: object; + }; + }[]; + nextCursor?: string; +} + +const listToolsCursors: (string | undefined)[] = []; + +const toolPages: ToolPage[] = [ + { + tools: [ + { + name: 'alpha', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ], + nextCursor: 'page-2', + }, + { + tools: [ + { + name: 'beta', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ], + // No nextCursor -> last page. + }, +]; + +vi.mock('../../src/mcp-connection.js', () => ({ + connect: (_options: ConnectOptions): Promise => { + let idx = 0; + const connection: MCPConnection = { + client: { + listTools: (params?: { cursor?: string }) => { + listToolsCursors.push(params?.cursor); + const page = toolPages[idx] ?? { + tools: [], + }; + idx += 1; + return Promise.resolve(page); + }, + getServerVersion: () => undefined, + getServerCapabilities: () => undefined, + } as never, + transport: 'streamableHttp', + setToolListChangedHandler: () => {}, + close: () => Promise.resolve(), + }; + return Promise.resolve(connection); + }, +})); + +const { freshConnect } = await import('../../src/handle.js'); + +function nameOf(tool: unknown): string | undefined { + if ( + typeof tool === 'object' && + tool !== null && + 'function' in tool && + typeof tool.function === 'object' && + tool.function !== null && + 'name' in tool.function && + typeof tool.function.name === 'string' + ) { + return tool.function.name; + } + return undefined; +} + +describe('listToolDefs pagination (via freshConnect)', () => { + it('discovers tools across every page following nextCursor', async () => { + listToolsCursors.length = 0; + const handle = await freshConnect( + { + url: 'https://mcp.example.com/mcp', + }, + new URL('https://mcp.example.com/mcp'), + 'test-key', + ); + const names = handle.tools.map(nameOf); + expect(names).toContain('alpha'); + expect(names).toContain('beta'); + // First page requested without a cursor, second following nextCursor. + expect(listToolsCursors).toEqual([ + undefined, + 'page-2', + ]); + }); +}); diff --git a/packages/mcp/tests/unit/rehydrate.test.ts b/packages/mcp/tests/unit/rehydrate.test.ts new file mode 100644 index 0000000..c135220 --- /dev/null +++ b/packages/mcp/tests/unit/rehydrate.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConnectOptions, MCPConnection } from '../../src/mcp-connection.js'; + +// Capture the options every `connect` call receives so we can assert on the auth +// that rehydrate forwards into the transport. +const connectCalls: ConnectOptions[] = []; + +vi.mock('../../src/mcp-connection.js', () => ({ + connect: (options: ConnectOptions): Promise => { + connectCalls.push(options); + const connection: MCPConnection = { + // Minimal client stand-in: buildTools stores the reference but these tests + // never invoke a wrapped tool, and capabilities/version are read as absent. + client: { + getServerVersion: () => undefined, + getServerCapabilities: () => undefined, + } as never, + transport: 'streamableHttp', + setToolListChangedHandler: () => {}, + close: () => Promise.resolve(), + }; + return Promise.resolve(connection); + }, +})); + +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; + +function snapshotWithHeaders(): SerializedMCPServer { + const snap: SerializedMCPServer = { + version: 1, + url: 'https://mcp.example.com/mcp', + transport: 'streamableHttp', + tools: [ + { + name: 'alpha', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'beta', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + ], + auth: { + headers: { + 'X-Api-Key': 'secret', + }, + }, + cachedAt: Date.now(), + }; + // Guard against drift between the literal above and the validator. + expect(isSerializedMCPServer(snap)).toBe(true); + return snap; +} + +function nameOf(tool: unknown): string | undefined { + if ( + typeof tool === 'object' && + tool !== null && + 'function' in tool && + typeof tool.function === 'object' && + tool.function !== null && + 'name' in tool.function && + typeof tool.function.name === 'string' + ) { + return tool.function.name; + } + return undefined; +} + +describe('rehydrateMCPTools', () => { + beforeEach(() => { + connectCalls.length = 0; + }); + + it('applies toolNamePrefix and excludeTools on a cache hit', async () => { + const handle = await rehydrateMCPTools({ + snapshot: snapshotWithHeaders(), + toolNamePrefix: 'svc_', + excludeTools: [ + 'beta', + ], + }); + expect(handle.tools.map(nameOf)).toEqual([ + 'svc_alpha', + ]); + }); + + it('reconstructs auth from a credential-bearing snapshot when no auth is passed', async () => { + await rehydrateMCPTools({ + snapshot: snapshotWithHeaders(), + }); + expect(connectCalls).toHaveLength(1); + expect(connectCalls[0]?.auth).toEqual({ + kind: 'headers', + headers: { + 'X-Api-Key': 'secret', + }, + }); + }); + + it('reconstructs a bearer token from snapshot tokens', async () => { + const snap = snapshotWithHeaders(); + snap.auth = { + tokens: { + accessToken: 'token-123', + }, + }; + await rehydrateMCPTools({ + snapshot: snap, + }); + expect(connectCalls[0]?.auth).toEqual({ + kind: 'bearer', + token: 'token-123', + }); + }); +}); diff --git a/packages/mcp/tests/unit/resource-tools.test.ts b/packages/mcp/tests/unit/resource-tools.test.ts new file mode 100644 index 0000000..53dbac6 --- /dev/null +++ b/packages/mcp/tests/unit/resource-tools.test.ts @@ -0,0 +1,271 @@ +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { describe, expect, it } from 'vitest'; +import { buildResourceTools } from '../../src/resource-tools.js'; + +// Minimal page shapes returned by the fake client's list endpoints. +interface ResourcePage { + resources: { + uri: string; + name: string; + }[]; + nextCursor?: string; +} +interface TemplatePage { + resourceTemplates: { + uriTemplate: string; + name: string; + }[]; + nextCursor?: string; +} + +// Build a fake MCP Client that serves the given pages of resources and +// templates one cursor at a time, recording the cursors it was asked for. +function fakeClient(args: { + resourcePages: ResourcePage[]; + templatePages?: TemplatePage[]; + templateError?: boolean; +}): { + client: Client; + resourceCursors: (string | undefined)[]; + templateCursors: (string | undefined)[]; +} { + const resourceCursors: (string | undefined)[] = []; + const templateCursors: (string | undefined)[] = []; + let resourceIdx = 0; + let templateIdx = 0; + const client = { + listResources: (params?: { cursor?: string }) => { + resourceCursors.push(params?.cursor); + const page = args.resourcePages[resourceIdx] ?? { + resources: [], + }; + resourceIdx += 1; + return Promise.resolve(page); + }, + listResourceTemplates: (params?: { cursor?: string }) => { + templateCursors.push(params?.cursor); + if (args.templateError === true) { + return Promise.reject(new Error('templates not supported')); + } + const page = args.templatePages?.[templateIdx] ?? { + resourceTemplates: [], + }; + templateIdx += 1; + return Promise.resolve(page); + }, + // Minimal stand-in: list_resources only invokes the two list methods above. + } as never; + return { + client, + resourceCursors, + templateCursors, + }; +} + +// A tool carrying a callable `function.execute`, narrowed from the Tool union. +function hasExecutableFunction(tool: unknown): tool is { + function: { + name: string; + execute: (args: Record) => Promise; + }; +} { + if (typeof tool !== 'object' || tool === null || !('function' in tool)) { + return false; + } + const fn = tool.function; + return ( + typeof fn === 'object' && + fn !== null && + 'name' in fn && + typeof fn.name === 'string' && + 'execute' in fn && + typeof fn.execute === 'function' + ); +} + +// Find the synthetic `list_resources` tool and invoke its execute closure. +function runListResources(client: Client): Promise { + const tools = buildResourceTools({ + client, + }); + const listTool = tools.find( + (t) => hasExecutableFunction(t) && t.function.name === 'list_resources', + ); + if (listTool === undefined || !hasExecutableFunction(listTool)) { + throw new Error('list_resources tool with execute not found'); + } + // Context is optional; list_resources reads no input and no context. + return listTool.function.execute({}); +} + +function isResourceListOutput(value: unknown): value is { + resources: { + uri: string; + name: string; + }[]; + resourceTemplates: { + uriTemplate: string; + name: string; + }[]; +} { + return ( + typeof value === 'object' && + value !== null && + 'resources' in value && + Array.isArray(value.resources) && + 'resourceTemplates' in value && + Array.isArray(value.resourceTemplates) + ); +} + +describe('buildResourceTools list_resources pagination', () => { + it('follows nextCursor across multiple pages of resources', async () => { + const { client, resourceCursors } = fakeClient({ + resourcePages: [ + { + resources: [ + { + uri: 'res://a', + name: 'a', + }, + ], + nextCursor: 'page-2', + }, + { + resources: [ + { + uri: 'res://b', + name: 'b', + }, + ], + nextCursor: 'page-3', + }, + { + resources: [ + { + uri: 'res://c', + name: 'c', + }, + ], + // no nextCursor -> terminate + }, + ], + }); + + const result = await runListResources(client); + expect(isResourceListOutput(result)).toBe(true); + if (!isResourceListOutput(result)) { + return; + } + expect(result.resources.map((r) => r.uri)).toEqual([ + 'res://a', + 'res://b', + 'res://c', + ]); + // First call has no cursor, subsequent calls follow nextCursor. + expect(resourceCursors).toEqual([ + undefined, + 'page-2', + 'page-3', + ]); + }); + + it('paginates resource templates and degrades to empty on error', async () => { + const { client } = fakeClient({ + resourcePages: [ + { + resources: [], + }, + ], + templateError: true, + }); + + const result = await runListResources(client); + expect(isResourceListOutput(result)).toBe(true); + if (!isResourceListOutput(result)) { + return; + } + expect(result.resourceTemplates).toEqual([]); + }); + + it('accumulates all template pages when supported', async () => { + const { client, templateCursors } = fakeClient({ + resourcePages: [ + { + resources: [], + }, + ], + templatePages: [ + { + resourceTemplates: [ + { + uriTemplate: 'res://{id}', + name: 't1', + }, + ], + nextCursor: 'tpl-2', + }, + { + resourceTemplates: [ + { + uriTemplate: 'res://{slug}', + name: 't2', + }, + ], + }, + ], + }); + + const result = await runListResources(client); + expect(isResourceListOutput(result)).toBe(true); + if (!isResourceListOutput(result)) { + return; + } + expect(result.resourceTemplates.map((t) => t.name)).toEqual([ + 't1', + 't2', + ]); + expect(templateCursors).toEqual([ + undefined, + 'tpl-2', + ]); + }); + + it('terminates when the server repeats the same cursor', async () => { + const { client, resourceCursors } = fakeClient({ + resourcePages: [ + { + resources: [ + { + uri: 'res://loop', + name: 'loop', + }, + ], + nextCursor: 'stuck', + }, + { + resources: [ + { + uri: 'res://loop2', + name: 'loop2', + }, + ], + // Same cursor echoed back -> must stop, not spin forever. + nextCursor: 'stuck', + }, + ], + }); + + const result = await runListResources(client); + expect(isResourceListOutput(result)).toBe(true); + if (!isResourceListOutput(result)) { + return; + } + // Two distinct cursor values requested: undefined then 'stuck'; the + // repeated 'stuck' stops the loop. + expect(resourceCursors).toEqual([ + undefined, + 'stuck', + ]); + }); +}); diff --git a/packages/mcp/tests/unit/result-mapper.test.ts b/packages/mcp/tests/unit/result-mapper.test.ts new file mode 100644 index 0000000..e1acf60 --- /dev/null +++ b/packages/mcp/tests/unit/result-mapper.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { MCPToolCallError } from '../../src/errors.js'; +import { mapCallToolResult } from '../../src/result-mapper.js'; + +describe('mapCallToolResult', () => { + it('prefers structuredContent when present', () => { + const out = mapCallToolResult('t', { + structuredContent: { + ok: true, + count: 3, + }, + content: [ + { + type: 'text', + text: 'ignored', + }, + ], + }); + expect(out).toEqual({ + ok: true, + count: 3, + }); + }); + + it('collapses text content blocks', () => { + const out = mapCallToolResult('t', { + content: [ + { + type: 'text', + text: 'line 1', + }, + { + type: 'text', + text: 'line 2', + }, + ], + }); + expect(out).toBe('line 1\nline 2'); + }); + + it('represents non-text blocks with a typed placeholder', () => { + const out = mapCallToolResult('t', { + content: [ + { + type: 'text', + text: 'see image', + }, + { + type: 'image', + data: 'base64', + mimeType: 'image/png', + }, + ], + }); + expect(out).toBe('see image\n[image content]'); + }); + + it('throws MCPToolCallError when isError is true', () => { + expect(() => + mapCallToolResult('my_tool', { + isError: true, + content: [ + { + type: 'text', + text: 'boom', + }, + ], + }), + ).toThrow(MCPToolCallError); + }); + + it('throws with a default message when error content is empty', () => { + expect(() => + mapCallToolResult('my_tool', { + isError: true, + }), + ).toThrow('MCP tool returned an error'); + }); +}); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..7da303d --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "esm", + // Resolve dependencies via their published exports (built JS + d.ts) rather + // than the repo-wide "source" condition. The MCP SDK transitively exposes a + // "source" condition that points at raw .ts files (eventsource), which would + // otherwise be type-checked. @openrouter/agent is built first by turbo, so it + // resolves cleanly through its normal exports. + "customConditions": [] + }, + "include": ["src"], + "exclude": ["node_modules", "esm"] +} diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts new file mode 100644 index 0000000..12fc72d --- /dev/null +++ b/packages/mcp/vitest.config.ts @@ -0,0 +1,39 @@ +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/**/*.test.ts'], + testTimeout: 10000, + 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 4963f56..dd31173 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,18 @@ importers: specifier: ^4.0.0 version: 4.3.6 + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + '@openrouter/agent': + specifier: workspace:* + version: link:../agent + zod: + specifier: ^4.0.0 + version: 4.3.6 + packages: '@babel/runtime@7.29.2': @@ -321,6 +333,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -339,6 +357,16 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -553,6 +581,21 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -579,14 +622,30 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -598,6 +657,30 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -618,6 +701,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -634,18 +721,44 @@ packages: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -654,17 +767,45 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -681,10 +822,22 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -698,6 +851,17 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -706,9 +870,29 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -726,6 +910,17 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -738,6 +933,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -749,6 +947,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -760,6 +961,12 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -776,6 +983,18 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -784,6 +1003,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -796,6 +1023,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -805,6 +1036,21 @@ packages: encoding: optional: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -831,6 +1077,10 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -839,6 +1089,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -865,6 +1118,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -874,16 +1131,36 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -897,6 +1174,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -908,6 +1189,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -916,6 +1208,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -940,6 +1248,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -984,6 +1296,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -991,6 +1307,10 @@ packages: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -1003,6 +1323,14 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1092,6 +1420,14 @@ packages: engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -1370,6 +1706,10 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@hono/node-server@1.19.14(hono@4.12.27)': + dependencies: + hono: 4.12.27 + '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: chardet: 2.1.1 @@ -1395,6 +1735,28 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1561,6 +1923,22 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -1579,12 +1957,38 @@ snapshots: dependencies: is-windows: 1.0.2 + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + braces@3.0.3: dependencies: fill-range: 7.1.1 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1597,6 +2001,21 @@ snapshots: check-error@2.1.3: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1611,6 +2030,8 @@ snapshots: deep-eql@5.0.2: {} + depd@2.0.0: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: @@ -1621,13 +2042,31 @@ snapshots: dotenv@8.6.0: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -1657,16 +2096,66 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + escape-html@1.0.3: {} + esprima@4.0.1: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1675,6 +2164,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.2: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -1687,11 +2178,26 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 path-exists: 4.0.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -1707,6 +2213,26 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1720,8 +2246,26 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.27: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-id@4.1.3: {} husky@9.1.7: {} @@ -1732,6 +2276,12 @@ snapshots: ignore@5.3.2: {} + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1740,6 +2290,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -1748,6 +2300,8 @@ snapshots: isexe@2.0.0: {} + jose@6.2.3: {} + js-tokens@9.0.1: {} js-yaml@3.14.2: @@ -1759,6 +2313,10 @@ snapshots: dependencies: argparse: 2.0.1 + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -1775,6 +2333,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -1782,16 +2346,36 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mri@1.2.0: {} ms@2.1.3: {} nanoid@3.3.11: {} + negotiator@1.0.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + outdent@0.5.0: {} p-filter@2.1.0: @@ -1814,10 +2398,14 @@ snapshots: dependencies: quansync: 0.2.11 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -1832,6 +2420,8 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -1840,10 +2430,29 @@ snapshots: prettier@2.8.8: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.11: {} queue-microtask@1.2.3: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -1851,6 +2460,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} reusify@1.1.0: {} @@ -1886,6 +2497,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -1894,12 +2515,67 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -1917,6 +2593,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} strip-ansi@6.0.1: @@ -1950,6 +2628,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tr46@0.0.3: {} turbo@2.9.6: @@ -1961,12 +2641,22 @@ snapshots: '@turbo/windows-64': 2.9.6 '@turbo/windows-arm64': 2.9.6 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.8.3: {} undici-types@6.21.0: {} universalify@0.1.2: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.19.15): dependencies: cac: 6.7.14 @@ -2057,4 +2747,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.3.6: {}