diff --git a/.changeset/mcp-platform-events-subscribable.md b/.changeset/mcp-platform-events-subscribable.md new file mode 100644 index 000000000..364e9a674 --- /dev/null +++ b/.changeset/mcp-platform-events-subscribable.md @@ -0,0 +1,5 @@ +--- +"@sapiom/mcp": patch +--- + +The schedule tool now says `sapiom.*` platform events can be subscribed to with an `event` trigger (never emitted) and points to the server instructions for the list; the bundled primer now documents `sapiom.run.failed`. diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 22884240b..1bbe10b5e 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -1113,11 +1113,13 @@ describe("interceptor CDP decisions", () => { await new Promise((resolve) => setTimeout(resolve, 40)); expect(calls).toBe(1); release(); + // `refused` is emitted once the failRequest reply comes back, after the stub records the command. await waitFor( () => stub.commands.filter( (command) => command.method === "Fetch.failRequest", - ).length >= 2, + ).length >= 2 && + events.filter((event) => event.type === "refused").length >= 2, "both failed", ); expect(calls).toBe(1); diff --git a/packages/mcp/src/instructions.generated.ts b/packages/mcp/src/instructions.generated.ts index a55ce7d6d..5f18d3666 100644 --- a/packages/mcp/src/instructions.generated.ts +++ b/packages/mcp/src/instructions.generated.ts @@ -1,6 +1,5 @@ // GENERATED by scripts/mcp-instructions-snapshot.mjs — do not edit. // Source: GET https://api.sapiom.ai/v1/mcp/instructions -// Content release 2.14, sha-256 055076ab6773f92133a88da8aae71e2130f4c693a79779176c3e0707cb4ffeac. // // This is the bundled snapshot of the authoring primer: the last source // instructions-fetch.ts falls back to, after the live copy and the last-known-good @@ -69,6 +68,15 @@ and self-contained; republishing the same slug replaces the app in place at the Org-scoped by default; \`public\` needs an explicit confirmation and a daily spend cap because your org pays for every wake. See https://docs.sapiom.ai/capabilities/app-links. +Never put a Sapiom API key in an App Link's env: publish rejects \`SAPIOM_API_KEY\` there. Each +publish mints the link its own key in the publishing org, injects it at wake as +\`SAPIOM_API_KEY\` (with \`SAPIOM_API_URL\`, plus \`SAPIOM_DEFINITION_ID\` when an agent's run +published it), and revokes the previous one; deleting the link revokes it too. The key is +least-privilege: it reads only the publishing agent's own secrets, so +\`vault.get("workflow:" + process.env.SAPIOM_DEFINITION_ID, key)\` from \`@sapiom/tools\` works +with no setup, and no other Vault ref does; it holds \`org.read\` only if the publisher did, and +it cannot write or spend. + An App Link is a wake-on-visit **redirector, not a reverse proxy**: no sub-path is forwarded, so the app's own API lives at whichever preview URL is serving the app, never under the link. To get that address, request the root WITHOUT following redirects — a WARM app answers 302 and @@ -117,6 +125,11 @@ into a webhook trigger. rotates or revokes a webhook secret. The \`event\` and \`webhook\` kinds and the secret tool need \`@sapiom/mcp\` >= 0.15 — on an older client, use the REST trigger routes in the guide: https://docs.sapiom.ai/guides/triggers. +\`sapiom.*\` event types are reserved, except \`sapiom.run.failed\`: the platform emits it when a run +of another agent in this tenant ends \`failed\` (never to the failing agent, and never again from a +run it started), with \`definitionId\`, \`slug\`, \`definitionName\`, \`executionId\`, \`failedStep\`, +\`attempt\` (0-based), \`faultClass\`, \`startedAt\` and \`finishedAt\` (no error text: read the run). +Listen with an \`event\` trigger; the events API refuses it. ## Canonical rules (types are the source of truth — run \`npm run typecheck\`) - Import \`defineAgent\`, \`defineStep\`, and the directives @@ -158,6 +171,10 @@ https://docs.sapiom.ai/guides/triggers. may be present). For structured/JSON output, use the \`output\` param — it forces a tool call under the hood, so the payload is always the \`tool_use\` block's \`input\` — never "reply with only JSON" + string parsing. +- **Fixed answer set and you need a probability → \`ctx.sapiom.decisions.evaluate\`** — yes/no, pick-one, or + rubric score with a System One decision model. Ask all independent questions over the same state + in one call. Use separate calls when a question needs an earlier answer. It is not a security + boundary and is weak at arithmetic/date math — keep those in code. - **Platform-driven agent loop → \`ctx.sapiom.models.run\`** — a multi-turn reasoning + tool-calling task (minutes, not seconds). \`models.coding.run\` for sandboxed coding tasks. Never use this for a one-shot completion — it will loop and overthink. @@ -181,8 +198,8 @@ https://docs.sapiom.ai/guides/connect-claude-code-with-mcp, plus the \`AGENTS.md your scaffolded project.`; /** `X-Sapiom-Content-Release` of the response this snapshot was taken from. */ -export const AUTHORING_INSTRUCTIONS_RELEASE = "2.14"; +export const AUTHORING_INSTRUCTIONS_RELEASE = "2026-10-03"; /** Full sha-256 hex of {@link AUTHORING_INSTRUCTIONS}; the served header carries its first 12 characters. */ export const AUTHORING_INSTRUCTIONS_DIGEST = - "055076ab6773f92133a88da8aae71e2130f4c693a79779176c3e0707cb4ffeac"; + "00bebe154f800dac263cd875b151ae647b8eb66bd914ae996c82ecc8f9f8be6e"; diff --git a/packages/mcp/src/instructions.test.ts b/packages/mcp/src/instructions.test.ts index b6bc642b1..894979341 100644 --- a/packages/mcp/src/instructions.test.ts +++ b/packages/mcp/src/instructions.test.ts @@ -33,7 +33,10 @@ describe("server instructions", () => { // from the served endpoint, body and digest together. Editing the body by hand // fails this; the fix is to re-run the script, never to re-point the digest. expect(AUTHORING_INSTRUCTIONS_DIGEST).toMatch(/^[0-9a-f]{64}$/); - expect(AUTHORING_INSTRUCTIONS_RELEASE).toMatch(/^\d+\.\d+$/); + // A release id is either a version (2.14) or a date (YYYY-MM-DD). + expect(AUTHORING_INSTRUCTIONS_RELEASE).toMatch( + /^(\d+\.\d+|\d{4}-\d{2}-\d{2})$/, + ); const sha256 = createHash("sha256") .update(AUTHORING_INSTRUCTIONS, "utf8") .digest("hex"); diff --git a/packages/mcp/src/tools/agents.schedules.test.ts b/packages/mcp/src/tools/agents.schedules.test.ts index 030ed75f4..b1b83956c 100644 --- a/packages/mcp/src/tools/agents.schedules.test.ts +++ b/packages/mcp/src/tools/agents.schedules.test.ts @@ -199,6 +199,91 @@ describe("agent schedule MCP tools", () => { expect(out.hint).toContain("/v1/workflows/events"); }); + // SAP-3864: platform events are subscribable but never emitted, so the hint must not send + // the agent to the emit route. The rule is the 'sapiom.' prefix, not a list of types. + function platformEventTrigger(eventType: string) { + return { + id: "trig-4", + kind: "event", + status: "active", + definitionSlug: "watchdog", + cron: null, + timezone: null, + eventType, + publicId: null, + secretVersion: null, + graceUntil: null, + revokedAt: null, + nextFireAt: null, + createdAt: "x", + input: {}, + startAt: null, + endAt: null, + policy: null, + recentFires: [], + } as never; + } + + function expectPlatformEventHint(hint: string) { + expect(hint).toContain("platform"); + expect(hint).toContain("Triggers"); + expect(hint).not.toContain("sapiom_dev_agents_emit_event"); + expect(hint).not.toContain("/v1/workflows/events"); + } + + it("event trigger on sapiom.run.failed hints that the platform emits it", async () => { + vi.mocked(createSchedule).mockResolvedValue( + platformEventTrigger("sapiom.run.failed"), + ); + const { server, handlers } = createMockServer(); + register(server, env); + + const res = await handlers.get("sapiom_dev_agents_schedule")!({ + definition: "watchdog", + kind: "event", + eventType: "sapiom.run.failed", + }); + + const out = parse(res); + expect(out.hint).toContain("sapiom.run.failed"); + expectPlatformEventHint(out.hint); + }); + + it("any sapiom.* event trigger gets the platform hint (prefix rule)", async () => { + vi.mocked(getSchedule).mockResolvedValue( + platformEventTrigger("sapiom.run.succeeded"), + ); + const { server, handlers } = createMockServer(); + register(server, env); + + const res = await handlers.get("sapiom_dev_agents_schedule_inspect")!({ + scheduleId: "trig-4", + }); + + expectPlatformEventHint(parse(res).hint); + }); + + it("schedule tool text says sapiom.* is a subscribable platform namespace", () => { + const { server } = createMockServer(); + register(server, env); + const call = vi + .mocked(server.tool) + .mock.calls.find((c) => c[0] === "sapiom_dev_agents_schedule")!; + const schema = call[2] as unknown as { + kind: { description: string }; + eventType: { description: string }; + }; + const eventTypeDoc = schema.eventType.description; + expect(eventTypeDoc).toContain("'sapiom.*' types are platform events"); + expect(eventTypeDoc).toContain("never emit"); + expect(eventTypeDoc).toContain("'Triggers'"); + // The list lives in the served primer, so the tool names no platform type. + expect(eventTypeDoc).not.toContain("sapiom.run.failed"); + expect(call[1]).toContain("platform emits a 'sapiom.*' event"); + expect(call[1]).toContain("'Triggers'"); + expect(schema.kind.description).toContain("platform 'sapiom.*' event"); + }); + it("webhook trigger create returns the hook URL, the shown-once secret, and the signing scheme", async () => { vi.mocked(createSchedule).mockResolvedValue({ id: "trig-3", diff --git a/packages/mcp/src/tools/agents.ts b/packages/mcp/src/tools/agents.ts index 733f0e4db..2bddc33d5 100644 --- a/packages/mcp/src/tools/agents.ts +++ b/packages/mcp/src/tools/agents.ts @@ -114,8 +114,11 @@ function scheduleHint(schedule: ScheduleDetail): string | undefined { return `Active — next fire at ${schedule.nextFireAt}.`; if (schedule.status === "active" && schedule.kind === "webhook") return `Armed — fires on every signed POST to the hook URL (publicId ${schedule.publicId}, secret v${schedule.secretVersion}). The secret was shown once at create/rotate time; rotate with sapiom_dev_agents_schedule_secret if it is lost.`; - if (schedule.status === "active" && schedule.kind === "event") + if (schedule.status === "active" && schedule.kind === "event") { + if (schedule.eventType?.startsWith("sapiom.")) + return `Armed: fires on every '${schedule.eventType}' platform event. The platform emits it; it cannot be emitted by hand (see the server instructions, section 'Triggers').`; return `Armed — fires on every '${schedule.eventType}' event this tenant emits (sapiom_dev_agents_emit_event, or POST /v1/workflows/events with { type, payload }).`; + } if (schedule.status === "completed") return "Completed — no further fires."; if (schedule.status === "disabled") return schedule.revokedAt @@ -714,7 +717,7 @@ export function register(server: McpServer, env: ResolvedEnvironment): void { registerTool( server, "sapiom_dev_agents_schedule", - "Create a trigger for a deployed agent — one of four kinds. 'schedule_cron' (+ cron, optional timezone) runs it on a recurring schedule; 'schedule_once' (+ at) runs it once at a future time; 'event' (+ eventType) runs it every time this tenant emits that event type via POST /v1/workflows/events; 'webhook' runs it every time an external system POSTs to a public hook URL — the result returns that URL plus a shown-once signing secret and the HMAC-SHA256 scheme the sender must use. Use 'webhook' when an external system should start the agent (\"run when X POSTs to us\") instead of hand-building an HTTP server; third-party senders with their own signature scheme (Slack, Meta, Stripe, GitHub) cannot produce our HMAC, so route those through an App Link /hook/* receiver or a translator. Returns the trigger with its next fire time where it has one. Tip: validate a cron with sapiom_dev_agents_cron_preview first.", + "Create a trigger for a deployed agent — one of four kinds. 'schedule_cron' (+ cron, optional timezone) runs it on a recurring schedule; 'schedule_once' (+ at) runs it once at a future time; 'event' (+ eventType) runs it every time this tenant emits that event type via POST /v1/workflows/events, or the platform emits a 'sapiom.*' event (list in the server instructions, section 'Triggers'); 'webhook' runs it every time an external system POSTs to a public hook URL — the result returns that URL plus a shown-once signing secret and the HMAC-SHA256 scheme the sender must use. Use 'webhook' when an external system should start the agent (\"run when X POSTs to us\") instead of hand-building an HTTP server; third-party senders with their own signature scheme (Slack, Meta, Stripe, GitHub) cannot produce our HMAC, so route those through an App Link /hook/* receiver or a translator. Returns the trigger with its next fire time where it has one. Tip: validate a cron with sapiom_dev_agents_cron_preview first.", { definition: z .string() @@ -724,7 +727,7 @@ export function register(server: McpServer, env: ResolvedEnvironment): void { kind: z .enum(["schedule_cron", "schedule_once", "event", "webhook"]) .describe( - "'schedule_cron' = recurring; 'schedule_once' = a single delayed run; 'event' = fires on a tenant-emitted event type; 'webhook' = fires on a signed POST to a public URL minted for this trigger.", + "'schedule_cron' = recurring; 'schedule_once' = a single delayed run; 'event' = fires on an event type the tenant emits, or on a platform 'sapiom.*' event; 'webhook' = fires on a signed POST to a public URL minted for this trigger.", ), cron: z .string() @@ -748,7 +751,7 @@ export function register(server: McpServer, env: ResolvedEnvironment): void { .string() .optional() .describe( - "Event type to match — required for 'event'. Lowercase dot-separated segments, e.g. 'lead.created'; the 'sapiom.*' namespace is reserved. Emit it with sapiom_dev_agents_emit_event, or POST /v1/workflows/events { type, payload }.", + "Event type to match — required for 'event'. Lowercase dot-separated segments, e.g. 'lead.created'. Emit your own types with sapiom_dev_agents_emit_event, or POST /v1/workflows/events { type, payload }. 'sapiom.*' types are platform events: subscribe to one, never emit it; which ones exist and what they carry is in the server instructions, section 'Triggers'.", ), input: z .unknown() diff --git a/scripts/mcp-instructions-snapshot.mjs b/scripts/mcp-instructions-snapshot.mjs index d5ffef6bc..72cae75d5 100644 --- a/scripts/mcp-instructions-snapshot.mjs +++ b/scripts/mcp-instructions-snapshot.mjs @@ -78,7 +78,6 @@ function toTemplateLiteral(text) { export function renderModule({ body, release, digest, apiURL }) { return `// GENERATED by scripts/mcp-instructions-snapshot.mjs — do not edit. // Source: GET ${apiURL}${INSTRUCTIONS_PATH} -// Content release ${release}, sha-256 ${digest}. // // This is the bundled snapshot of the authoring primer: the last source // instructions-fetch.ts falls back to, after the live copy and the last-known-good diff --git a/scripts/mcp-instructions-snapshot.test.mjs b/scripts/mcp-instructions-snapshot.test.mjs index b03044855..a3f72ced6 100644 --- a/scripts/mcp-instructions-snapshot.test.mjs +++ b/scripts/mcp-instructions-snapshot.test.mjs @@ -8,6 +8,10 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import prettier from "prettier"; + +import { OUTPUT_PATH, renderModule } from "./mcp-instructions-snapshot.mjs"; + const SCRIPT = fileURLToPath( new URL("./mcp-instructions-snapshot.mjs", import.meta.url), ); @@ -164,3 +168,23 @@ test("writes a stamped body without its footer, then is a no-op on the second ru assert.match(second.stdout, /already matches/); assert.equal(await readFile(out, "utf8"), generated); }); + +// A hand edit anywhere in the committed snapshot, header included, fails this offline: +// the file must be exactly what the generator renders from its own constants. +test("the committed snapshot is the generator's output for its own body", async () => { + // The generated file holds no types, so it loads as JavaScript on every supported Node. + const committed = await readFile(OUTPUT_PATH, "utf8"); + const snapshot = await import( + `data:text/javascript,${encodeURIComponent(committed)}` + ); + const rendered = await prettier.format( + renderModule({ + body: snapshot.AUTHORING_INSTRUCTIONS, + release: snapshot.AUTHORING_INSTRUCTIONS_RELEASE, + digest: snapshot.AUTHORING_INSTRUCTIONS_DIGEST, + apiURL: "https://api.sapiom.ai", + }), + { filepath: OUTPUT_PATH }, + ); + assert.equal(committed, rendered); +});