159389fad2
* fix(mcp): sanitize for gemini when model is unresolved isGeminiRouted() previously required the effective model string to contain "gemini" — but when payload.model="auto" (or any unresolved slug) reaches addTools(), `effective` is the literal "auto", which doesn't match. opencode then auto-selects gemini *after* the MCP server has registered raw arktype schemas, and every tool turn dies on `function_declarations[*].properties[*].any_of[*].enum: only allowed for STRING type`. widen the gate: any unresolved specifier (undefined / "auto" / a slug without a `provider/` prefix) is treated as gemini-routed and sanitized. the transforms are universally compatible normalizations so the false-positive cost is negligible. tighten case 3 to preserve `description` so the only lossy path no longer drops operator-facing context. fixes #676. * revert case-3 description preservation per pullfrog review on #697: keeping `description` as a peer of `anyOf`/`oneOf` directly contradicts the file's own header (lines 19-21) and the upstream opencode #14659 rationale that gates this sanitizer — gemini requires anyOf to be the ONLY field on a schema node, sibling keywords trigger `anyOf must be the only field in a schema node`. the change was speculative scope creep with no evidence, and would silently re-introduce a different gemini failure for any future schema using `.describe().or(...)`. the bug fix for #676 doesn't need it (arktype doesn't emit non-collapsible anyOf for current tool schemas).
208 lines
8.9 KiB
TypeScript
208 lines
8.9 KiB
TypeScript
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
import type { Tool } from "fastmcp";
|
|
import type { ToolContext } from "./server.ts";
|
|
|
|
// ── gemini schema sanitizer ────────────────────────────────────────────────────
|
|
//
|
|
// gemini's generateContent API expects an OpenAPI 3.0 Schema subset, not full
|
|
// JSON Schema. arktype 2.x emits constructs that gemini rejects with errors like:
|
|
// - "parameters.<field>.enum: only allowed for STRING type"
|
|
// - "functionDeclaration parameters.<field> schema didn't specify the schema type field"
|
|
// - "anyOf must be the only field in a schema node"
|
|
//
|
|
// transforms applied here:
|
|
// 1. add `type: "string"` to enum-only schemas. arktype emits string literal
|
|
// unions as `{enum: ["a","b"]}` without a `type` field — gemini requires
|
|
// the type declaration for any non-object schema.
|
|
// 2. collapse `{anyOf: [{enum:["a"]}, {enum:["b"]}]}` (older arktype form)
|
|
// into `{type:"string", enum:[...]}`. also handles `{const:"a"}` branches.
|
|
// 3. when `anyOf` / `oneOf` can't be collapsed, strip sibling fields (`type`,
|
|
// `description`, `items`, etc.) — gemini rejects `anyOf` alongside any
|
|
// peer keywords. see opencode #14659.
|
|
// 4. drop `$schema` metadata and rename `$defs` → `definitions` (draft-07
|
|
// compatibility; gemini doesn't understand either).
|
|
//
|
|
// gating: `isGeminiRouted()` detects gemini-targeted traffic so other
|
|
// providers continue to see the original (untransformed) schema.
|
|
//
|
|
// delivery: fastmcp (3.x) uses `xsschema.toJsonSchema()` which reads
|
|
// `schema["~standard"].jsonSchema.input({target:"draft-07"})` when present
|
|
// (arktype 2.x exposes this). we proxy the whole `~standard` chain so our
|
|
// transform runs regardless of which path xsschema takes.
|
|
|
|
function parseStringEnumBranch(item: unknown): { values: string[] } | null {
|
|
if (!item || typeof item !== "object") return null;
|
|
const record = item as Record<string, unknown>;
|
|
if (Array.isArray(record.enum)) {
|
|
const strings = record.enum.filter((v): v is string => typeof v === "string");
|
|
return strings.length === record.enum.length && strings.length > 0 ? { values: strings } : null;
|
|
}
|
|
if (typeof record.const === "string") {
|
|
return { values: [record.const] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collapseStringUnion(branches: unknown[]): { type: "string"; enum: string[] } | null {
|
|
const values: string[] = [];
|
|
for (const item of branches) {
|
|
const parsed = parseStringEnumBranch(item);
|
|
if (!parsed) return null;
|
|
values.push(...parsed.values);
|
|
}
|
|
if (values.length === 0) return null;
|
|
return { type: "string", enum: [...new Set(values)] };
|
|
}
|
|
|
|
/**
|
|
* Recursively transform a JSON schema to gemini's stricter subset.
|
|
* See module header for the exact transforms applied.
|
|
*/
|
|
export function sanitizeForGemini(schema: unknown): unknown {
|
|
if (!schema || typeof schema !== "object") return schema;
|
|
if (Array.isArray(schema)) return schema.map(sanitizeForGemini);
|
|
|
|
const source = schema as Record<string, unknown>;
|
|
|
|
// case 1: enum-only string union → add `type: "string"`.
|
|
// arktype emits `type: "'A' | 'B'"` as `{enum: ["A","B"]}` without a type.
|
|
if (Array.isArray(source.enum) && typeof source.type !== "string") {
|
|
const allStrings = source.enum.every((v) => typeof v === "string");
|
|
if (allStrings) {
|
|
const result: Record<string, unknown> = { type: "string", enum: source.enum };
|
|
if (typeof source.description === "string") result.description = source.description;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// case 2: collapsible string-enum union (older arktype form)
|
|
for (const unionKey of ["anyOf", "oneOf"] as const) {
|
|
const branches = source[unionKey];
|
|
if (Array.isArray(branches) && branches.length > 0) {
|
|
const collapsed = collapseStringUnion(branches);
|
|
if (collapsed) {
|
|
const result: Record<string, unknown> = { ...collapsed };
|
|
if (typeof source.description === "string") result.description = source.description;
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
// case 3: non-collapsible anyOf/oneOf → strip sibling fields (gemini rule)
|
|
if (Array.isArray(source.anyOf) || Array.isArray(source.oneOf)) {
|
|
const result: Record<string, unknown> = {};
|
|
if (Array.isArray(source.anyOf)) result.anyOf = source.anyOf.map(sanitizeForGemini);
|
|
if (Array.isArray(source.oneOf)) result.oneOf = source.oneOf.map(sanitizeForGemini);
|
|
return result;
|
|
}
|
|
|
|
// case 4: generic pass — drop $schema, rename $defs, recurse
|
|
const sanitized: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (key === "$schema") continue;
|
|
if (key === "$defs") {
|
|
sanitized.definitions = sanitizeForGemini(value);
|
|
continue;
|
|
}
|
|
sanitized[key] = sanitizeForGemini(value);
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
// ── delivery mechanism ─────────────────────────────────────────────────────────
|
|
//
|
|
// fastmcp 3.x resolves the JSON schema via xsschema, which takes two paths:
|
|
// path A: `schema["~standard"].jsonSchema.input({target:"draft-07"})` when
|
|
// the StandardJSONSchemaV1 extension is present (arktype 2.x).
|
|
// path B: `schema.toJsonSchema()` via a vendor-dispatched function (older
|
|
// arktype, other vendors).
|
|
//
|
|
// we proxy both entry points so the transform runs regardless of which path
|
|
// xsschema picks.
|
|
|
|
function wrapJsonSchemaProducer<T extends object>(producer: T): T {
|
|
return new Proxy(producer, {
|
|
get(target, prop, receiver) {
|
|
const value = Reflect.get(target, prop, receiver);
|
|
if ((prop === "input" || prop === "output") && typeof value === "function") {
|
|
const fn = value as (...args: unknown[]) => unknown;
|
|
return (...args: unknown[]) => sanitizeForGemini(fn.apply(target, args));
|
|
}
|
|
return value;
|
|
},
|
|
});
|
|
}
|
|
|
|
function wrapStandard<T extends object>(standard: T): T {
|
|
return new Proxy(standard, {
|
|
get(target, prop, receiver) {
|
|
if (prop === "jsonSchema") {
|
|
const value = Reflect.get(target, prop, receiver);
|
|
if (value && typeof value === "object") {
|
|
return wrapJsonSchemaProducer(value as object);
|
|
}
|
|
return value;
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function wrapSchemaForGemini(schema: StandardSchemaV1<any>): StandardSchemaV1<any> {
|
|
return new Proxy(schema, {
|
|
get(target, prop, receiver) {
|
|
if (prop === "~standard") {
|
|
const value = Reflect.get(target, prop, receiver);
|
|
if (value && typeof value === "object") {
|
|
return wrapStandard(value as object);
|
|
}
|
|
return value;
|
|
}
|
|
if (prop === "toJsonSchema") {
|
|
const method = Reflect.get(target, prop, receiver);
|
|
if (typeof method === "function") {
|
|
return () => sanitizeForGemini((method as (...args: unknown[]) => unknown).call(target));
|
|
}
|
|
return method;
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
}) as StandardSchemaV1<any>;
|
|
}
|
|
|
|
export function sanitizeToolForGemini<T extends Tool<any, any>>(tool: T): T {
|
|
if (!tool.parameters) return tool;
|
|
return { ...tool, parameters: wrapSchemaForGemini(tool.parameters) } as T;
|
|
}
|
|
|
|
/**
|
|
* true when the effective upstream model is — or might become — google
|
|
* generative language API traffic. matches:
|
|
* - direct `google/*`, opencode `opencode/gemini-*`, openrouter
|
|
* `openrouter/google/gemini-*` (slug substring "gemini" wins).
|
|
* - any unresolved specifier: `undefined`, `"auto"`, or a slug that
|
|
* didn't map through the alias registry (no `provider/` prefix).
|
|
* these flow through the agent's own auto-select, which may land
|
|
* on gemini *after* the MCP server has already registered tools —
|
|
* at which point sanitization is too late to apply. erring on the
|
|
* side of sanitizing is safe: cases 1 + 2 are universally
|
|
* compatible JSON-Schema normalizations (enum-only → typed string,
|
|
* collapsible const-unions → string enum); case 3 is gemini-
|
|
* specific but only fires on non-collapsible unions, which arktype
|
|
* does not emit for our current tool schemas. see issue #676 for
|
|
* the prod failure that motivated this widening.
|
|
*/
|
|
export function isGeminiRouted(ctx: ToolContext): boolean {
|
|
const effective = ctx.payload.proxyModel ?? ctx.resolvedModel ?? ctx.payload.model;
|
|
if (!effective) return true;
|
|
const normalized = effective.toLowerCase();
|
|
if (normalized.includes("gemini")) return true;
|
|
// every concrete model resolved through the registry carries a
|
|
// `provider/` prefix (e.g. "anthropic/claude-opus-4-7"). anything
|
|
// without a slash is either the literal `"auto"` alias or an
|
|
// unrecognized slug that resolveModel logged a warning for — both
|
|
// route through the agent's late auto-select, which may pick gemini.
|
|
if (!normalized.includes("/")) return true;
|
|
return false;
|
|
}
|