mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
fix(server): emit Gemini-safe tool schemas so agy/Gemini CLI expose ctx_* tools
Antigravity CLI (agy) and Gemini CLI use Gemini's function-calling API, which
rejects JSON Schema `const` and `additionalProperties`. When a tool's parameter
schema contains either, the host SILENTLY DROPS that tool from the model's
function list — so agy never sees the ctx_* tools and works around them by
hand-rolling the MCP protocol through its Bash tool (verified on Windows: agy
wrote scratch/call_ctx_stats.js + list_mcp_tools.js MCP clients instead of
calling the tools natively). That defeats the point of context-mode — bash
output floods the context window instead of staying in the sandbox.
context-mode builds schemas with Zod, which emits `const` (from coerce/preprocess
constructs) and `additionalProperties`, with no Gemini sanitization. Wrap the
SDK's tools/list handler to rewrite the EMITTED schema:
- `const: X` -> `enum: [X]` (an identical single-value constraint)
- drop `additionalProperties` (advisory-only; every ctx_* handler parses args
with Zod, which strips unknown keys server-side regardless)
Both transforms are behavior-preserving for every other client (Claude Code,
Copilot, Cursor): const and a one-value enum are equivalent, and no model sends
undeclared properties — only the wire schema changes, never validation or how a
tool is called. Best-effort: if the MCP SDK internals shift, the original handler
is left untouched (no regression). Verified on the real tools/list: all 11 ctx_*
tools now emit 0 `const` / 0 `additionalProperties`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -353,6 +353,68 @@ server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts
|
||||
server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
||||
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));
|
||||
|
||||
// ── Strict-client (Gemini function-calling) schema compatibility ──────────────
|
||||
// Gemini's function-calling API — used by Antigravity CLI (`agy`) and Gemini CLI
|
||||
// — rejects JSON Schema `const` and `additionalProperties`. A rejected parameter
|
||||
// schema makes the host SILENTLY DROP that tool from the model's function list,
|
||||
// so the agent never sees our ctx_* tools and falls back to hand-rolling the MCP
|
||||
// protocol through its Bash tool. Sanitize the EMITTED tools/list schema:
|
||||
// • `const: X` → `enum: [X]` — an identical single-value constraint
|
||||
// • drop `additionalProperties` — advisory only; every ctx_* handler parses
|
||||
// args with Zod (which strips unknown keys server-side), so removing it
|
||||
// changes no validation and no call behavior.
|
||||
// Both transforms are behavior-preserving for every other client (Claude Code,
|
||||
// Copilot, Cursor, …): `const` and a one-value `enum` are equivalent, and no
|
||||
// model sends undeclared properties. Only the wire schema changes — never
|
||||
// validation or how any tool is invoked.
|
||||
export function sanitizeSchemaForStrictClients(node: unknown): unknown {
|
||||
if (Array.isArray(node)) return node.map(sanitizeSchemaForStrictClients);
|
||||
if (node === null || typeof node !== "object") return node;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
if (key === "additionalProperties") continue;
|
||||
if (key === "const") {
|
||||
out.enum = [value];
|
||||
continue;
|
||||
}
|
||||
out[key] = sanitizeSchemaForStrictClients(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wrap the SDK-installed tools/list handler so its generated schemas pass through
|
||||
// the sanitizer above. Best-effort by design: if the MCP SDK's internals shift,
|
||||
// the original handler is left untouched (no regression — strict clients stay as
|
||||
// they were, every other client unaffected). Must run AFTER all registerTool()
|
||||
// calls so the SDK's default tools/list handler already exists.
|
||||
export function installStrictClientSchemaCompat(target: McpServer = server): void {
|
||||
try {
|
||||
const low = target.server as unknown as {
|
||||
_requestHandlers?: Map<string, (req: unknown, extra: unknown) => Promise<unknown>>;
|
||||
};
|
||||
const original = low._requestHandlers?.get("tools/list");
|
||||
if (typeof original !== "function") return;
|
||||
target.server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
|
||||
const result = (await original(req as unknown, extra as unknown)) as
|
||||
| { tools?: Array<{ inputSchema?: unknown }> }
|
||||
| undefined;
|
||||
if (result && Array.isArray(result.tools)) {
|
||||
for (const tool of result.tools) {
|
||||
if (!tool || tool.inputSchema == null) continue;
|
||||
try {
|
||||
tool.inputSchema = sanitizeSchemaForStrictClients(tool.inputSchema);
|
||||
} catch {
|
||||
/* leave this tool's schema unchanged */
|
||||
}
|
||||
}
|
||||
}
|
||||
return result as never;
|
||||
});
|
||||
} catch {
|
||||
/* best-effort — never break tools/list */
|
||||
}
|
||||
}
|
||||
|
||||
const executor = new PolyglotExecutor({
|
||||
runtimes,
|
||||
projectRoot: () => getProjectDir(),
|
||||
@@ -4889,6 +4951,11 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Runs after every registerTool() above, so the SDK's default tools/list handler
|
||||
// exists and can be wrapped. Makes ctx_* schemas safe for strict (Gemini
|
||||
// function-calling) clients like Antigravity CLI (`agy`) / Gemini CLI.
|
||||
installStrictClientSchemaCompat();
|
||||
|
||||
if (process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeSchemaForStrictClients } from "../../src/server.js";
|
||||
|
||||
// Gemini's function-calling API (Antigravity CLI `agy`, Gemini CLI) rejects
|
||||
// JSON Schema `const` and `additionalProperties` and then silently drops the
|
||||
// tool from the model's function list. The sanitizer rewrites the EMITTED
|
||||
// tools/list schema in a behavior-preserving way so those tools become callable.
|
||||
describe("sanitizeSchemaForStrictClients", () => {
|
||||
it("rewrites `const: X` to `enum: [X]` (an identical single-value constraint)", () => {
|
||||
expect(sanitizeSchemaForStrictClients({ const: "javascript" })).toEqual({ enum: ["javascript"] });
|
||||
expect(sanitizeSchemaForStrictClients({ const: 1 })).toEqual({ enum: [1] });
|
||||
});
|
||||
|
||||
it("strips `additionalProperties` (advisory-only — Zod validates args server-side)", () => {
|
||||
const out = sanitizeSchemaForStrictClients({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: { a: { type: "string" } },
|
||||
}) as Record<string, unknown>;
|
||||
expect(out).not.toHaveProperty("additionalProperties");
|
||||
expect(out.type).toBe("object");
|
||||
expect(out.properties).toEqual({ a: { type: "string" } });
|
||||
});
|
||||
|
||||
it("preserves every Gemini-compatible keyword unchanged", () => {
|
||||
// enum / pattern / default / minLength etc. are accepted by Gemini and must
|
||||
// pass through untouched so non-Gemini clients see an identical schema.
|
||||
const input = {
|
||||
type: "string",
|
||||
enum: ["a", "b"],
|
||||
pattern: "^x",
|
||||
default: "a",
|
||||
minLength: 1,
|
||||
description: "desc",
|
||||
};
|
||||
expect(sanitizeSchemaForStrictClients(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("recurses through nested properties and arrays", () => {
|
||||
const input = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
language: { const: "shell" },
|
||||
items: { type: "array", items: { const: 1 }, additionalProperties: true },
|
||||
},
|
||||
};
|
||||
expect(sanitizeSchemaForStrictClients(input)).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
language: { enum: ["shell"] },
|
||||
items: { type: "array", items: { enum: [1] } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves primitives and null untouched", () => {
|
||||
expect(sanitizeSchemaForStrictClients("x")).toBe("x");
|
||||
expect(sanitizeSchemaForStrictClients(7)).toBe(7);
|
||||
expect(sanitizeSchemaForStrictClients(true)).toBe(true);
|
||||
expect(sanitizeSchemaForStrictClients(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("does not mutate the input object", () => {
|
||||
const input = { const: "x", additionalProperties: false };
|
||||
sanitizeSchemaForStrictClients(input);
|
||||
expect(input).toEqual({ const: "x", additionalProperties: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user