Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/mcp/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type McpToolDefinition = {
properties: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
$defs?: Record<string, unknown>;
definitions?: Record<string, unknown>;
};
};

Expand Down
111 changes: 103 additions & 8 deletions packages/core/src/mcp/mcp-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ export class McpManager {
properties: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
$defs?: Record<string, unknown>;
definitions?: Record<string, unknown>;
};
};
}> {
Expand All @@ -321,14 +323,7 @@ export class McpManager {
function: {
name: t.namespacedName,
description: this.buildMcpToolDescription(t),
parameters: {
type: "object" as const,
properties: t.definition.inputSchema.properties,
required: t.definition.inputSchema.required,
...(t.definition.inputSchema.additionalProperties !== undefined
? { additionalProperties: t.definition.inputSchema.additionalProperties }
: {}),
},
parameters: normalizeMcpInputSchema(t.definition.inputSchema),
},
}));
}
Expand Down Expand Up @@ -522,3 +517,103 @@ function fitApiToolNameWithSuffix(name: string, suffix: string): string {
function hashToolName(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 8);
}

export function normalizeMcpInputSchema(inputSchema?: McpToolDefinition["inputSchema"]): {
type: "object";
properties: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
$defs?: Record<string, unknown>;
definitions?: Record<string, unknown>;
} {
if (!inputSchema || typeof inputSchema !== "object") {
return { type: "object", properties: {} };
}

const defs: Record<string, unknown> = {
...(isPlainObject(inputSchema.definitions) ? inputSchema.definitions : {}),
...(isPlainObject(inputSchema.$defs) ? inputSchema.$defs : {}),
};

let hasUnresolvedRef = false;

const deref = (node: unknown, seen: ReadonlySet<string> = new Set()): unknown => {
if (Array.isArray(node)) {
return node.map((item) => deref(item, seen));
}
if (!isPlainObject(node)) {
return node;
}

if (typeof node.$ref === "string") {
const ref = node.$ref;
let defKey: string | null = null;
if (ref.startsWith("#/$defs/")) {
defKey = ref.slice("#/$defs/".length);
} else if (ref.startsWith("#/definitions/")) {
defKey = ref.slice("#/definitions/".length);
}

if (defKey && Object.prototype.hasOwnProperty.call(defs, defKey) && !seen.has(defKey)) {
const nextSeen = new Set(seen);
nextSeen.add(defKey);
const resolvedTarget = deref(defs[defKey], nextSeen);
const rest = { ...node };
delete rest.$ref;
const resolvedRest = deref(rest, seen);
if (isPlainObject(resolvedTarget) && isPlainObject(resolvedRest)) {
return { ...resolvedTarget, ...resolvedRest };
}
}

hasUnresolvedRef = true;
}

const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node)) {
out[key] = deref(value, seen);
}

if (Array.isArray(out.allOf) && out.allOf.length === 1 && isPlainObject(out.allOf[0])) {
const { allOf, ...rest } = out;
return { ...allOf[0], ...rest };
}

return out;
};

const resolvedProperties = isPlainObject(inputSchema.properties)
? (deref(inputSchema.properties) as Record<string, unknown>)
: {};

const result: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
$defs?: Record<string, unknown>;
definitions?: Record<string, unknown>;
} = {
type: "object",
properties: resolvedProperties,
...(inputSchema.required !== undefined ? { required: inputSchema.required } : {}),
...(inputSchema.additionalProperties !== undefined
? { additionalProperties: inputSchema.additionalProperties }
: {}),
};

if (hasUnresolvedRef) {
if (isPlainObject(inputSchema.$defs)) {
result.$defs = inputSchema.$defs;
}
if (isPlainObject(inputSchema.definitions)) {
result.definitions = inputSchema.definitions;
}
}

return result;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
2 changes: 2 additions & 0 deletions packages/core/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ export type ToolDefinition = {
properties: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
$defs?: Record<string, unknown>;
definitions?: Record<string, unknown>;
};
};
};
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/tests/mcp-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { McpClient, createMcpSpawnSpec } from "../mcp/mcp-client";
import { normalizeMcpInputSchema } from "../mcp/mcp-manager";

test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => {
assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "darwin"), {
Expand Down Expand Up @@ -108,3 +109,94 @@ test("McpClient starts a PATH-resolved cmd MCP server on Windows", { skip: proce
rmSync(serverDir, { recursive: true, force: true });
}
});

test("normalizeMcpInputSchema inlines $defs and definitions $ref pointers", () => {
const schema = normalizeMcpInputSchema({
type: "object",
properties: {
body: {
$ref: "#/$defs/CreateDelegationRequest",
type: "object",
},
tier: {
allOf: [{ $ref: "#/definitions/VolumeType" }],
description: "Storage tier override.",
},
},
required: ["body"],
$defs: {
CreateDelegationRequest: {
type: "object",
additionalProperties: false,
required: ["resource"],
properties: {
resource: {
type: "string",
description: "ECR resource ARN",
},
},
},
},
definitions: {
VolumeType: {
type: "string",
description: "Default volume type.",
enum: ["STANDARD", "HIGH_PERFORMANCE"],
},
},
});

assert.deepEqual(schema, {
type: "object",
properties: {
body: {
type: "object",
additionalProperties: false,
required: ["resource"],
properties: {
resource: {
type: "string",
description: "ECR resource ARN",
},
},
},
tier: {
type: "string",
description: "Storage tier override.",
enum: ["STANDARD", "HIGH_PERFORMANCE"],
},
},
required: ["body"],
});
});

test("normalizeMcpInputSchema preserves $defs when cyclic $ref cannot be fully inlined", () => {
const schema = normalizeMcpInputSchema({
type: "object",
properties: {
node: {
$ref: "#/$defs/TreeNode",
},
},
$defs: {
TreeNode: {
type: "object",
properties: {
child: {
$ref: "#/$defs/TreeNode",
},
},
},
},
});

assert.equal(typeof schema.$defs, "object");
assert.deepEqual(schema.properties.node, {
type: "object",
properties: {
child: {
$ref: "#/$defs/TreeNode",
},
},
});
});
Loading