diff --git a/packages/docs/content/docs/02-sdk/07-intent-capture.mdx b/packages/docs/content/docs/02-sdk/07-intent-capture.mdx index 6149345..727d361 100644 --- a/packages/docs/content/docs/02-sdk/07-intent-capture.mdx +++ b/packages/docs/content/docs/02-sdk/07-intent-capture.mdx @@ -29,6 +29,7 @@ The SDK reads and removes the `context` value at the protocol layer **before** y - **Your handler never sees it.** Tool code and input schemas stay untouched — strict schemas included. - **A call without `context` never fails.** The parameter is required only in the advertised schema; the server tolerates its absence. - **Tools that define their own `context` parameter are left completely alone** — no injection, no capture, no stripping. +- **Widget-invoked tools get `context` as optional, automatically.** A widget iframe calls its tools with only their real arguments — it cannot know to send `context` — and hosts validate widget calls against the advertised schema, so a required `context` would make them refuse every such call. The failure is delayed (hosts cache connector schemas until a refresh), i.e. the widget would break silently some time *after* you enable intent capture. Three `_meta` shapes mark a tool as widget-invoked: `"openai/widgetAccessible": true`; an MCP Apps `ui.visibility` containing `"app"`; and — the broadest rule — a `ui.resourceUri` with **no** declared visibility, because MCP Apps defaults omitted visibility to `["model", "app"]`. That last rule covers every view-owning tool of every MCP App. If your widget never calls its view-owning tool, declare `ui.visibility: ["model"]` on it to keep `context` required there (maximum capture). The SDK logs one `[yavio] Intent context advertised as OPTIONAL…` line per affected tool so you can see exactly which tools were downgraded. Capture still works whenever a model call fills the optional field — but note that the `fallback` option is *not* invoked for context-less calls on these tools: they are presumptively the widget's own machine traffic, and inferring an intent per 3-second refresh would drown the real ones. Captured intents are stored on the `tool_call` event (`intent_signals`), passed through the SDK's PII stripper, and capped at 500 characters. Read [what that does not cover](#what-intent-capture-does-not-protect-you-from) before enabling this on a server that handles sensitive data. @@ -56,7 +57,7 @@ withYavio(server, { ## Dashboard status -The SDK reports its intent setting with the first tool call of each session, so the dashboard's **User Intents** panel always shows an honest state: *enabled*, *disabled*, or *SDK too old* (intent capture requires `@yavio/sdk` 0.2.0+). If capture is enabled but no intents arrive, the panel says so — some MCP clients do not fill unknown parameters. +The SDK reports its intent setting with the first tool call of each session, so the dashboard's **User Intents** panel always shows an honest state: *enabled*, *disabled*, or *SDK too old* (intent capture requires `@yavio/sdk` 0.2.0+). If capture is enabled but no intents arrive, check two causes before suspecting a bug: some MCP clients do not fill unknown parameters, and on **widget-invoked tools** `context` is optional by design (see above) — a tool that is mostly called by the app's own widget will naturally show few or no intents, because widget calls carry no conversation to describe. The SDK logs one `[yavio]` line per tool it downgrades, so the server log tells you which tools are affected. ## Privacy and app-store review diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9406899..d3992c5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@yavio/sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "Yavio SDK for instrumenting MCP servers with analytics, session tracking, and a React widget", "type": "module", "license": "MIT", diff --git a/packages/sdk/src/__tests__/integration/intent.test.ts b/packages/sdk/src/__tests__/integration/intent.test.ts index 1daf3ea..33401d8 100644 --- a/packages/sdk/src/__tests__/integration/intent.test.ts +++ b/packages/sdk/src/__tests__/integration/intent.test.ts @@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { DEFAULT_INTENT_DESCRIPTION, resolveConfig } from "../../core/config.js"; import type { IntentConfig, YavioConfig } from "../../core/types.js"; -import { MAX_INTENT_LENGTH } from "../../server/intent.js"; +import { MAX_INTENT_LENGTH, createIntentController } from "../../server/intent.js"; import { _resetGlobalState, createProxy } from "../../server/proxy.js"; import type { Transport } from "../../transport/types.js"; @@ -782,3 +782,226 @@ describe("documentation stays in step with the shipped default", () => { expect(page).toContain(DEFAULT_INTENT_DESCRIPTION); }); }); + +describe("intent capture — widget-invoked tools", () => { + // A widget iframe calls its tools with only their real arguments; it cannot + // know to send `context`. Advertising `context` as REQUIRED on such a tool + // makes the host refuse every widget call as schema-invalid — and only after + // it refreshes cached schemas, so the widget breaks silently days later with + // no server-side trace. Incident: billiger-mietwagen, 2026-08-07 — the + // widget's 3s auto-refresh dropped from 83 calls/day to zero the moment the + // connector re-fetched schemas. Hence: on widget-invoked tools `context` is + // advertised as optional, while capture still applies when a model fills it. + beforeEach(() => _resetGlobalState()); + + const WIDGET_METAS: Array<[string, Record]> = [ + ["openai/widgetAccessible", { "openai/widgetAccessible": true }], + ["nested ui.visibility array", { ui: { visibility: ["app"] } }], + ["flat ui/visibility array", { "ui/visibility": ["app"] }], + ["bare-string visibility (off-spec authoring slip)", { ui: { visibility: "app" } }], + [ + "nested resourceUri with omitted visibility (spec default [model, app])", + { ui: { resourceUri: "ui://views/x.html" } }, + ], + ["flat ui/resourceUri with omitted visibility", { "ui/resourceUri": "ui://views/x.html" }], + [ + "nested ui object beside a flat ui/visibility key (shadowing regression)", + { ui: { resourceUri: "ui://views/x.html" }, "ui/visibility": ["app"] }, + ], + ]; + + /** One registration shape for every widget test, plus a model-tool control. */ + function setupWidget( + meta: Record, + intent: IntentConfig = INTENT_ON, + onCall?: (args: unknown) => void, + ): Promise { + return setup(intent, (proxy) => { + proxy.registerTool( + "widget-refresh", + { inputSchema: { search_id: z.string() }, _meta: meta }, + async (args: { search_id: string }) => { + onCall?.(args); + return ok("refreshed"); + }, + ); + proxy.registerTool("search", { inputSchema: { query: z.string() } }, async () => ok("x")); + }); + } + + it.each(WIDGET_METAS)("advertises context as optional: %s", async (_label, meta) => { + const h = await setupWidget(meta); + const list = await h.client.listTools(); + + const widgetTool = listedTool(list, "widget-refresh"); + expect(widgetTool.inputSchema.properties).toHaveProperty("context"); + expect(widgetTool.inputSchema.required ?? []).not.toContain("context"); + + // Control on the same server: ordinary tools keep the required parameter. + expect(listedTool(list, "search").inputSchema.required).toContain("context"); + }); + + it("openai/widgetAccessible: false does not downgrade the tool", async () => { + // Pins the deliberate strict `=== true` check: an explicit false (or any + // non-true value) must not count as widget-invoked, and a later cleanup + // "simplifying" the comparison to truthiness has a failing test to answer. + const h = await setupWidget({ "openai/widgetAccessible": false }); + const tool = listedTool(await h.client.listTools(), "widget-refresh"); + expect(tool.inputSchema.required).toContain("context"); + }); + + it("an explicit visibility of ['model'] keeps context required despite a resourceUri", async () => { + // The documented escape hatch: an app whose widget never calls its + // view-owning tool declares model-only visibility and keeps required + // context (maximum capture) on it. + const h = await setupWidget({ + ui: { resourceUri: "ui://views/x.html", visibility: ["model"] }, + }); + const tool = listedTool(await h.client.listTools(), "widget-refresh"); + expect(tool.inputSchema.required).toContain("context"); + }); + + it("accepts a context-less widget call, records no intent, and skips the fallback", async () => { + // The fallback exists to approximate intents for model calls that omit + // context. Widget traffic (a 3s auto-refresh) is context-less by nature — + // running the fallback there would record machine-generated "inferred" + // intents at machine frequency. + const fallback = vi.fn(() => "machine noise"); + let seenArgs: unknown; + const h = await setupWidget( + { "openai/widgetAccessible": true }, + { ...INTENT_ON, fallback }, + (args) => { + seenArgs = args; + }, + ); + await h.client.listTools(); + + const result = await h.client.callTool({ + name: "widget-refresh", + arguments: { search_id: "abc123" }, + }); + expect(result.isError).toBeFalsy(); + expect(seenArgs).toEqual({ search_id: "abc123" }); + expect(toolCallEvents(h.events)[0]?.intent_signals).toBeUndefined(); + expect(fallback).not.toHaveBeenCalled(); + + // The fallback still serves ordinary tools on the same server. + await h.client.callTool({ name: "search", arguments: { query: "boots" } }); + expect(fallback).toHaveBeenCalledTimes(1); + expect(toolCallEvents(h.events)[1]?.intent_signals).toEqual({ + intent: "machine noise", + source: "inferred", + }); + }); + + it("still captures and strips context when a model call supplies it", async () => { + let seenArgs: unknown; + const h = await setupWidget({ "openai/widgetAccessible": true }, INTENT_ON, (args) => { + seenArgs = args; + }); + await h.client.listTools(); + + const result = await h.client.callTool({ + name: "widget-refresh", + arguments: { search_id: "abc123", context: "Fetching offer details the user asked about." }, + }); + + expect(result.isError).toBeFalsy(); + expect(seenArgs).toEqual({ search_id: "abc123" }); + const event = toolCallEvents(h.events)[0]; + expect(event?.intent_signals).toEqual({ + intent: "Fetching offer details the user asked about.", + source: "context_parameter", + }); + // The stripped context must not leak into input capture — the widget + // branch must assert no less than the model-tool test above. + expect(event?.input_values).not.toHaveProperty("context"); + expect(event?.input_keys).not.toHaveProperty("context"); + }); +}); + +describe("intent capture — widget classification without listed _meta", () => { + // MCP SDK versions before 1.18.0 accept `_meta` in registerTool but drop + // it: it reaches neither the registry nor the tools/list entries. The + // registration-time record (noteToolRegistration's meta argument, fed by + // the proxy's registerTool interceptor) is then the only signal, so the + // wrapped list handler must classify from it even when the listed entry + // carries no _meta. Exercised against a stub low-level server because the + // MCP SDK installed in this repo always forwards _meta. + beforeEach(() => _resetGlobalState()); + + function installOnStub(intent: IntentConfig, listedTools: Array>) { + const handlers = new Map Promise>(); + handlers.set("tools/list", async () => ({ tools: listedTools })); + const controller = createIntentController(intent); + controller.install({ + server: { setRequestHandler: () => {}, _requestHandlers: handlers }, + _registeredTools: {}, + } as unknown as McpServer); + return { controller, callList: () => handlers.get("tools/list")?.({}, {}) }; + } + + it("classifies from the registration-time _meta when the listed entry carries none", async () => { + const { controller, callList } = installOnStub(INTENT_ON, [ + { + name: "widget-refresh", + inputSchema: { type: "object", properties: { search_id: { type: "string" } } }, + }, + { + name: "search", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ]); + controller.noteToolRegistration("widget-refresh", [], { "openai/widgetAccessible": true }); + controller.noteToolRegistration("search", []); + + const result = (await callList()) as { + tools: Array<{ name: string; inputSchema: { properties: object; required?: string[] } }>; + }; + const widget = result.tools.find((t) => t.name === "widget-refresh"); + const search = result.tools.find((t) => t.name === "search"); + expect(widget?.inputSchema.properties).toHaveProperty("context"); + expect(widget?.inputSchema.required ?? []).not.toContain("context"); + expect(search?.inputSchema.required).toContain("context"); + }); + + it("removes a pre-existing 'context' entry from a widget tool's required array", async () => { + const { callList } = installOnStub(INTENT_ON, [ + { + name: "widget-refresh", + _meta: { "openai/widgetAccessible": true }, + inputSchema: { + type: "object", + properties: { search_id: { type: "string" } }, + // Legal JSON Schema: required may name keys that properties omits — + // e.g. a stale leftover after the property itself was removed. Kept + // as-is it would defeat the widget exemption silently. + required: ["search_id", "context"], + }, + }, + ]); + const result = (await callList()) as { tools: Array<{ inputSchema: { required?: string[] } }> }; + expect(result.tools[0]?.inputSchema.required).toEqual(["search_id"]); + }); + + it("strips a stale required 'context' on ordinary tools under required: false", async () => { + // The stale-required cleanup is not widget-specific: with intent + // configured optional, a leftover required: ["context"] in a customer + // schema would otherwise ship `context` as required against the + // operator's explicit configuration — the hard-validating-client + // breakage `required: false` exists to avoid. + const { callList } = installOnStub({ ...INTENT_ON, required: false }, [ + { + name: "search", + inputSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query", "context"], + }, + }, + ]); + const result = (await callList()) as { tools: Array<{ inputSchema: { required?: string[] } }> }; + expect(result.tools[0]?.inputSchema.required).toEqual(["query"]); + }); +}); diff --git a/packages/sdk/src/core/version.ts b/packages/sdk/src/core/version.ts index 3c6a260..6899868 100644 --- a/packages/sdk/src/core/version.ts +++ b/packages/sdk/src/core/version.ts @@ -10,4 +10,4 @@ * * Keep this in step with packages/sdk/package.json — a test asserts they match. */ -export const SDK_VERSION = "0.3.1"; +export const SDK_VERSION = "0.3.2"; diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 99fbf65..8f5819d 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -37,8 +37,12 @@ export function withYavio(server: T, options?: WithYavioOpt } if (config.intent.enabled) { + // `required` is independently configurable; the log must not claim + // "required" for a server that was explicitly configured optional. console.info( - "[yavio] Intent capture enabled: tools advertise a required 'context' parameter (pass intent: false to disable).", + config.intent.required + ? "[yavio] Intent capture enabled: tools advertise a 'context' parameter — required on model-facing tools, optional on widget-invoked ones (pass intent: false to disable)." + : "[yavio] Intent capture enabled: tools advertise an optional 'context' parameter on every tool (pass intent: false to disable).", ); } diff --git a/packages/sdk/src/server/intent.ts b/packages/sdk/src/server/intent.ts index 34784d5..5566dd9 100644 --- a/packages/sdk/src/server/intent.ts +++ b/packages/sdk/src/server/intent.ts @@ -14,6 +14,12 @@ import type { IntentConfig } from "../core/types.js"; * handler never sees it. Captured intents reach the tool_call event through * AsyncLocalStorage. * + * Exception: on widget-invoked tools (`openai/widgetAccessible`, MCP Apps + * `ui.visibility: ["app"]`) `context` is advertised as OPTIONAL — the widget + * iframe calls those tools without it, and a required parameter would make + * the host refuse every such call once it refreshes cached schemas. Capture + * still applies when the value is present. See metaIndicatesWidget. + * * Registered tool schemas are never modified: mixing our Zod instance into a * customer shape can throw ("Mixed Zod versions detected") and strict schemas * would reject the extra key. Everything happens at the protocol layer. @@ -182,9 +188,12 @@ export interface IntentController { /** * Record a tool registration seen by the proxy. `schemas` are the candidate * schema-shaped arguments; the tool is eligible for capture only when none - * of them defines its own `context` key. + * of them defines its own `context` key. `meta` is the registration + * config's `_meta`, when the call form carries one — the only reliable + * widget-invokability source on MCP SDKs older than 1.18.0, which drop + * `_meta` before it reaches the registry or tools/list. */ - noteToolRegistration(toolName: string, schemas: unknown[]): void; + noteToolRegistration(toolName: string, schemas: unknown[], meta?: unknown): void; /** Patch the underlying low-level server. Idempotent per server. */ install(server: McpServer): void; } @@ -198,6 +207,24 @@ export function createIntentController(config: IntentConfig): IntentController { // harmless, deleting a genuine customer argument is not. const hasOwnContext = new Map(); + // toolName -> widget may invoke this tool. Fed from three places: the + // registration-time config's `_meta` (via noteToolRegistration), the live + // registry at install(), and listed entries at tools/list time. The + // registration-time record matters most: MCP SDK versions before 1.18.0 + // accept `_meta` in registerTool but drop it — it never reaches the + // registry or tools/list — so without this record the widget exemption + // would be silently inert on every supported version below 1.18 while the + // proxy's registerTool interceptor saw the truth all along. + const widgetInvoked = new Map(); + + const isWidgetTool = (toolName: unknown): boolean => + typeof toolName === "string" && widgetInvoked.get(toolName) === true; + + // Once per tool: config demanded required context, the widget exemption + // overrode it. Without this line the override is indistinguishable from a + // client that stopped filling the parameter. + const loggedWidgetOverride = new Set(); + // The McpServer this controller is installed on — used to consult the LIVE // registered schema at call time, so RegisteredTool.update() and tools // registered before withYavio() are classified correctly without waiting @@ -245,7 +272,12 @@ export function createIntentController(config: IntentConfig): IntentController { downstream = { ...req, params: { ...req.params, arguments: rest } }; } } - if (!captured && config.fallback) { + // No fallback for context-less calls on widget-invoked tools: those + // are presumptively the widget's own machine traffic (a 3s auto- + // refresh, a filter-bar click), and inferring an intent for each would + // record boilerplate "inferred" entries at machine frequency, + // drowning the real intents the fallback exists to approximate. + if (!captured && config.fallback && !isWidgetTool(toolName)) { try { const inferred = normalizeIntent(await config.fallback(toolName, args)); if (inferred) captured = { intent: inferred, source: "inferred" }; @@ -264,12 +296,58 @@ export function createIntentController(config: IntentConfig): IntentController { interface ToolEntry { name?: unknown; inputSchema?: Record; + _meta?: unknown; [key: string]: unknown; } const isPlainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + /** + * Does this `_meta` mark a tool the app's own widget (iframe) may invoke, + * rather than only the model? Signals, in order of authority: + * + * 1. `openai/widgetAccessible: true` (OpenAI Apps SDK). + * 2. An explicit MCP Apps visibility — nested `ui.visibility` wins over the + * flat `ui/visibility` key (both exist in the wild; the fallthrough must + * be `??`, not a ternary, or a nested `ui` object shadows the flat key). + * A bare string `"app"` is accepted alongside the spec's array form: the + * off-spec authoring slip is cheap to tolerate and misreading it would + * recreate exactly the failure this code exists to prevent. + * 3. No visibility at all but a `resourceUri` (nested or flat): MCP Apps + * defaults omitted visibility to ["model", "app"], so a tool that + * participates in UI is app-callable unless it says otherwise. Skybridge + * emits precisely this shape for every view-owning tool. An app whose + * widget never calls its view tool can keep required `context` there by + * declaring `ui.visibility: ["model"]` explicitly. + * + * Why this matters: widget calls carry only the tool's real arguments — an + * iframe cannot know to send `context` — so advertising `context` as + * REQUIRED on such a tool makes the host refuse every widget call as + * schema-invalid. The failure is delayed (hosts cache connector schemas + * until a refresh) and invisible server-side (the calls never arrive). + * Observed in production on 2026-08-07: a widget's 3s auto-refresh went + * from 83 calls/day to zero the moment the connector re-fetched schemas. + * + * There is no valid use of a required `context` on a widget-invoked tool, + * so this is enforced automatically rather than left to configuration. + */ + function metaIndicatesWidget(meta: unknown): boolean { + if (!isPlainObject(meta)) return false; + if (meta["openai/widgetAccessible"] === true) return true; + + const ui = isPlainObject(meta.ui) ? meta.ui : undefined; + const visibility = (ui ? ui.visibility : undefined) ?? meta["ui/visibility"]; + if (visibility !== undefined) { + if (visibility === "app") return true; + return Array.isArray(visibility) && visibility.includes("app"); + } + + if (ui && typeof ui.resourceUri === "string") return true; + if (typeof meta["ui/resourceUri"] === "string") return true; + return false; + } + function injectIntoListedTool(tool: ToolEntry): ToolEntry { const name = typeof tool?.name === "string" ? tool.name : undefined; const schema = tool?.inputSchema; @@ -307,11 +385,40 @@ export function createIntentController(config: IntentConfig): IntentController { const properties = isPlainObject(copy.properties) ? copy.properties : {}; properties.context = { type: "string", description: config.description }; copy.properties = properties; - if (config.required) { + + // Classify from the listed `_meta` when the MCP SDK forwarded one; + // otherwise fall back to what the registration-time config declared + // (pre-1.18 MCP SDKs drop `_meta` before it reaches tools/list). + const widget = + tool._meta !== undefined + ? metaIndicatesWidget(tool._meta) + : name !== undefined && widgetInvoked.get(name) === true; + if (name) widgetInvoked.set(name, widget); + + // From here on the SDK owns the `context` key. A customer schema may + // already name "context" in `required` without declaring the property + // (legal JSON Schema, e.g. a stale leftover) — kept as-is it would ship + // `context` as required against the policy below, on widget tools AND + // under `required: false`. Strip it first; the policy branch is then the + // only thing that can add it back. + if (Array.isArray(copy.required)) { + copy.required = (copy.required as unknown[]).filter((k) => k !== "context"); + } + + // Widget-invoked tools get `context` as optional regardless of config: + // capture still works when a model call fills it, while the widget's own + // context-less calls stay schema-valid. See metaIndicatesWidget. + if (config.required && !widget) { const required = Array.isArray(copy.required) ? (copy.required as unknown[]) : []; if (!required.includes("context")) required.push("context"); copy.required = required; } + if (widget && config.required && name && !loggedWidgetOverride.has(name)) { + loggedWidgetOverride.add(name); + console.info( + `[yavio] Intent context advertised as OPTIONAL on widget-invoked tool "${name}" (a required parameter would make the host refuse the widget's own calls). Model calls that fill it are still captured.`, + ); + } return { ...tool, inputSchema: copy }; } @@ -339,12 +446,16 @@ export function createIntentController(config: IntentConfig): IntentController { } return { - noteToolRegistration(toolName, schemas) { + noteToolRegistration(toolName, schemas, meta) { // Only positive determination enables capture. Any schema-ish argument // carrying a `context` key (including annotations — false positives are // safe) marks the tool as owning the parameter. const owns = schemas.some((s) => shapeHasContext(s)); hasOwnContext.set(toolName, owns); + // Record widget-invokability from the registration config. Only when a + // `_meta` was actually supplied: absence here must not erase a value a + // more informed source recorded. + if (meta !== undefined) widgetInvoked.set(toolName, metaIndicatesWidget(meta)); }, install(server) { @@ -362,6 +473,11 @@ export function createIntentController(config: IntentConfig): IntentController { try { for (const [name, tool] of Object.entries(toolsHost._registeredTools ?? {})) { hasOwnContext.set(name, shapeHasContext(tool?.inputSchema)); + // Same guard as noteToolRegistration: only a present `_meta` may + // write — old MCP SDKs never store one, and undefined must not + // erase a registration-time record. + const meta = (tool as { _meta?: unknown } | undefined)?._meta; + if (meta !== undefined) widgetInvoked.set(name, metaIndicatesWidget(meta)); } } catch { // Registry unreadable — classification falls back to tools/list time diff --git a/packages/sdk/src/server/proxy.ts b/packages/sdk/src/server/proxy.ts index 574ac5e..7f269c6 100644 --- a/packages/sdk/src/server/proxy.ts +++ b/packages/sdk/src/server/proxy.ts @@ -431,9 +431,14 @@ export function createProxy( : typeof configArg?.name === "string" ? configArg.name : "unknown"; + // Pass the config's `_meta` too: MCP SDK versions before 1.18.0 + // accept it here but drop it before the registry and tools/list, so + // this interceptor is the only place the widget-invokability signal + // is guaranteed to be visible on every supported version. intent?.noteToolRegistration( toolName, configArg?.inputSchema ? [configArg.inputSchema] : [], + configArg?._meta, ); if (cbIndex !== -1) { const originalCb = args[cbIndex] as (...cbArgs: unknown[]) => unknown;