diff --git a/open-sse/config/toolCloaking.ts b/open-sse/config/toolCloaking.ts index 429a6ca28bcb..22f648eabb5b 100644 --- a/open-sse/config/toolCloaking.ts +++ b/open-sse/config/toolCloaking.ts @@ -52,6 +52,40 @@ function toToolName(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +/** + * Recursively strip `enumDescriptions` from a JSON schema. + * + * VSCode Copilot emits `enumDescriptions` inside tool parameter schemas, but the + * Antigravity API rejects any request carrying that field with HTTP 400. Walk the + * schema's `properties` and `items` so the field is removed at every nesting level + * before the declaration is sent upstream. + */ +export function stripEnumDescriptions(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return schema; + + if (Array.isArray(schema)) { + return schema.map((entry) => stripEnumDescriptions(entry)); + } + + const result: JsonRecord = { ...(schema as JsonRecord) }; + delete result.enumDescriptions; + + const properties = asRecord(result.properties); + if (properties) { + const nextProperties: JsonRecord = {}; + for (const key of Object.keys(properties)) { + nextProperties[key] = stripEnumDescriptions(properties[key]); + } + result.properties = nextProperties; + } + + if (result.items !== undefined) { + result.items = stripEnumDescriptions(result.items); + } + + return result; +} + export function shouldCloakAntigravityTool(toolName: string): boolean { return ( toolName.length > 0 && !AG_DEFAULT_TOOLS.has(toolName) && !toolName.endsWith(AG_TOOL_SUFFIX) @@ -99,9 +133,20 @@ export function cloakAntigravityToolPayload( const declaration = asRecord(declarationValue); if (!declaration) continue; - const rawName = toToolName(declaration.name); + // VSCode Copilot sends `enumDescriptions` in tool parameter schemas, but the + // Antigravity API rejects requests carrying that field with HTTP 400. Strip it + // recursively before the declaration is forwarded upstream. + const stripped: JsonRecord = + declaration.parameters !== undefined + ? { ...declaration, parameters: stripEnumDescriptions(declaration.parameters) } + : declaration; + if (stripped !== declaration) { + changed = true; + } + + const rawName = toToolName(stripped.name); if (!rawName) { - cloakedDeclarations.push({ ...declaration }); + cloakedDeclarations.push({ ...stripped }); continue; } @@ -112,7 +157,7 @@ export function cloakAntigravityToolPayload( } cloakedDeclarations.push({ - ...declaration, + ...stripped, name: cloakedName, }); } diff --git a/tests/unit/antigravity-tool-cloaking.test.ts b/tests/unit/antigravity-tool-cloaking.test.ts index cf413718e478..3770eab655f8 100644 --- a/tests/unit/antigravity-tool-cloaking.test.ts +++ b/tests/unit/antigravity-tool-cloaking.test.ts @@ -5,6 +5,7 @@ import { AG_DECOY_TOOLS, AG_TOOL_SUFFIX, cloakAntigravityToolPayload, + stripEnumDescriptions, } from "../../open-sse/config/toolCloaking.ts"; test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools and injects decoys", () => { @@ -92,3 +93,101 @@ test("cloakAntigravityToolPayload composes namespace sanitization maps with Anti "mcp__filesystem__workspace_read" ); }); + +test("stripEnumDescriptions removes enumDescriptions at every nesting level", () => { + const schema = { + type: "OBJECT", + enumDescriptions: ["should be removed at root"], + properties: { + mode: { + type: "STRING", + enum: ["a", "b"], + enumDescriptions: ["desc a", "desc b"], + }, + nested: { + type: "OBJECT", + properties: { + choice: { + type: "STRING", + enumDescriptions: ["deep desc"], + }, + }, + }, + list: { + type: "ARRAY", + items: { + type: "STRING", + enumDescriptions: ["item desc"], + }, + }, + }, + }; + + const stripped = stripEnumDescriptions(schema) as any; + + assert.equal("enumDescriptions" in stripped, false); + assert.equal("enumDescriptions" in stripped.properties.mode, false); + assert.equal("enumDescriptions" in stripped.properties.nested.properties.choice, false); + assert.equal("enumDescriptions" in stripped.properties.list.items, false); + // Non-target fields are preserved. + assert.deepEqual(stripped.properties.mode.enum, ["a", "b"]); + // Input is not mutated. + assert.ok(Array.isArray((schema.properties.mode as any).enumDescriptions)); +}); + +test("cloakAntigravityToolPayload strips enumDescriptions from declaration parameters", () => { + const payload = { + request: { + tools: [ + { + functionDeclarations: [ + { + name: "workspace_read", + description: "Read a file", + parameters: { + type: "OBJECT", + enumDescriptions: ["root-level"], + properties: { + mode: { + type: "STRING", + enum: ["read", "write"], + enumDescriptions: ["read mode", "write mode"], + }, + }, + }, + }, + { + name: "run_command", + description: "Native tool keeps visible name but loses enumDescriptions", + parameters: { + type: "OBJECT", + properties: { + shell: { + type: "STRING", + enumDescriptions: ["bash", "zsh"], + }, + }, + }, + }, + ], + }, + ], + contents: [], + }, + }; + + const result = cloakAntigravityToolPayload(payload); + const declarations = (result.body.request.tools?.[0] as any)?.functionDeclarations || []; + + const cloaked = declarations.find( + (d: { name: string }) => d.name === `workspace_read${AG_TOOL_SUFFIX}` + ); + assert.ok(cloaked, "cloaked client tool present"); + assert.equal("enumDescriptions" in cloaked.parameters, false); + assert.equal("enumDescriptions" in cloaked.parameters.properties.mode, false); + assert.deepEqual(cloaked.parameters.properties.mode.enum, ["read", "write"]); + + const native = declarations.find((d: { name: string }) => d.name === "run_command"); + assert.ok(native, "native tool preserved"); + assert.equal("enumDescriptions" in native.parameters.properties.shell, false); +});