fix: clone a remote MCP schema before normalizing it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw
This commit is contained in:
Guilhem Lemouel
2026-09-10 15:06:23 +02:00
co-authored by Claude Opus 5
parent 34643076a5
commit f4b1d8bb53
2 changed files with 25 additions and 1 deletions
@@ -260,6 +260,26 @@ describe('loaded remote tools', () => {
expect(loadedMcpTools(OWNER)).toEqual([])
})
// The sanitized schema must not alias the listing cache: the normalize pass rewrites
// nested nodes in place, so a shallow copy would edit the entry `summarizeTool` and
// the failed-call schema echo read from, and that every other chat shares.
it('does not mutate the remote schema it was given', () => {
const remote = {
name: 'aliased',
description: 'x',
inputSchema: {
type: 'object',
properties: { a: { type: 'string', format: '' } },
required: ['a', 'a']
}
} as any
registerMcpTools(OWNER, mcpRegistryGeneration(OWNER), server, [remote])
expect(remote.inputSchema.properties.a.format).toBe('')
expect(remote.inputSchema.required).toEqual(['a', 'a'])
})
it('falls back to an empty object schema when the remote sends no usable one', () => {
registerMcpTools(OWNER, mcpRegistryGeneration(OWNER), server, [
{ name: 'nada', description: 'x', inputSchema: null } as any
@@ -646,7 +646,11 @@ function safeInputSchema(schema: unknown): Record<string, unknown> {
)
]
: []
const safe = { ...rest, type: 'object', properties, required }
// Cloned, not spread: the spread would leave `properties` and any `items`/`allOf`
// carried through `rest` pointing at the listing cache's own objects, and the
// normalize pass below rewrites nested nodes in place. A registered tool is
// supposed to be a frozen copy — sharing that structure makes it one in name only.
const safe = structuredClone({ ...rest, type: 'object', properties, required })
// The same pass `createToolDef` runs on every other tool, so a remote schema is not
// the one that reaches a provider unnormalized. It recurses, which this does not:
// Windmill's own MCP server emits `format: ""` on untyped fields.