fix: normalize raw MCP schemas for OpenAI Responses (#58299) (thanks @yelog)

This commit is contained in:
Peter Steinberger 2026-03-31 18:29:37 +01:00
parent dd3796aef3
commit b4433a1bfe
4 changed files with 115 additions and 70 deletions

View File

@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/OpenAI Responses: normalize raw bundled MCP tool schemas on the WebSocket/Responses path so bare-object, object-ish, and top-level union MCP tools no longer get rejected by OpenAI during tool registration. (#58299) Thanks @yelog.
- ACP/security: replace ACP's dangerous-tool name override with semantic approval classes, so only narrow readonly reads/searches can auto-approve while indirect exec-capable and control-plane tools always require explicit prompt approval. Thanks @vincentkoc.
- ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.
- ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success.

View File

@ -8,6 +8,7 @@ import type {
OpenAIResponsesAssistantPhase,
ResponseObject,
} from "./openai-ws-connection.js";
import { normalizeToolParameterSchema } from "./pi-tools.schema.js";
import { buildAssistantMessage, buildUsageWithNoCost } from "./stream-message-shared.js";
type AnyMessage = Message & { role: string; content: unknown };
@ -277,18 +278,11 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[]
return [];
}
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,
parameters: normalizeToolParameterSchema(tool.parameters ?? {}) as Record<string, unknown>,
};
});
}

View File

@ -369,6 +369,58 @@ describe("convertTools", () => {
});
});
it("adds missing top-level type for raw object-ish MCP schemas", () => {
const tools = [
{
name: "query",
description: "Run a query",
parameters: { properties: { q: { type: "string" } }, required: ["q"] },
},
];
const result = convertTools(tools as unknown as Parameters<typeof convertTools>[0]);
expect(result[0]?.parameters).toEqual({
type: "object",
properties: { q: { type: "string" } },
required: ["q"],
});
});
it("flattens raw top-level anyOf MCP schemas into one object schema", () => {
const tools = [
{
name: "dispatch",
description: "Dispatch an action",
parameters: {
anyOf: [
{
type: "object",
properties: { action: { const: "ping" } },
required: ["action"],
},
{
type: "object",
properties: {
action: { const: "echo" },
text: { type: "string" },
},
required: ["action", "text"],
},
],
},
},
];
const result = convertTools(tools as unknown as Parameters<typeof convertTools>[0]);
expect(result[0]?.parameters).toEqual({
type: "object",
properties: {
action: { type: "string", enum: ["ping", "echo"] },
text: { type: "string" },
},
required: ["action"],
additionalProperties: true,
});
});
it("preserves existing properties on type:object schemas", () => {
const tools = [
{

View File

@ -67,21 +67,14 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
return existing;
}
export function normalizeToolParameters(
tool: AnyAgentTool,
export function normalizeToolParameterSchema(
schema: unknown,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): AnyAgentTool {
function preserveToolMeta(target: AnyAgentTool): AnyAgentTool {
copyPluginToolMeta(tool, target);
copyChannelAgentToolMeta(tool as never, target as never);
return target;
}
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema) {
return tool;
): unknown {
const schemaRecord =
schema && typeof schema === "object" ? (schema as Record<string, unknown>) : undefined;
if (!schemaRecord) {
return schema;
}
// Provider quirks:
@ -92,7 +85,6 @@ export function normalizeToolParameters(
// - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright.
//
// Normalize once here so callers can always pass `tools` through unchanged.
const isGeminiProvider =
options?.modelProvider?.toLowerCase().includes("google") ||
options?.modelProvider?.toLowerCase().includes("gemini");
@ -109,55 +101,41 @@ export function normalizeToolParameters(
return s;
}
// If schema already has type + properties (no top-level anyOf to merge),
// clean it for Gemini/xAI compatibility as appropriate.
if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) {
return preserveToolMeta({
...tool,
parameters: applyProviderCleaning(schema),
});
}
// Some tool schemas (esp. unions) may omit `type` at the top-level. If we see
// object-ish fields, force `type: "object"` so OpenAI accepts the schema.
if (
!("type" in schema) &&
(typeof schema.properties === "object" || Array.isArray(schema.required)) &&
!Array.isArray(schema.anyOf) &&
!Array.isArray(schema.oneOf)
"type" in schemaRecord &&
"properties" in schemaRecord &&
!Array.isArray(schemaRecord.anyOf)
) {
const schemaWithType = { ...schema, type: "object" };
return preserveToolMeta({
...tool,
parameters: applyProviderCleaning(schemaWithType),
});
return applyProviderCleaning(schemaRecord);
}
// 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)
!("type" in schemaRecord) &&
(typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) &&
!Array.isArray(schemaRecord.anyOf) &&
!Array.isArray(schemaRecord.oneOf)
) {
const schemaWithProperties = { ...schema, properties: {} };
return preserveToolMeta({
...tool,
parameters: applyProviderCleaning(schemaWithProperties),
});
return applyProviderCleaning({ ...schemaRecord, type: "object" });
}
const variantKey = Array.isArray(schema.anyOf)
if (
"type" in schemaRecord &&
!("properties" in schemaRecord) &&
!Array.isArray(schemaRecord.anyOf) &&
!Array.isArray(schemaRecord.oneOf)
) {
return applyProviderCleaning({ ...schemaRecord, properties: {} });
}
const variantKey = Array.isArray(schemaRecord.anyOf)
? "anyOf"
: Array.isArray(schema.oneOf)
: Array.isArray(schemaRecord.oneOf)
? "oneOf"
: null;
if (!variantKey) {
return tool;
return schema;
}
const variants = schema[variantKey] as unknown[];
const variants = schemaRecord[variantKey] as unknown[];
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
let objectVariants = 0;
@ -189,8 +167,8 @@ export function normalizeToolParameters(
}
}
const baseRequired = Array.isArray(schema.required)
? schema.required.filter((key) => typeof key === "string")
const baseRequired = Array.isArray(schemaRecord.required)
? schemaRecord.required.filter((key) => typeof key === "string")
: undefined;
const mergedRequired =
baseRequired && baseRequired.length > 0
@ -201,25 +179,45 @@ export function normalizeToolParameters(
.map(([key]) => key)
: undefined;
const nextSchema: Record<string, unknown> = { ...schema };
const nextSchema: Record<string, unknown> = { ...schemaRecord };
const flattenedSchema = {
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schema.properties ?? {}),
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true,
additionalProperties:
"additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true,
};
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
return applyProviderCleaning(flattenedSchema);
}
export function normalizeToolParameters(
tool: AnyAgentTool,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): AnyAgentTool {
function preserveToolMeta(target: AnyAgentTool): AnyAgentTool {
copyPluginToolMeta(tool, target);
copyChannelAgentToolMeta(tool as never, target as never);
return target;
}
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema) {
return tool;
}
return preserveToolMeta({
...tool,
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
parameters: applyProviderCleaning(flattenedSchema),
parameters: normalizeToolParameterSchema(schema, options),
});
}