diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a4e62e3b241..84535b3cb4e1 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 000000000000..de72d6393029 --- /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 000000000000..f1193bd7739c --- /dev/null +++ b/apps/server/src/mcp/advertisedJsonSchema.ts @@ -0,0 +1,59 @@ +import * as Predicate from "effect/Predicate"; +import { Tool } from "effect/ai"; + +type Json = unknown; +type JsonObject = { [key: string]: Json }; + +const isNullSchema = (value: Json) => + Predicate.isObject(value) && value.type === "null" && Object.keys(value).length === 1; + +const isBareAnyOf = (value: Json): value is { anyOf: Array } => + 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) => + Predicate.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 (!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" && Predicate.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 = Predicate.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 5d8e4d29d68a..b4dffacd74c0 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),