mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(gateway): preserve MCP literal union schemas (#108054)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user