Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/mcp-result-discrimination.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/openrouter-mcp-initial.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -147,6 +147,7 @@ export type {
InferToolOutput,
InferToolOutputsUnion,
ManualTool,
McpBranded,
NextTurnParamsContext,
NextTurnParamsFunctions,
ParsedToolCall,
Expand Down Expand Up @@ -192,6 +193,7 @@ export {
isGeneratorTool,
isHITLTool,
isManualTool,
isMcpTool,
isRegularExecuteTool,
isServerTool,
isToolCallOutputEvent,
Expand Down
62 changes: 46 additions & 16 deletions packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import type {
import {
isAutoResolvableTool,
isClientTool,
isMcpTool,
isServerTool,
isToolCallOutputEvent,
} from './tool-types.js';
Expand Down Expand Up @@ -200,6 +201,7 @@ export class ModelResult<
| {
type: 'tool_result';
toolCallId: string;
source: 'client' | 'mcp';
result: InferToolOutputsUnion<TTools>;
preliminaryResults?: InferToolEventsUnion<TTools>[];
}
Expand Down Expand Up @@ -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<TTools>,
preliminaryResults?: InferToolEventsUnion<TTools>[],
): void {
this.toolEventBroadcaster?.push({
type: 'tool_result' as const,
toolCallId,
source,
result,
...(preliminaryResults?.length && {
preliminaryResults,
Expand All @@ -388,6 +402,7 @@ export class ModelResult<
this.turnBroadcaster?.push({
type: 'tool.result' as const,
toolCallId,
source,
result,
timestamp: Date.now(),
...(preliminaryResults?.length && {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<TTools>);

Expand Down Expand Up @@ -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<TTools>);
this.broadcastToolResult(
originalToolCall.id,
this.toolSourceByName(originalToolCall.name),
{
error: errorMessage,
} as InferToolOutputsUnion<TTools>,
);

const rejectedOutput: models.FunctionCallOutputItem = {
type: 'function_call_output' as const,
Expand Down Expand Up @@ -1017,6 +1046,7 @@ export class ModelResult<
) as InferToolOutputsUnion<TTools>;
this.broadcastToolResult(
value.toolCall.id,
isMcpTool(value.tool) ? 'mcp' : 'client',
toolResult,
value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined,
);
Expand Down Expand Up @@ -2131,7 +2161,7 @@ export class ModelResult<
getFullResponsesStream(): AsyncIterableIterator<
ResponseStreamEvent<InferToolEventsUnion<TTools>, InferToolOutputsUnion<TTools>>
> {
return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down Expand Up @@ -2164,7 +2194,7 @@ export class ModelResult<
* including text from follow-up responses in multi-turn tool loops.
*/
getTextStream(): AsyncIterableIterator<string> {
return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down Expand Up @@ -2233,7 +2263,7 @@ export class ModelResult<
return false;
};

return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down Expand Up @@ -2402,7 +2432,7 @@ export class ModelResult<
getNewMessagesStream(): AsyncIterableIterator<
models.OutputMessage | models.FunctionCallOutputItem | models.OutputFunctionCallItem
> {
return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down Expand Up @@ -2470,7 +2500,7 @@ export class ModelResult<
* including reasoning from follow-up responses in multi-turn tool loops.
*/
getReasoningStream(): AsyncIterableIterator<string> {
return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down Expand Up @@ -2503,7 +2533,7 @@ export class ModelResult<
* - Preliminary results as { type: "preliminary_result", toolCallId, result }
*/
getToolStream(): AsyncIterableIterator<ToolStreamEvent<InferToolEventsUnion<TTools>>> {
return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down Expand Up @@ -2584,7 +2614,7 @@ export class ModelResult<
* Each iteration yields a complete tool call with parsed arguments.
*/
getToolCallsStream(): AsyncIterableIterator<ParsedToolCall<TTools[number]>> {
return async function* (this: ModelResult<TTools>) {
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStream();

if (!this.reusableStream && !this.finalResponse) {
Expand Down
14 changes: 14 additions & 0 deletions packages/agent/src/lib/tool-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
hasExecuteFunction,
isGeneratorTool,
isHITLTool,
isMcpTool,
isRegularExecuteTool,
isServerTool,
} from './tool-types.js';
Expand Down Expand Up @@ -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);
Expand All @@ -222,19 +225,22 @@ export async function executeRegularTool(
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
source,
result: validatedOutput,
};
}

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)),
};
Expand All @@ -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);
Expand Down Expand Up @@ -312,13 +320,15 @@ export async function executeGeneratorTool(
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
source,
result: finalResult,
preliminaryResults,
};
} catch (error) {
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
source,
result: null,
error: error instanceof Error ? error : new Error(String(error)),
};
Expand All @@ -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);
Expand All @@ -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)),
};
Expand Down
5 changes: 4 additions & 1 deletion packages/agent/src/lib/tool-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<Tool>;
Expand Down Expand Up @@ -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)),
Expand Down
Loading
Loading