fix(gateway): preserve MCP literal union schemas (#108054)

This commit is contained in:
Peter Steinberger
2026-07-14 23:17:42 -07:00
committed by GitHub
parent d5ece3b2d7
commit bda5288365
2 changed files with 191 additions and 13 deletions
+70 -13
View File
@@ -1,7 +1,7 @@
// MCP loopback tool schema projection.
// Converts gateway-scoped tools into MCP tools/list-compatible schemas.
import { isDeepStrictEqual } from "node:util";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { logWarn } from "../logger.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
@@ -57,6 +57,69 @@ function readLoopbackToolParameters(tool: McpLoopbackTool): Record<string, unkno
}
}
function readLiteralSchemaValues(schema: Record<string, unknown>): unknown[] | undefined {
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
if (Object.hasOwn(schema, "const")) {
if (!enumValues) {
return [schema.const];
}
return enumValues.some((value) => isDeepStrictEqual(value, schema.const)) ? [schema.const] : [];
}
return enumValues;
}
function uniqueLiteralValues(values: unknown[]): unknown[] {
return values.filter(
(value, index) =>
values.findIndex((candidate) => isDeepStrictEqual(candidate, value)) === index,
);
}
const SCHEMA_ANNOTATION_KEYS = new Set([
"$comment",
"default",
"deprecated",
"description",
"example",
"examples",
"readOnly",
"title",
"writeOnly",
]);
function readLiteralValidationConstraints(
schema: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(schema).filter(
([key]) => key !== "const" && key !== "enum" && !SCHEMA_ANNOTATION_KEYS.has(key),
),
);
}
function mergeLiteralSchemas(
existing: Record<string, unknown>,
incoming: Record<string, unknown>,
): Record<string, unknown> | undefined {
const existingValues = readLiteralSchemaValues(existing);
const incomingValues = readLiteralSchemaValues(incoming);
if (existingValues === undefined || incomingValues === undefined) {
return undefined;
}
const existingConstraints = readLiteralValidationConstraints(existing);
const incomingConstraints = readLiteralValidationConstraints(incoming);
if (!isDeepStrictEqual(existingConstraints, incomingConstraints)) {
return undefined;
}
const values = uniqueLiteralValues([...existingValues, ...incomingValues]);
if (values.length === 0) {
return undefined;
}
const merged: Record<string, unknown> = { ...existing, enum: values };
delete merged.const;
return merged;
}
function flattenUnionSchema(
raw: Record<string, unknown>,
toolName: string,
@@ -111,20 +174,14 @@ function flattenUnionSchema(
}
continue;
}
if (Array.isArray(existing.enum) && Array.isArray(incoming.enum)) {
mergedProps[key] = {
...existing,
enum: uniqueValues([...(existing.enum as unknown[]), ...(incoming.enum as unknown[])]),
};
if (isDeepStrictEqual(existing, incoming)) {
continue;
}
if ("const" in existing && "const" in incoming && existing.const !== incoming.const) {
const merged: Record<string, unknown> = {
...existing,
enum: [existing.const, incoming.const],
};
delete merged.const;
mergedProps[key] = merged;
// A prior const merge becomes an enum. Treat both as one literal family
// so later union variants cannot silently disappear based on ordering.
const mergedLiterals = mergeLiteralSchemas(existing, incoming);
if (mergedLiterals) {
mergedProps[key] = mergedLiterals;
continue;
}
warnSchemaOnce(
+121
View File
@@ -775,6 +775,127 @@ describe("buildMcpToolSchema", () => {
}
});
it("preserves every literal across const and enum union variants", () => {
const tool = makeMockTool({
name: "codex_threads_literal_union",
parameters: {
anyOf: [
{
type: "object",
properties: {
action: {
type: "string",
const: "list",
enum: ["list", "ignored"],
description: "List threads",
},
thread_id: { type: "string" },
},
required: ["action"],
},
{
type: "object",
properties: {
action: { type: "string", const: "fork", description: "Fork a thread" },
thread_id: { type: "string" },
},
required: ["action"],
},
{
type: "object",
properties: {
action: { type: "string", const: "rename" },
thread_id: { type: "string" },
},
required: ["action"],
},
{
type: "object",
properties: {
action: { type: "string", enum: ["archive", "unarchive"] },
thread_id: { type: "string" },
},
required: ["action"],
},
],
},
});
expect(buildMockMcpToolSchema([tool])[0]?.inputSchema).toEqual({
type: "object",
properties: {
action: {
type: "string",
enum: ["list", "fork", "rename", "archive", "unarchive"],
description: "List threads",
},
thread_id: { type: "string" },
},
required: ["action"],
});
expect(logWarnMock).not.toHaveBeenCalled();
});
it("keeps the first literal schema when validation constraints differ", () => {
const tool = makeMockTool({
name: "codex_threads_constrained_literals",
parameters: {
anyOf: [
{
type: "object",
properties: {
action: { type: "string", const: "list", pattern: "^list$" },
},
},
{
type: "object",
properties: {
action: { type: "string", const: "fork", pattern: "^fork$" },
},
},
],
},
});
expect(buildMockMcpToolSchema([tool])[0]?.inputSchema).toMatchObject({
properties: {
action: { type: "string", const: "list", pattern: "^list$" },
},
});
expect(logWarnMock.mock.calls.map(([message]) => message)).toEqual([
'mcp loopback: conflicting schema definitions for "codex_threads_constrained_literals.action", keeping the first variant',
]);
});
it("does not warn for structurally identical property schemas", () => {
const tool = makeMockTool({
name: "codex_threads_identical_properties",
parameters: {
oneOf: [
{
type: "object",
properties: {
thread_id: { type: "string", description: "Thread identifier" },
},
},
{
type: "object",
properties: {
thread_id: { type: "string", description: "Thread identifier" },
},
},
],
},
});
expect(buildMockMcpToolSchema([tool])[0]?.inputSchema).toMatchObject({
properties: {
thread_id: { type: "string", description: "Thread identifier" },
},
});
expect(logWarnMock).not.toHaveBeenCalled();
});
it("warns once for repeated union schema conflicts across loopback schema rebuilds", () => {
const tool = makeMockTool({
name: "mcp_message_send_rebuild",