Skip to content
Merged
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
51 changes: 48 additions & 3 deletions open-sse/config/toolCloaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -99,9 +133,20 @@ export function cloakAntigravityToolPayload<T extends JsonRecord>(
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;
}

Expand All @@ -112,7 +157,7 @@ export function cloakAntigravityToolPayload<T extends JsonRecord>(
}

cloakedDeclarations.push({
...declaration,
...stripped,
name: cloakedName,
});
}
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/antigravity-tool-cloaking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
Loading