diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2ce9a3922..c8537be5d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -4072,7 +4072,7 @@ export const createExecutor = { }); }); + it("collapses narrow objects with data-bearing keys to a map", () => { + // Two entries is far below the width threshold — the keys themselves are + // the tell. None of these may persist as schema "field names". + const cases: Record[] = [ + { "alice@example.com": { active: true }, "bob@example.com": { active: false } }, + { "3f2b8c1e-79aa-4f10-8d5c-0a1b2c3d4e5f": 1 }, + { "2026-08-27T01:00:00Z": "event" }, + { "12345": { qty: 2 }, "67890": { qty: 1 } }, + { "https://example.com/page": 3 }, + { deadbeefdeadbeef00: true }, + { U012ABCDEF: { presence: "active" } }, + { cus_9s6XKzkNRiz8i3: { plan: "pro" } }, + { "10.0.0.7": "reachable" }, + { sk4bcD3fGh1jKlMnOpQr: true }, + ]; + for (const value of cases) { + const shape = inferShape(value); + expect(shape.properties, JSON.stringify(value)).toBeUndefined(); + expect(shape.additionalProperties, JSON.stringify(value)).toBeDefined(); + } + }); + + it("keeps ordinary API field names as properties", () => { + const shape = inferShape({ + id: 1, + created_at: "2026-01-01", + pageUrl: "https://x", + email2fa: true, + "@odata.context": "ctx", + organizationMembershipSettings: {}, + sha256Fingerprint: "…", + }); + expect(shape.properties).toBeDefined(); + expect(Object.keys(shape.properties ?? {}).sort()).toEqual([ + "@odata.context", + "created_at", + "email2fa", + "id", + "organizationMembershipSettings", + "pageUrl", + "sha256Fingerprint", + ]); + }); + it("degrades to unknown past the depth bound", () => { let value: unknown = "leaf"; for (let i = 0; i < 10; i++) value = { child: value }; diff --git a/packages/core/sdk/src/shape-inference.ts b/packages/core/sdk/src/shape-inference.ts index 3cd0e5d33..c9b5b6769 100644 --- a/packages/core/sdk/src/shape-inference.ts +++ b/packages/core/sdk/src/shape-inference.ts @@ -45,6 +45,43 @@ const MAX_ANYOF = 4; const isUnknown = (shape: InferredShape): boolean => shape.type === undefined && shape.anyOf === undefined; +/** + * Keys that look like DATA rather than API surface: emails, UUIDs, + * timestamps, URLs, bare numbers, long random tokens. Struct field names are + * schema; map keys are values, and values must never persist. Width alone + * (`MAX_OBJECT_KEYS`) misses a two-entry object keyed by email addresses, so + * any single data-looking key collapses the whole object to a map. No + * classifier is a proof — only declared schemas are — so this errs toward + * collapsing. + */ +const DATA_KEY_PATTERNS: readonly RegExp[] = [ + // Email addresses (full-string — `@odata.context`-style annotation keys are + // legitimate API surface and must NOT collapse). + /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + // UUIDs. + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + // Timestamps / dates. + /^\d{4}-\d{2}-\d{2}/, + // Bare numbers and IPv4 addresses. + /^\d+$/, + /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + // URLs. + /^https?:\/\//, + // Long hex tokens (hashes, ids). + /^[0-9a-f]{16,}$/i, + // Platform opaque ids: Slack-style ALL-CAPS ids (U012ABCDEF), and + // prefix_body ids (cus_..., price_..., asst_...). Real field names in + // snake_case are lowercase words, not lowercase prefix + mixed-case body. + /^[A-Z][A-Z0-9]{8,}$/, + /^[a-z]{1,6}_(?=.*[A-Z0-9])[A-Za-z0-9]{10,}$/, + // Generic digit-bearing opaque tokens (API keys, base62/base64 ids). Long + // camelCase field names rarely contain digits at this length. + /^(?=.*\d)[A-Za-z0-9+/=_-]{20,}$/, +]; + +const looksLikeDataKey = (key: string): boolean => + DATA_KEY_PATTERNS.some((pattern) => pattern.test(key)); + /** Infer the shape of one observed value. Reads structure only, never values. */ export const inferShape = (value: unknown, depth = 0): InferredShape => { if (value === null || value === undefined) return { type: "null" }; @@ -64,7 +101,9 @@ export const inferShape = (value: unknown, depth = 0): InferredShape => { if (typeof value === "object") { const entries = Object.entries(value as Record); - if (entries.length > MAX_OBJECT_KEYS) { + // Both branches guarantee at least one entry, so the seedless reduce is + // safe (an UNKNOWN seed would absorb every merge). + if (entries.length > MAX_OBJECT_KEYS || entries.some(([key]) => looksLikeDataKey(key))) { const merged = entries .slice(0, MAX_ARRAY_SAMPLE) .map(([, item]) => inferShape(item, depth + 1)) diff --git a/packages/core/sdk/src/shape-memory.test.ts b/packages/core/sdk/src/shape-memory.test.ts new file mode 100644 index 000000000..2e68a4389 --- /dev/null +++ b/packages/core/sdk/src/shape-memory.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as TestClock from "effect/testing/TestClock"; + +import type { Owner } from "./ids"; +import type { PluginStorageEntry, PluginStorageFacade } from "./plugin-storage"; +import { makeShapeMemory } from "./shape-memory"; + +const OWNER: Owner = "org"; +const ADDRESS = "tools.demo.org.main.run"; + +/** Map-backed stand-in for plugin_storage, with a write counter. */ +const makeStubStorage = () => { + const rows = new Map(); + let writes = 0; + const entryFor = (key: string): PluginStorageEntry | null => { + const data = rows.get(key); + if (data === undefined) return null; + return { + id: key, + owner: OWNER, + pluginId: "executor.shape-memory", + collection: "observed-output-shapes", + key, + data: data as T, + createdAt: new Date(0), + updatedAt: new Date(0), + }; + }; + let failNextWrites = 0; + const unsupported = (member: string) => () => + Effect.die(`stub storage does not implement ${member}`); + const storage: PluginStorageFacade = { + collection: () => ({ + get: unsupported("collection.get"), + getForOwner: unsupported("collection.getForOwner"), + list: unsupported("collection.list"), + put: unsupported("collection.put"), + query: unsupported("collection.query"), + count: unsupported("collection.count"), + remove: unsupported("collection.remove"), + }), + get: (input) => Effect.sync(() => entryFor(input.key)), + getForOwner: (input) => Effect.sync(() => entryFor(input.key)), + list: unsupported("list"), + put: (input) => + Effect.suspend(() => { + if (failNextWrites > 0) { + failNextWrites -= 1; + return Effect.fail({ _tag: "StorageError" as const }) as never; + } + writes += 1; + rows.set(input.key, input.data); + return Effect.sync(() => entryFor(input.key) as never); + }), + putMany: unsupported("putMany"), + remove: unsupported("remove"), + removeMany: unsupported("removeMany"), + }; + return { + storage, + rows, + writeCount: () => writes, + failWrites: (count: number) => { + failNextWrites = count; + }, + }; +}; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +describe("makeShapeMemory", () => { + it.effect("recalls what it observed, and writes only on change or freshness", () => + Effect.gen(function* () { + const stub = makeStubStorage(); + const memory = makeShapeMemory(stub.storage); + + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 1 }); + expect(stub.writeCount(), "first observation persists").toBe(1); + + // Identical shape shortly after: no write. + yield* TestClock.adjust("1 minute"); + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 2 }); + expect(stub.writeCount(), "stable shape does not write").toBe(1); + + // Shape change: writes. + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 3, extra: true }); + expect(stub.writeCount(), "changed shape writes").toBe(2); + + const recalled = yield* memory.recall(ADDRESS, OWNER, "direct"); + expect(recalled?.observations).toBe(3); + expect(recalled?.schema.required).toEqual(["id"]); + }), + ); + + it.effect("persists freshness on the interval even when the shape is stable", () => + Effect.gen(function* () { + const stub = makeStubStorage(); + const memory = makeShapeMemory(stub.storage); + + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 1 }); + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 2 }); + expect(stub.writeCount()).toBe(1); + + yield* TestClock.adjust("7 hours"); + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 3 }); + expect(stub.writeCount(), "staleness alone forces a freshness write").toBe(2); + const stored = stub.rows.get(ADDRESS) as { observations: number; updatedAt: number }; + expect(stored.observations, "persisted counters are current").toBe(3); + expect(stored.updatedAt).toBe(7 * HOUR + 0); + }), + ); + + it.effect("ignores a record observed under a different contract and restarts on observe", () => + Effect.gen(function* () { + const stub = makeStubStorage(); + const memory = makeShapeMemory(stub.storage); + + yield* memory.observe(ADDRESS, OWNER, "direct", { content: [{ type: "text" }] }); + expect(yield* memory.recall(ADDRESS, OWNER, "direct")).not.toBeNull(); + expect( + yield* memory.recall(ADDRESS, OWNER, "mcp-call-tool-result-v2"), + "other contract sees nothing", + ).toBeNull(); + + // Observing under the new contract replaces rather than merges. + yield* memory.observe(ADDRESS, OWNER, "mcp-call-tool-result-v2", { issues: [] }); + const fresh = yield* memory.recall(ADDRESS, OWNER, "mcp-call-tool-result-v2"); + expect(fresh?.observations).toBe(1); + expect(Object.keys(fresh?.schema.properties ?? {})).toEqual(["issues"]); + }), + ); + + it.effect("retries after a failed write instead of pretending it persisted", () => + Effect.gen(function* () { + const stub = makeStubStorage(); + const memory = makeShapeMemory(stub.storage); + + stub.failWrites(1); + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 1 }); + expect(stub.rows.has(ADDRESS), "the failed write stored nothing").toBe(false); + + // The very next observation retries — no waiting out the freshness + // interval on bookkeeping that lied about persisting. + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 2 }); + expect(stub.rows.has(ADDRESS), "the retry persisted").toBe(true); + const stored = stub.rows.get(ADDRESS) as { observations: number }; + expect(stored.observations).toBe(2); + }), + ); + + it.effect("treats legacy records without a contract field as direct", () => + Effect.gen(function* () { + const stub = makeStubStorage(); + stub.rows.set(ADDRESS, { + schema: { type: "object", properties: { ran: { type: "string" } }, required: ["ran"] }, + observations: 4, + updatedAt: 0, + }); + const memory = makeShapeMemory(stub.storage); + const recalled = yield* memory.recall(ADDRESS, OWNER, "direct"); + expect(recalled?.observations).toBe(4); + }), + ); + + it.effect("expires shapes that have not been reinforced", () => + Effect.gen(function* () { + const stub = makeStubStorage(); + const memory = makeShapeMemory(stub.storage); + + yield* memory.observe(ADDRESS, OWNER, "direct", { id: 1 }); + yield* TestClock.adjust("29 days"); + expect(yield* memory.recall(ADDRESS, OWNER, "direct"), "fresh enough").not.toBeNull(); + + yield* TestClock.adjust("2 days"); + expect(yield* memory.recall(ADDRESS, OWNER, "direct"), "expired").toBeNull(); + + // The next observation restarts instead of merging into the fossil. + yield* memory.observe(ADDRESS, OWNER, "direct", { fresh: true }); + const restarted = yield* memory.recall(ADDRESS, OWNER, "direct"); + expect(restarted?.observations).toBe(1); + expect(Object.keys(restarted?.schema.properties ?? {})).toEqual(["fresh"]); + void DAY; + }), + ); +}); diff --git a/packages/core/sdk/src/shape-memory.ts b/packages/core/sdk/src/shape-memory.ts index d5ab9f1d2..857f9f427 100644 --- a/packages/core/sdk/src/shape-memory.ts +++ b/packages/core/sdk/src/shape-memory.ts @@ -7,12 +7,20 @@ * by tool-catalog refresh (which deletes and recreates `tool` rows, so the * tool row itself is not a viable home). An in-memory read-through cache * keeps the hot path off the database: within one executor instance a tool's - * shape is loaded at most once, and a write happens only when a new - * observation actually changes the merged shape — after a few calls a stable - * API stops producing writes entirely. + * shape is loaded at most once, and a write happens only when the merged + * shape changed, on an observation-count milestone, or when the persisted + * record's freshness is stale — so a stable API converges to rare + * freshness-only writes instead of a write per call. * - * `observe` never fails and is intended to be forked off the dispatch path; - * `recall` degrades to "no memory" on any storage failure. + * Records carry the `contract` (result encoding) they were observed under: + * a recall with a different contract returns nothing and the next + * observation starts a fresh record, so a data-contract migration + * invalidates stale shapes without a deletion pass. Records also expire — + * a shape not reinforced within `EXPIRY_MS` is not served, because a + * confidently wrong type is worse than `unknown`. + * + * `observe` never fails; `recall` degrades to "no memory" on any storage + * failure. */ import { Clock, Effect } from "effect"; @@ -20,63 +28,129 @@ import { Clock, Effect } from "effect"; import type { Owner } from "./ids"; import type { PluginStorageFacade } from "./plugin-storage"; import { observeShape, type ObservedShape } from "./shape-inference"; +import type { ToolResultEncoding } from "./tool-result-normalization"; /** Reserved system namespace inside `plugin_storage`; not a real plugin. */ export const SHAPE_MEMORY_PLUGIN_ID = "executor.shape-memory"; const COLLECTION = "observed-output-shapes"; +/** A shape not reinforced for this long stops being served and restarts on + * the next observation. */ +const EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; +/** Persist observation-count/freshness bookkeeping at most this often when + * the schema itself is stable. */ +const FRESHNESS_WRITE_INTERVAL_MS = 6 * 60 * 60 * 1000; +/** ... and always on these observation-count milestones. */ +const OBSERVATION_WRITE_MILESTONE = 25; + +/** Persisted record: ObservedShape plus the result contract it was learned + * under. Records written before contracts existed default to "direct". */ +type StoredShape = ObservedShape & { readonly contract?: ToolResultEncoding }; + export type ShapeMemory = { /** * Fold one successful tool payload into the tool's remembered shape. * Structure only — values never leave this call. Never fails. */ - readonly observe: (address: string, owner: Owner, value: unknown) => Effect.Effect; - /** The remembered shape for an address, or null when nothing is known. */ - readonly recall: (address: string, owner: Owner) => Effect.Effect; + readonly observe: ( + address: string, + owner: Owner, + contract: ToolResultEncoding, + value: unknown, + ) => Effect.Effect; + /** The remembered shape for an address under a contract, or null. */ + readonly recall: ( + address: string, + owner: Owner, + contract: ToolResultEncoding, + ) => Effect.Effect; }; export const makeShapeMemory = (storage: PluginStorageFacade): ShapeMemory => { - const cache = new Map(); - const persisted = new Map(); + const cache = new Map(); + const persistedSchema = new Map(); + const persistedAt = new Map(); const cacheKey = (owner: Owner, address: string) => `${owner}:${address}`; - const load = (address: string, owner: Owner): Effect.Effect => + const storedContract = (record: StoredShape): ToolResultEncoding => record.contract ?? "direct"; + + const load = (address: string, owner: Owner): Effect.Effect => Effect.gen(function* () { const key = cacheKey(owner, address); const hit = cache.get(key); if (hit !== undefined) return hit; const entry = yield* storage - .getForOwner({ owner, collection: COLLECTION, key: address }) + .getForOwner({ owner, collection: COLLECTION, key: address }) .pipe(Effect.catch(() => Effect.succeed(null))); const record = entry?.data ?? null; cache.set(key, record); - if (record !== null) persisted.set(key, JSON.stringify(record.schema)); + if (record !== null) { + persistedSchema.set(key, JSON.stringify(record.schema)); + persistedAt.set(key, record.updatedAt); + } return record; }); - const observe = (address: string, owner: Owner, value: unknown): Effect.Effect => + const usable = ( + record: StoredShape | null, + contract: ToolResultEncoding, + now: number, + ): ObservedShape | null => + record !== null && storedContract(record) === contract && now - record.updatedAt <= EXPIRY_MS + ? record + : null; + + const observe = ( + address: string, + owner: Owner, + contract: ToolResultEncoding, + value: unknown, + ): Effect.Effect => Effect.gen(function* () { const key = cacheKey(owner, address); - const prior = yield* load(address, owner); + const stored = yield* load(address, owner); const now = yield* Clock.currentTimeMillis; - const next = observeShape(prior, value, now); + // A contract mismatch or expiry means the record describes data this + // tool no longer returns — restart rather than merge into it. + const prior = usable(stored, contract, now); + const next: StoredShape = { ...observeShape(prior, value, now), contract }; cache.set(key, next); - // Write only when the merged shape actually changed — observation - // counters alone are bookkeeping, not worth a row write per call. const schemaJson = JSON.stringify(next.schema); - if (persisted.get(key) === schemaJson) return; - yield* storage + const lastWrite = persistedAt.get(key) ?? 0; + const shouldWrite = + persistedSchema.get(key) !== schemaJson || + stored === null || + prior === null || + next.observations % OBSERVATION_WRITE_MILESTONE === 0 || + now - lastWrite >= FRESHNESS_WRITE_INTERVAL_MS; + if (!shouldWrite) return; + // Advance the persisted-state bookkeeping only on a successful write: + // otherwise a transient storage failure would silence retries for the + // whole freshness interval while nothing is actually stored. + const wrote = yield* storage .put({ owner, collection: COLLECTION, key: address, data: next }) - .pipe(Effect.catch(() => Effect.succeed(null))); - persisted.set(key, schemaJson); + .pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + if (!wrote) return; + persistedSchema.set(key, schemaJson); + persistedAt.set(key, now); }).pipe(Effect.catchCause(() => Effect.void)); - return { - observe, - recall: (address, owner) => - load(address, owner).pipe(Effect.catchCause(() => Effect.succeed(null))), - }; + const recall = ( + address: string, + owner: Owner, + contract: ToolResultEncoding, + ): Effect.Effect => + Effect.gen(function* () { + const stored = yield* load(address, owner); + const now = yield* Clock.currentTimeMillis; + return usable(stored, contract, now); + }).pipe(Effect.catchCause(() => Effect.succeed(null))); + + return { observe, recall }; }; /** diff --git a/packages/core/sdk/src/tool-result-normalization.test.ts b/packages/core/sdk/src/tool-result-normalization.test.ts new file mode 100644 index 000000000..2de17b88f --- /dev/null +++ b/packages/core/sdk/src/tool-result-normalization.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { ToolResult } from "./tool-result"; +import { applyResultEncoding, normalizeMcpCallToolResult } from "./tool-result-normalization"; + +const text = (value: string) => ({ type: "text", text: value }); +const image = { type: "image", data: "aGk=", mimeType: "image/png" }; + +describe("normalizeMcpCallToolResult", () => { + it("serves structuredContent as data and suppresses its serialized duplicate", () => { + const structured = { issues: [{ id: 1, title: "a" }], total: 1 }; + const normalized = normalizeMcpCallToolResult({ + content: [text(JSON.stringify(structured))], + structuredContent: structured, + }); + expect(normalized.data).toEqual(structured); + expect(normalized.content).toBeUndefined(); + }); + + it("suppresses a key-order-shuffled duplicate but keeps genuine extra prose", () => { + const structured = { a: 1, b: { c: [1, 2] } }; + const normalized = normalizeMcpCallToolResult({ + content: [text('{"b":{"c":[1,2]},"a":1}'), text("2 results, capped at 100.")], + structuredContent: structured, + }); + expect(normalized.data).toEqual(structured); + expect(normalized.content).toEqual([text("2 results, capped at 100.")]); + }); + + it("keeps media blocks beside structured data", () => { + const normalized = normalizeMcpCallToolResult({ + content: [image], + structuredContent: { name: "chart.png" }, + }); + expect(normalized.data).toEqual({ name: "chart.png" }); + expect(normalized.content).toEqual([image]); + }); + + it("parses a lone exact-JSON text block into data", () => { + const normalized = normalizeMcpCallToolResult({ + content: [text(' {"issues":[{"id":7}],"total":1} ')], + }); + expect(normalized.data).toEqual({ issues: [{ id: 7 }], total: 1 }); + }); + + it("parses a lone JSON array text block", () => { + expect(normalizeMcpCallToolResult({ content: [text("[1,2,3]")] }).data).toEqual([1, 2, 3]); + }); + + it("keeps prose, fenced JSON, bare literals, and broken JSON as strings", () => { + expect(normalizeMcpCallToolResult({ content: [text("No incidents found.")] }).data).toBe( + "No incidents found.", + ); + expect(normalizeMcpCallToolResult({ content: [text('```json\n{"a":1}\n```')] }).data).toBe( + '```json\n{"a":1}\n```', + ); + expect(normalizeMcpCallToolResult({ content: [text("42")] }).data).toBe("42"); + expect(normalizeMcpCallToolResult({ content: [text('"quoted"')] }).data).toBe('"quoted"'); + expect(normalizeMcpCallToolResult({ content: [text('{"a":')] }).data).toBe('{"a":'); + }); + + it("returns null for an empty result", () => { + expect(normalizeMcpCallToolResult({ content: [] }).data).toBeNull(); + }); + + it("returns the ordered block array for multi-block and media-only results", () => { + const blocks = [text("caption"), image]; + expect(normalizeMcpCallToolResult({ content: blocks }).data).toEqual(blocks); + expect(normalizeMcpCallToolResult({ content: [image] }).data).toEqual([image]); + }); + + it("preserves unknown future block types", () => { + const exotic = { type: "hologram", payload: "??" }; + expect(normalizeMcpCallToolResult({ content: [text("x"), exotic] }).data).toEqual([ + text("x"), + exotic, + ]); + }); + + it("moves _meta beside data", () => { + const normalized = normalizeMcpCallToolResult({ + content: [text('{"a":1}')], + _meta: { "io.modelcontextprotocol/serverInfo": { name: "s" } }, + }); + expect(normalized.data).toEqual({ a: 1 }); + expect(normalized.meta).toEqual({ "io.modelcontextprotocol/serverInfo": { name: "s" } }); + }); + + it("survives adversarially deep valid JSON without blowing the stack", () => { + const deep = "[".repeat(50_000) + "]".repeat(50_000); + // Deep duplicate-detection degrades to "not a duplicate" (block kept); + // it must never throw from inside the invocation path. + const normalized = normalizeMcpCallToolResult({ + content: [text(deep)], + structuredContent: { safe: true }, + }); + expect(normalized.data).toEqual({ safe: true }); + expect(normalized.content).toEqual([text(deep)]); + // The lone-text parse path is equally safe: a stack-exceeding parse is + // simply "not JSON" and the text stays a string. + const lone = normalizeMcpCallToolResult({ content: [text(deep)] }); + expect(typeof lone.data === "string" || Array.isArray(lone.data)).toBe(true); + }); + + it("passes non-envelope values through untouched", () => { + expect(normalizeMcpCallToolResult({ rows: [1] }).data).toEqual({ rows: [1] }); + expect(normalizeMcpCallToolResult("plain").data).toBe("plain"); + expect(normalizeMcpCallToolResult(null).data).toBeNull(); + }); +}); + +describe("applyResultEncoding", () => { + it("leaves direct results and failures untouched", () => { + const ok = ToolResult.ok({ content: [text("looks like an envelope but is direct data")] }); + expect(applyResultEncoding("direct", ok)).toBe(ok); + const fail = ToolResult.fail({ code: "mcp_tool_error", message: "boom" }); + expect(applyResultEncoding("mcp-call-tool-result-v2", fail)).toBe(fail); + }); + + it("normalizes v2-encoded successes into semantic data with side channels", () => { + const applied = applyResultEncoding( + "mcp-call-tool-result-v2", + ToolResult.ok({ + content: [image], + structuredContent: { ok: true }, + _meta: { trace: 1 }, + }), + ); + expect(applied).toEqual({ + ok: true, + data: { ok: true }, + content: [image], + meta: { trace: 1 }, + }); + }); +}); diff --git a/packages/core/sdk/src/tool-result-normalization.ts b/packages/core/sdk/src/tool-result-normalization.ts new file mode 100644 index 000000000..108525469 --- /dev/null +++ b/packages/core/sdk/src/tool-result-normalization.ts @@ -0,0 +1,181 @@ +/** + * Result-encoding normalization — the seam that keeps `ToolResult.data` a + * SEMANTIC payload no matter what the upstream transport wrapped it in. + * + * OpenAPI set the precedent: `data` is the response body, transport facts + * (`http`) ride beside it. MCP's CallToolResult envelope is the same + * situation one protocol over: `content` blocks and `_meta` are transport + * ceremony, and the payload lives in `structuredContent` — or, for the many + * servers that predate it, serialized as JSON inside a text block. Serving + * the raw envelope as `data` made models guess field paths through ceremony + * and pay for spec-mandated payload duplication (servers SHOULD mirror + * `structuredContent` as text). + * + * A tool row opts in via its persisted `result_encoding`; the executor + * applies normalization after invocation and error recovery, before + * telemetry and shape observation, so learned shapes describe payloads. + * Owned by core — not the MCP plugin — because the encoding outlives the + * plugin system. + */ + +import type { ToolContentBlock, ToolResult } from "./tool-result"; + +/** Wire vocabulary persisted on tool rows. `direct` = data is already the + * payload (every non-MCP tool today). */ +export type ToolResultEncoding = "direct" | "mcp-call-tool-result-v2"; + +export const TOOL_RESULT_ENCODINGS: readonly ToolResultEncoding[] = [ + "direct", + "mcp-call-tool-result-v2", +]; + +export const isToolResultEncoding = (value: unknown): value is ToolResultEncoding => + value === "direct" || value === "mcp-call-tool-result-v2"; + +/** Parse guard: a lone text block larger than this stays a string rather + * than paying a second unbounded parse. */ +const MAX_JSON_TEXT_CHARS = 4_000_000; + +type Envelope = { + readonly content: readonly ToolContentBlock[]; + readonly structuredContent?: unknown; + readonly meta?: unknown; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const readEnvelope = (value: unknown): Envelope | null => { + if (!isRecord(value)) return null; + const content = value["content"]; + if (!Array.isArray(content)) return null; + if (!content.every(isRecord)) return null; + return { + content: content as readonly ToolContentBlock[], + ...("structuredContent" in value ? { structuredContent: value["structuredContent"] } : {}), + ...("_meta" in value ? { meta: value["_meta"] } : {}), + }; +}; + +const textOf = (block: ToolContentBlock): string | null => + block["type"] === "text" && typeof block["text"] === "string" ? block["text"] : null; + +/** Exact-JSON parse of an object/array payload; anything else (prose, JSON + * fenced in Markdown, bare literals, oversized text) stays a string. */ +const parseJsonPayload = (text: string): unknown | undefined => { + if (text.length > MAX_JSON_TEXT_CHARS) return undefined; + const trimmed = text.trim(); + const first = trimmed[0]; + if (first !== "{" && first !== "[") return undefined; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing whether upstream text IS JSON; a parse rejection is the "not JSON" answer, not a failure to model + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: same probe; there is no schema for arbitrary upstream JSON + const parsed: unknown = JSON.parse(trimmed); + return isRecord(parsed) || Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +}; + +/** Depth bound for duplicate detection. Beyond it two values are treated as + * DIFFERENT — the safe direction: the block is kept rather than dropped — + * and, critically, an adversarially deep (but valid) upstream JSON cannot + * blow the stack inside the invocation path. */ +const MAX_EQUALITY_DEPTH = 64; + +/** Structural equality against the parsed form of a text block, used to + * suppress the spec-mandated serialized duplicate of `structuredContent`. + * Key-order insensitive; depth-bounded. */ +const structurallyEquals = (left: unknown, right: unknown, depth = 0): boolean => { + if (Object.is(left, right)) return true; + if (depth >= MAX_EQUALITY_DEPTH) return false; + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((item, index) => structurallyEquals(item, right[index], depth + 1)) + ); + } + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left); + if (leftKeys.length !== Object.keys(right).length) return false; + return leftKeys.every( + (key) => key in right && structurallyEquals(left[key], right[key], depth + 1), + ); + } + return false; +}; + +const isDuplicateOfStructured = (block: ToolContentBlock, structured: unknown): boolean => { + const text = textOf(block); + if (text === null) return false; + const parsed = parseJsonPayload(text); + return parsed !== undefined && structurallyEquals(parsed, structured); +}; + +export type NormalizedToolSuccess = { + readonly data: unknown; + /** Supplemental blocks NOT already represented by `data` (media, extra + * prose). Never the serialized duplicate of `structuredContent`. */ + readonly content?: readonly ToolContentBlock[]; + /** Envelope `_meta`, excluded from schemas and shape inference. */ + readonly meta?: unknown; +}; + +/** + * Normalize one successful MCP CallToolResult into semantic data: + * + * 1. `structuredContent` present → it IS the data; non-duplicate blocks ride + * in `content`. + * 2. Lone text block of exact JSON (object/array) → the parsed value. + * 3. Lone text block of prose → the string itself. + * 4. No content and no structuredContent → null. + * 5. Anything else (media, multi-block) → the ordered block array. + * + * A value that isn't envelope-shaped is returned as-is — normalization must + * never invent structure. + */ +export const normalizeMcpCallToolResult = (raw: unknown): NormalizedToolSuccess => { + const envelope = readEnvelope(raw); + if (envelope === null) return { data: raw }; + + const meta = envelope.meta !== undefined ? { meta: envelope.meta } : {}; + + if (envelope.structuredContent !== undefined) { + const supplemental = envelope.content.filter( + (block) => !isDuplicateOfStructured(block, envelope.structuredContent), + ); + return { + data: envelope.structuredContent, + ...(supplemental.length > 0 ? { content: supplemental } : {}), + ...meta, + }; + } + + if (envelope.content.length === 0) return { data: null, ...meta }; + + if (envelope.content.length === 1) { + const only = envelope.content[0]; + const text = only === undefined ? null : textOf(only); + if (text !== null) { + const parsed = parseJsonPayload(text); + return { data: parsed !== undefined ? parsed : text, ...meta }; + } + } + + return { data: envelope.content, ...meta }; +}; + +/** Apply a row's result encoding to a successful invocation value. */ +export const applyResultEncoding = ( + encoding: ToolResultEncoding, + result: ToolResult, +): ToolResult => { + if (encoding === "direct" || !result.ok) return result; + const normalized = normalizeMcpCallToolResult(result.data); + return { + ...result, + data: normalized.data, + ...(normalized.content !== undefined ? { content: normalized.content } : {}), + ...(normalized.meta !== undefined ? { meta: normalized.meta } : {}), + }; +}; diff --git a/packages/core/sdk/src/tool-result.ts b/packages/core/sdk/src/tool-result.ts index 883015fa0..284002f8c 100644 --- a/packages/core/sdk/src/tool-result.ts +++ b/packages/core/sdk/src/tool-result.ts @@ -51,8 +51,28 @@ const matchesToolFileSchema = Schema.is(ToolFileSchema); export const isToolFile = (value: unknown): value is ToolFile => matchesToolFileSchema(value); +/** + * One MCP-style content block, kept structurally open: unknown future block + * types must survive normalization rather than being dropped. + */ +export type ToolContentBlock = Readonly>; + export type ToolResult = - | { readonly ok: true; readonly data: T; readonly http?: ToolHttpMeta } + | { + readonly ok: true; + readonly data: T; + readonly http?: ToolHttpMeta; + /** + * Supplemental rich content beside `data` (media, extra prose) for + * transports whose results carry more than one channel — MCP content + * blocks not already represented by `data`. Never a serialized + * duplicate of `data`. + */ + readonly content?: readonly ToolContentBlock[]; + /** Transport envelope metadata (MCP `_meta`); excluded from schemas + * and shape inference. */ + readonly meta?: unknown; + } | { readonly ok: false; readonly error: ToolError }; export const ToolResult = { @@ -69,6 +89,8 @@ const ToolResultSchema = Schema.Union([ ok: Schema.Literal(true), data: Schema.Unknown, http: Schema.optional(ToolHttpMetaSchema), + content: Schema.optional(Schema.Array(Schema.Record(Schema.String, Schema.Unknown))), + meta: Schema.optional(Schema.Unknown), }), Schema.Struct({ ok: Schema.Literal(false), error: ToolErrorSchema }), ]);