fix(mcp): skip unreadable plugin tools

This commit is contained in:
Vincent Koc
2026-06-04 17:17:12 +02:00
parent 4c32553875
commit 74b2d2ff4d
2 changed files with 150 additions and 18 deletions

View File

@@ -12,34 +12,95 @@ type CallPluginToolParams = {
arguments?: unknown;
};
function resolveJsonSchemaForTool(tool: AnyAgentTool): Record<string, unknown> {
const params = tool.parameters;
if (params && typeof params === "object" && "type" in params) {
return params as Record<string, unknown>;
type PluginMcpToolEntry = {
description: string;
inputSchema: Record<string, unknown>;
name: string;
tool: AnyAgentTool;
};
function readToolName(tool: AnyAgentTool): string | undefined {
try {
const value = tool.name;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
} catch {
return undefined;
}
return { type: "object", properties: {} };
}
function readToolDescription(tool: AnyAgentTool): string {
try {
return typeof tool.description === "string" ? tool.description : "";
} catch {
return "";
}
}
function readToolParameters(tool: AnyAgentTool): { ok: true; value: unknown } | { ok: false } {
try {
return { ok: true, value: tool.parameters };
} catch {
return { ok: false };
}
}
function resolveJsonSchemaForTool(
tool: AnyAgentTool,
): { ok: true; schema: Record<string, unknown> } | { ok: false } {
const params = readToolParameters(tool);
if (!params.ok) {
return { ok: false };
}
try {
if (params.value && typeof params.value === "object" && "type" in params.value) {
return { ok: true, schema: params.value as Record<string, unknown> };
}
} catch {
return { ok: false };
}
return { ok: true, schema: { type: "object", properties: {} } };
}
export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) {
const wrappedTools = tools.map((tool) => {
if (isToolWrappedWithBeforeToolCallHook(tool)) {
return rewrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
const toolEntries: PluginMcpToolEntry[] = [];
for (const tool of tools) {
const name = readToolName(tool);
if (!name) {
continue;
}
// The ACPX MCP bridge should enforce the same pre-execution hook boundary
// as the agent and HTTP tool execution paths.
return wrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
});
const inputSchema = resolveJsonSchemaForTool(tool);
if (!inputSchema.ok) {
continue;
}
const description = readToolDescription(tool);
let wrappedTool: AnyAgentTool;
try {
// The ACPX MCP bridge should enforce the same pre-execution hook boundary
// as the agent and HTTP tool execution paths.
wrappedTool = isToolWrappedWithBeforeToolCallHook(tool)
? rewrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" })
: wrapToolWithBeforeToolCallHook(tool, undefined, { approvalMode: "report" });
} catch {
continue;
}
toolEntries.push({
description,
inputSchema: inputSchema.schema,
name,
tool: wrappedTool,
});
}
const toolMap = new Map<string, AnyAgentTool>();
for (const tool of wrappedTools) {
toolMap.set(tool.name, tool);
for (const entry of toolEntries) {
toolMap.set(entry.name, entry.tool);
}
return {
listTools: async () => ({
tools: wrappedTools.map((tool) => ({
name: tool.name,
description: tool.description ?? "",
inputSchema: resolveJsonSchemaForTool(tool),
tools: toolEntries.map((entry) => ({
name: entry.name,
description: entry.description,
inputSchema: entry.inputSchema,
})),
}),
callTool: async (params: CallPluginToolParams, signal?: AbortSignal) => {

View File

@@ -174,6 +174,77 @@ describe("plugin tools MCP server", () => {
expect(result.content).toEqual([{ type: "text", text: "Stored." }]);
});
it("skips unreadable plugin tool descriptors while keeping healthy MCP siblings", async () => {
const healthyExecute = vi.fn().mockResolvedValue({ content: "ok" });
const unreadableNameTool = Object.defineProperties(
{},
{
name: {
get() {
throw new Error("name unavailable");
},
},
description: { value: "Unreadable name" },
parameters: { value: { type: "object", properties: {} } },
execute: { value: vi.fn() },
},
);
const unreadableParametersTool = Object.defineProperties(
{},
{
name: { value: "broken_parameters" },
description: { value: "Unreadable parameters" },
parameters: {
get() {
throw new Error("parameters unavailable");
},
},
execute: { value: vi.fn() },
},
);
const revoked = Proxy.revocable({}, {});
revoked.revoke();
const handlers = createPluginToolsMcpHandlers([
unreadableNameTool,
unreadableParametersTool,
{
name: "revoked_schema",
description: "Revoked schema",
parameters: revoked.proxy,
execute: vi.fn(),
},
{
name: "healthy_tool",
description: "Healthy tool",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
},
execute: healthyExecute,
},
] as unknown as AnyAgentTool[]);
const listed = await handlers.listTools();
expect(listed.tools.map((tool) => tool.name)).toEqual(["healthy_tool"]);
expect(listed.tools[0]?.inputSchema).toMatchObject({
type: "object",
properties: {
query: { type: "string" },
},
});
const result = await handlers.callTool({
name: "healthy_tool",
arguments: { query: "ok" },
});
expect(result.content).toEqual([{ type: "text", text: "ok" }]);
expect(healthyExecute).toHaveBeenCalledTimes(1);
});
it("serializes plugin tool results that do not use the MCP content envelope", async () => {
const execute = vi.fn().mockResolvedValue({
provider: "kitchen-sink-search",