Skip to content
Closed
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
5 changes: 3 additions & 2 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 })),
Expand Down Expand Up @@ -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 })),
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/mcp/advertisedJsonSchema.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
});
});
59 changes: 59 additions & 0 deletions apps/server/src/mcp/advertisedJsonSchema.ts
Original file line number Diff line number Diff line change
@@ -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<Json> } =>
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<string, number> = {
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<typeof Tool.getJsonSchema>;
4 changes: 2 additions & 2 deletions packages/contracts/src/scheduledTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
Loading