fix(mcp): normalize invalid object tool properties

This commit is contained in:
Peter Steinberger
2026-05-02 09:47:32 +01:00
parent ea26bdf07d
commit 54d82b3055
4 changed files with 66 additions and 27 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987.
- MCP/OpenAI: normalize parameter-free tool schemas whose top-level object `properties` is missing, null, or invalid before sending tools to OpenAI, so MCP tools without params stay usable. Fixes #75362. Thanks @tolkonepiu and @SymbolStar.
- TTS: honor explicit short `[[tts:text]]...[[/tts:text]]` blocks while keeping untagged short auto-TTS suppressed, so tagged voice replies are synthesized instead of being dropped as empty voice-only payloads. Fixes #73758. Thanks @yfge.
- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5.
- Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.
@@ -200,7 +201,6 @@ Docs: https://docs.openclaw.ai
- Plugins/hooks: derive hook `ctx.channelId` from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels.
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
- Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.
- Agents/OpenAI: normalize parameter-free MCP tool schemas whose `properties` value is null or undefined, so OpenAI no longer rejects MCP tools without parameters. Fixes #75362. (#75401) Thanks @SymbolStar.
- Gateway/agents: avoid rebuilding core tools for plugin-only allowlists and keep the full plugin registry cache warm across scoped plugin loads, reducing per-turn latency spikes. Fixes #75882, #75907, #75906, #75887, and #75851. (#75922) Thanks @obviyus.
## 2026.4.30

View File

@@ -6,6 +6,29 @@ import {
} from "./openai-tool-schema.js";
describe("OpenAI strict tool schema normalization", () => {
it("repairs top-level object schemas with missing or invalid properties", () => {
const schemas = [
{ type: "object" },
{ type: "object", properties: undefined },
{ type: "object", properties: null },
{ type: "object", properties: [] },
{ type: "object", properties: "invalid" },
];
for (const schema of schemas) {
expect(normalizeStrictOpenAIJsonSchema(schema)).toEqual({
type: "object",
properties: {},
required: [],
additionalProperties: false,
});
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(true);
expect(
resolveOpenAIStrictToolFlagForInventory([{ name: "empty", parameters: schema }], true),
).toBe(true);
}
});
it("does not close permissive nested object schemas implicitly", () => {
const schema = {
type: "object",
@@ -30,16 +53,6 @@ describe("OpenAI strict tool schema normalization", () => {
).toBe(false);
});
it("normalizes parameter-free MCP tool schema with properties:undefined (#75362)", () => {
const schema = { type: "object", properties: undefined } as unknown;
const normalized = normalizeStrictOpenAIJsonSchema(schema) as Record<string, unknown>;
expect(normalized.type).toBe("object");
expect(normalized.properties).toEqual({});
expect(normalized.required).toEqual([]);
expect(normalized.additionalProperties).toBe(false);
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(true);
});
it("normalizes truly empty MCP tool schema {} for strict mode", () => {
const schema = {};
const normalized = normalizeStrictOpenAIJsonSchema(schema) as Record<string, unknown>;

View File

@@ -75,6 +75,10 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
type FlattenableVariantKey = "anyOf" | "oneOf";
type TopLevelConditionalKey = FlattenableVariantKey | "allOf";
function isSchemaRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function hasTopLevelArrayKeyword(
schemaRecord: Record<string, unknown>,
key: TopLevelConditionalKey,
@@ -108,10 +112,8 @@ function hasTopLevelObjectSchema(
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
"type" in schemaRecord &&
"properties" in schemaRecord &&
schemaRecord.properties != null &&
typeof schemaRecord.properties === "object" &&
schemaRecord.type === "object" &&
isSchemaRecord(schemaRecord.properties) &&
conditionalKey === null
);
}
@@ -122,23 +124,20 @@ function isObjectLikeSchemaMissingType(
): boolean {
return (
!("type" in schemaRecord) &&
(typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) &&
(isSchemaRecord(schemaRecord.properties) || Array.isArray(schemaRecord.required)) &&
conditionalKey === null
);
}
function isTypedSchemaMissingProperties(
function isTypedObjectSchemaMissingValidProperties(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
if (!("type" in schemaRecord) || conditionalKey !== null) {
return false;
}
if (!("properties" in schemaRecord)) {
return true;
}
const props = schemaRecord.properties;
return props == null || typeof props !== "object" || Array.isArray(props);
return (
schemaRecord.type === "object" &&
!isSchemaRecord(schemaRecord.properties) &&
conditionalKey === null
);
}
function isTrulyEmptySchema(schemaRecord: Record<string, unknown>): boolean {
@@ -187,10 +186,14 @@ export function normalizeToolParameterSchema(
}
if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, type: "object" });
return applyProviderCleaning({
...schemaRecord,
type: "object",
properties: isSchemaRecord(schemaRecord.properties) ? schemaRecord.properties : {},
});
}
if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) {
if (isTypedObjectSchemaMissingValidProperties(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, properties: {} });
}

View File

@@ -48,6 +48,29 @@ describe("normalizeToolParameterSchema", () => {
});
});
it("normalizes typed object schemas with missing or invalid properties", () => {
const schemas = [
{ type: "object" },
{ type: "object", properties: undefined },
{ type: "object", properties: null },
{ type: "object", properties: [] },
{ type: "object", properties: "invalid" },
];
for (const schema of schemas) {
expect(normalizeToolParameterSchema(schema)).toEqual({
type: "object",
properties: {},
});
}
});
it("leaves non-object typed schemas without properties unchanged", () => {
const schema = { type: "array", items: { type: "string" } };
expect(normalizeToolParameterSchema(schema)).toEqual(schema);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",