From bb0c2a9b44f30a33efbf0d03b4eaf4d3c707a09f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 14:04:53 -0700 Subject: [PATCH 1/2] feat(server): MCP tool schemas drop JSON noise Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.ts | 5 +- .../src/mcp/advertisedJsonSchema.test.ts | 50 ++++++++++++++++ apps/server/src/mcp/advertisedJsonSchema.ts | 59 +++++++++++++++++++ packages/contracts/src/scheduledTask.ts | 4 +- 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/mcp/advertisedJsonSchema.test.ts create mode 100644 apps/server/src/mcp/advertisedJsonSchema.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a4e62e3b2417..84535b3cb4e19 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -35,6 +35,7 @@ import { MAX_REPORTED_DEPTH, toolOutcome, } from "../telemetry/ProviderDimensions.ts"; +import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpToolAccess from "./McpToolAccess.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -448,7 +449,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot tool: new McpSchema.Tool({ name: tool.name, description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), + inputSchema: advertisedJsonSchema(tool), annotations: { ...Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), @@ -632,7 +633,7 @@ const registerImageTool = Effect.fn("McpHttpServer.registerImageTool")(function* tool: new McpSchema.Tool({ name: tool.name, description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), + inputSchema: advertisedJsonSchema(tool), annotations: { ...Context.getOption(tool.annotations, Tool.Title).pipe( Option.map((title) => ({ title })), diff --git a/apps/server/src/mcp/advertisedJsonSchema.test.ts b/apps/server/src/mcp/advertisedJsonSchema.test.ts new file mode 100644 index 0000000000000..de72d63930298 --- /dev/null +++ b/apps/server/src/mcp/advertisedJsonSchema.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { Tool } from "effect/ai"; + +import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; + +it("drops JSON noise but keeps null where it is required", () => { + const Every = Schema.Struct({ ms: Schema.Int }); + const tool = Tool.make("example", { + parameters: Schema.Struct({ + threadId: Schema.optional(Schema.NullOr(Schema.String)), + beforeRunId: Schema.NullOr(Schema.String), + limit: Schema.optional(Schema.Int), + every: Schema.Union([Every, Schema.fromJsonString(Every)]), + target: Schema.optional( + Schema.NullOr( + Schema.Union([ + Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), + Schema.Struct({ kind: Schema.Literal("number"), number: Schema.Int }), + ]), + ), + ), + }), + }); + + expect(advertisedJsonSchema(tool)).toEqual({ + type: "object", + properties: { + threadId: { type: "string" }, + beforeRunId: { anyOf: [{ type: "string" }, { type: "null" }] }, + limit: { type: "integer" }, + every: { type: "object", properties: { ms: { type: "integer" } }, required: ["ms"] }, + target: { + anyOf: [ + { + type: "object", + properties: { kind: { type: "string", enum: ["url"] }, url: { type: "string" } }, + required: ["kind", "url"], + }, + { + type: "object", + properties: { kind: { type: "string", enum: ["number"] }, number: { type: "integer" } }, + required: ["kind", "number"], + }, + ], + }, + }, + required: ["beforeRunId", "every"], + }); +}); diff --git a/apps/server/src/mcp/advertisedJsonSchema.ts b/apps/server/src/mcp/advertisedJsonSchema.ts new file mode 100644 index 0000000000000..fc4a4ab15c2bd --- /dev/null +++ b/apps/server/src/mcp/advertisedJsonSchema.ts @@ -0,0 +1,59 @@ +import { Tool } from "effect/ai"; + +type Json = unknown; +type JsonObject = { [key: string]: Json }; + +const isObject = (value: Json): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isNullSchema = (value: Json) => + isObject(value) && value.type === "null" && Object.keys(value).length === 1; + +const isBareAnyOf = (value: Json): value is { anyOf: Array } => + isObject(value) && Array.isArray(value.anyOf) && Object.keys(value).length === 1; + +// A `Schema.fromJsonString` fallback. Advertised beside object shapes, it invites stringified input. +const isJsonStringSchema = (value: Json) => + isObject(value) && value.type === "string" && value.contentMediaType === "application/json"; + +const SAFE_INTEGER_BOUNDS: Record = { + maximum: Number.MAX_SAFE_INTEGER, + minimum: Number.MIN_SAFE_INTEGER, +}; + +const compact = (schema: Json, optional: boolean): Json => { + if (Array.isArray(schema)) return schema.map((item) => compact(item, false)); + if (!isObject(schema)) return schema; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + let result: JsonObject = {}; + for (const [key, value] of Object.entries(schema)) { + if (key === "additionalProperties" && value === true) continue; + if (SAFE_INTEGER_BOUNDS[key] === value) continue; + result[key] = + key === "properties" && isObject(value) + ? Object.fromEntries( + Object.entries(value).map(([name, property]) => [ + name, + compact(property, !required.has(name)), + ]), + ) + : compact(value, false); + } + if (Array.isArray(result.anyOf)) { + const branches = result.anyOf + .flatMap((branch) => (isBareAnyOf(branch) ? branch.anyOf : [branch])) + .filter((branch) => !(optional && isNullSchema(branch)) && !isJsonStringSchema(branch)); + const { anyOf: _anyOf, ...rest } = result; + const only = branches.length === 1 ? branches[0] : undefined; + result = isObject(only) ? { ...only, ...rest } : { ...rest, anyOf: branches }; + } + return result; +}; + +/** + * The input schema T3 advertises for a tool. Drops JSON noise that every thread pays for: + * `additionalProperties: true`, safe-integer bounds, nested `anyOf`, JSON-string fallbacks, and + * the `null` branch of optional properties. Decoding still uses the full schema. + */ +export const advertisedJsonSchema = (tool: Tool.Any) => + compact(Tool.getJsonSchema(tool), false) as ReturnType; diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 5d8e4d29d68ad..b4dffacd74c04 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -66,7 +66,7 @@ export const MAX_WEBHOOK_DELIVERY_AGE_MINUTES = 24 * 60; const WebhookMaxDeliveryAgeMinutes = Schema.Int.check( Schema.isBetween({ minimum: 1, maximum: MAX_WEBHOOK_DELIVERY_AGE_MINUTES }), ).annotate({ - description: "Skip requests held offline over this many minutes. Null: run all.", + description: "Skip requests held offline over this many minutes. Omit to run all.", }); export function isWebhookDedupeTemplate(template: string): boolean { @@ -152,7 +152,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ }), ), ).annotate({ - description: "Null or omitted: URL token only.", + description: "Omit for URL token only.", }), maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), dedupe: Schema.optional(ScheduledTaskWebhookDedupe), From a9033c28fdd876cb37df051b393875c5d1eee045 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 14:45:04 -0700 Subject: [PATCH 2/2] refactor(server): advertisedJsonSchema uses Predicate.isObject Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/advertisedJsonSchema.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/advertisedJsonSchema.ts b/apps/server/src/mcp/advertisedJsonSchema.ts index fc4a4ab15c2bd..f1193bd7739c0 100644 --- a/apps/server/src/mcp/advertisedJsonSchema.ts +++ b/apps/server/src/mcp/advertisedJsonSchema.ts @@ -1,20 +1,20 @@ +import * as Predicate from "effect/Predicate"; import { Tool } from "effect/ai"; type Json = unknown; type JsonObject = { [key: string]: Json }; -const isObject = (value: Json): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value); - const isNullSchema = (value: Json) => - isObject(value) && value.type === "null" && Object.keys(value).length === 1; + Predicate.isObject(value) && value.type === "null" && Object.keys(value).length === 1; const isBareAnyOf = (value: Json): value is { anyOf: Array } => - isObject(value) && Array.isArray(value.anyOf) && Object.keys(value).length === 1; + Predicate.isObject(value) && Array.isArray(value.anyOf) && Object.keys(value).length === 1; // A `Schema.fromJsonString` fallback. Advertised beside object shapes, it invites stringified input. const isJsonStringSchema = (value: Json) => - isObject(value) && value.type === "string" && value.contentMediaType === "application/json"; + Predicate.isObject(value) && + value.type === "string" && + value.contentMediaType === "application/json"; const SAFE_INTEGER_BOUNDS: Record = { maximum: Number.MAX_SAFE_INTEGER, @@ -23,14 +23,14 @@ const SAFE_INTEGER_BOUNDS: Record = { const compact = (schema: Json, optional: boolean): Json => { if (Array.isArray(schema)) return schema.map((item) => compact(item, false)); - if (!isObject(schema)) return schema; + if (!Predicate.isObject(schema)) return schema; const required = new Set(Array.isArray(schema.required) ? schema.required : []); let result: JsonObject = {}; for (const [key, value] of Object.entries(schema)) { if (key === "additionalProperties" && value === true) continue; if (SAFE_INTEGER_BOUNDS[key] === value) continue; result[key] = - key === "properties" && isObject(value) + key === "properties" && Predicate.isObject(value) ? Object.fromEntries( Object.entries(value).map(([name, property]) => [ name, @@ -45,7 +45,7 @@ const compact = (schema: Json, optional: boolean): Json => { .filter((branch) => !(optional && isNullSchema(branch)) && !isJsonStringSchema(branch)); const { anyOf: _anyOf, ...rest } = result; const only = branches.length === 1 ? branches[0] : undefined; - result = isObject(only) ? { ...only, ...rest } : { ...rest, anyOf: branches }; + result = Predicate.isObject(only) ? { ...only, ...rest } : { ...rest, anyOf: branches }; } return result; };