fix: normalize MCP tool schemas missing properties field for OpenAI Responses API

Tools with no parameters produce { type: "object" } schemas without a
properties field. The OpenAI Responses API rejects these, silently
crashing entire sessions.

Add properties: {} injection in normalizeToolParameters() and
convertTools() to ensure all object-type schemas include a properties
field.

Closes #58246
This commit is contained in:
yelog
2026-03-31 18:19:33 +08:00
committed by Peter Steinberger
parent fcb802e826
commit dd3796aef3
4 changed files with 109 additions and 6 deletions

View File

@@ -276,12 +276,21 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[]
if (!tools || tools.length === 0) {
return [];
}
return tools.map((tool) => ({
type: "function" as const,
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
}));
return tools.map((tool) => {
const params = (tool.parameters ?? {}) as Record<string, unknown>;
// Ensure `type: "object"` schemas include `properties` — the OpenAI Responses
// API rejects bare `{ type: "object" }` from MCP tools with no parameters.
const normalizedParams =
params.type === "object" && !("properties" in params)
? { ...params, properties: {} }
: params;
return {
type: "function" as const,
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
parameters: normalizedParams,
};
});
}
export function planTurnInput(params: {

View File

@@ -354,6 +354,35 @@ describe("convertTools", () => {
const result = convertTools(tools as Parameters<typeof convertTools>[0]);
expect(result[0]?.name).toBe("ping");
});
it("injects properties:{} for type:object schemas missing properties (MCP no-param tools)", () => {
const tools = [
{ name: "list_regions", description: "List AWS regions", parameters: { type: "object" } },
];
const result = convertTools(tools as unknown as Parameters<typeof convertTools>[0]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "function",
name: "list_regions",
description: "List AWS regions",
parameters: { type: "object", properties: {} },
});
});
it("preserves existing properties on type:object schemas", () => {
const tools = [
{
name: "exec",
description: "Run a command",
parameters: { type: "object", properties: { cmd: { type: "string" } } },
},
];
const result = convertTools(tools as unknown as Parameters<typeof convertTools>[0]);
expect(result[0]?.parameters).toEqual({
type: "object",
properties: { cmd: { type: "string" } },
});
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -4,6 +4,55 @@ import { normalizeToolParameters } from "./pi-tools.schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
describe("normalizeToolParameters", () => {
it("injects properties:{} for type:object schemas missing properties (MCP no-param tools)", () => {
const tool: AnyAgentTool = {
name: "list_regions",
label: "list_regions",
description: "List all AWS regions",
parameters: { type: "object" },
execute: vi.fn(),
};
const normalized = normalizeToolParameters(tool);
const parameters = normalized.parameters as Record<string, unknown>;
expect(parameters.type).toBe("object");
expect(parameters.properties).toEqual({});
});
it("preserves existing properties on type:object schemas", () => {
const tool: AnyAgentTool = {
name: "query",
label: "query",
description: "Run a query",
parameters: { type: "object", properties: { q: { type: "string" } } },
execute: vi.fn(),
};
const normalized = normalizeToolParameters(tool);
const parameters = normalized.parameters as Record<string, unknown>;
expect(parameters.type).toBe("object");
expect(parameters.properties).toEqual({ q: { type: "string" } });
});
it("injects properties:{} for type:object with only additionalProperties", () => {
const tool: AnyAgentTool = {
name: "passthrough",
label: "passthrough",
description: "Accept any input",
parameters: { type: "object", additionalProperties: true },
execute: vi.fn(),
};
const normalized = normalizeToolParameters(tool);
const parameters = normalized.parameters as Record<string, unknown>;
expect(parameters.type).toBe("object");
expect(parameters.properties).toEqual({});
expect(parameters.additionalProperties).toBe(true);
});
it("strips compat-declared unsupported schema keywords without provider-specific branching", () => {
const tool: AnyAgentTool = {
name: "demo",

View File

@@ -133,6 +133,22 @@ export function normalizeToolParameters(
});
}
// MCP tools with no parameters produce `{ type: "object" }` without `properties`.
// The OpenAI function-calling API rejects bare `{ type: "object" }` schemas.
// Inject an empty `properties` object — semantically identical in JSON Schema.
if (
"type" in schema &&
!("properties" in schema) &&
!Array.isArray(schema.anyOf) &&
!Array.isArray(schema.oneOf)
) {
const schemaWithProperties = { ...schema, properties: {} };
return preserveToolMeta({
...tool,
parameters: applyProviderCleaning(schemaWithProperties),
});
}
const variantKey = Array.isArray(schema.anyOf)
? "anyOf"
: Array.isArray(schema.oneOf)