fix(mcp): honor callback port in debug (#39259)

This commit is contained in:
Aiden Cline
2026-07-28 00:00:38 -05:00
committed by GitHub
parent 172d08cb98
commit c3be6c4965
6 changed files with 84 additions and 55 deletions
+1
View File
@@ -737,6 +737,7 @@ export const McpDebugCommand = effectCmd({
clientId: oauthConfig?.clientId, clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret, clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope, scope: oauthConfig?.scope,
callbackPort: oauthConfig?.callbackPort,
redirectUri: oauthConfig?.redirectUri, redirectUri: oauthConfig?.redirectUri,
}, },
{ {
+35 -22
View File
@@ -1,33 +1,26 @@
import { Client, type Tool as MCPToolDef } from "@modelcontextprotocol/client" import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
import { Effect } from "effect" import { Effect } from "effect"
const DEFAULT_TIMEOUT = 30_000 const DEFAULT_TIMEOUT = 30_000
export function defs(client: Client, timeout?: number) {
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) export interface McpTool {
readonly def: MCPToolDef
readonly client: Client
readonly timeout?: number
} }
export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { export async function callTool(
const inputSchema: JSONSchema7 = { tool: McpTool,
...(mcpTool.inputSchema as JSONSchema7), args: Record<string, unknown>,
type: "object", signal?: AbortSignal,
properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"], ): Promise<CallToolResult> {
additionalProperties: false, const result = await tool.client.callTool(
} { name: tool.def.name, arguments: args },
return dynamicTool({
description: mcpTool.description ?? "",
inputSchema: jsonSchema(inputSchema),
execute: async (args: unknown, options) => {
const result = await client.callTool(
{
name: mcpTool.name,
arguments: (args || {}) as Record<string, unknown>,
},
{ {
resetTimeoutOnProgress: true, resetTimeoutOnProgress: true,
signal: options.abortSignal, signal,
timeout, timeout: tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {}, onprogress: () => {},
}, },
@@ -39,6 +32,26 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
.filter((text) => text.trim()) .filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error", .join("\n\n") || "MCP tool returned an error",
) )
return result
}
export function defs(client: Client, timeout?: number) {
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void))
}
export function convertTool(tool: McpTool): Tool {
const inputSchema: JSONSchema7 = {
...(tool.def.inputSchema as JSONSchema7),
type: "object",
properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false,
}
return dynamicTool({
description: tool.def.description ?? "",
inputSchema: jsonSchema(inputSchema),
execute: async (args: unknown, options) => {
const result = await callTool(tool, (args || {}) as Record<string, unknown>, options.abortSignal)
if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null)
return result return result
return { return {
+1 -6
View File
@@ -162,12 +162,7 @@ export interface ServerInstructions {
} }
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */ /** An MCP tool in its native shape; consumers adapt it to their own tool format. */
export interface McpTool { export type McpTool = McpCatalog.McpTool
/** Shared cached definition; consumers must copy rather than mutate it. */
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
}
export interface Interface { export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>> readonly status: () => Effect.Effect<Record<string, Status>>
+1 -1
View File
@@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
if (flags.experimentalCodeMode) return tools if (flags.experimentalCodeMode) return tools
for (const [key, entry] of Object.entries(yield* mcp.tools())) { for (const [key, entry] of Object.entries(yield* mcp.tools())) {
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout) const item = McpCatalog.convertTool(entry)
const execute = item.execute const execute = item.execute
if (!execute) continue if (!execute) continue
+1 -21
View File
@@ -145,27 +145,7 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input:
) )
const result: CallToolResult = yield* Effect.gen(function* () { const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns. return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort))
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe( }).pipe(
Effect.withSpan("Tool.execute", { Effect.withSpan("Tool.execute", {
attributes: { attributes: {
+42 -2
View File
@@ -28,7 +28,10 @@ describe("McpCatalog.convertTool", () => {
test("preserves content when structuredContent is also present", async () => { test("preserves content when structuredContent is also present", async () => {
const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }]
const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } }
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content, structuredContent }),
})
const output = await converted.execute?.({}, options) const output = await converted.execute?.({}, options)
@@ -37,7 +40,10 @@ describe("McpCatalog.convertTool", () => {
test("falls back to structuredContent only when content is absent", async () => { test("falls back to structuredContent only when content is absent", async () => {
const structuredContent = { results: [{ title: "one" }] } const structuredContent = { results: [{ title: "one" }] }
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content: [], structuredContent }),
})
const output = await converted.execute?.({}, options) const output = await converted.execute?.({}, options)
@@ -48,6 +54,40 @@ describe("McpCatalog.convertTool", () => {
}) })
}) })
describe("McpCatalog.callTool", () => {
test("forwards the request options", async () => {
const controller = new AbortController()
let request: unknown
let options: unknown
const client = {
callTool: async (input: unknown, config: unknown) => {
request = input
options = config
return { content: [] }
},
} as unknown as Client
await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal)
expect(request).toEqual({ name: "screenshot", arguments: { value: true } })
expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 })
expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function")
})
test("throws text returned by an MCP tool error", async () => {
const client = clientReturning({
isError: true,
content: [
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
})
await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond")
})
})
test("preserves output schema validation across paginated tool discovery", async () => { test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", ({ params }) => server.setRequestHandler("tools/list", ({ params }) =>