fix(mcp): honor callback port in debug (#39259)
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user