okengine 0.23.1 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. package/src/runtime/types.ts +5 -0
@@ -12,9 +12,14 @@ import type { WideEvent } from "../runs/types.ts";
12
12
  import { authorizeToolCall, expandOperatorScopes } from "./authorization.ts";
13
13
  import { MCP_CONFIRM_PHRASE } from "./confirmation.ts";
14
14
  import { asData, isDataEnvelope, MCP_DATA_KIND } from "./data.ts";
15
- import { authenticateMcpRequest, mintMcpSession, MCP_AUDIENCE } from "./session.ts";
15
+ import {
16
+ authenticateMcpRequest,
17
+ mintMcpSession,
18
+ MCP_AUDIENCE,
19
+ type McpRequester,
20
+ } from "./session.ts";
16
21
  import { createMcpServer } from "./server.ts";
17
- import { createToolRuntime } from "./tools.ts";
22
+ import { createToolRuntime, type McpContext } from "./tools.ts";
18
23
  import { MCP_CLIENT_PROTOCOL_VERSION, MCP_PROTOCOL_VERSION } from "./versions.ts";
19
24
 
20
25
  const SECRET = "mcp-test-secret";
@@ -106,6 +111,69 @@ describe("MCP audience validation", () => {
106
111
  });
107
112
  });
108
113
 
114
+ const CONFIRM_REASON = "operator approved test invoke";
115
+
116
+ function stringField(content: unknown, key: string): string {
117
+ if (content === null || typeof content !== "object" || Array.isArray(content)) {
118
+ throw new Error(`expected object content for ${key}`);
119
+ }
120
+ const value = (content as Record<string, unknown>)[key];
121
+ if (typeof value !== "string" || value.length === 0) {
122
+ throw new Error(`expected string ${key}`);
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function runtimeWithInvoke(onInvoke: () => void): ReturnType<typeof createToolRuntime> {
128
+ const ctx: McpContext = {
129
+ getManifest: () => SAMPLE_MANIFEST,
130
+ listRuns: async () => [],
131
+ invokeFlow: async (input) => {
132
+ onInvoke();
133
+ return { ok: true, flowId: input.flowId };
134
+ },
135
+ proposeStructural: async () => {
136
+ onInvoke();
137
+ return { ok: true };
138
+ },
139
+ };
140
+ return createToolRuntime(ctx);
141
+ }
142
+
143
+ async function twoSessionsOf(
144
+ principalId: string,
145
+ ): Promise<{ readonly a: McpRequester; readonly b: McpRequester }> {
146
+ const store = createSessionStore();
147
+ const mint = () =>
148
+ mintMcpSession({
149
+ store,
150
+ secret: SECRET,
151
+ principalId,
152
+ scopes: ["console:*"],
153
+ });
154
+ const issuedA = await mint();
155
+ const issuedB = await mint();
156
+ const a = await authenticateMcpRequest(store, SECRET, issuedA.accessToken);
157
+ const b = await authenticateMcpRequest(store, SECRET, issuedB.accessToken);
158
+ return { a, b };
159
+ }
160
+
161
+ async function openConfirmation(
162
+ runtime: ReturnType<typeof createToolRuntime>,
163
+ requester: McpRequester,
164
+ tool: string,
165
+ args: Record<string, unknown>,
166
+ ): Promise<string> {
167
+ const opened = await runtime.callTool(requester, tool, args);
168
+ expect(opened.ok).toBe(false);
169
+ if (opened.ok) throw new Error("expected confirmation challenge");
170
+ const confirmationId = stringField(opened.data.content, "confirmationId");
171
+ expect(confirmationId.startsWith("mcp_id_")).toBe(true);
172
+ expect(JSON.stringify(opened.data)).not.toContain("mcp_c_");
173
+ expect(JSON.stringify(opened.data)).not.toContain("confirmToken");
174
+ return confirmationId;
175
+ }
176
+
109
177
  describe("MCP write confirmation", () => {
110
178
  test("agent cannot invoke a write tool without confirmation", async () => {
111
179
  const decision = authorizeToolCall(
@@ -119,12 +187,8 @@ describe("MCP write confirmation", () => {
119
187
  expect(decision.reason).toBe("confirmation-required");
120
188
  }
121
189
 
122
- const runtime = createToolRuntime({
123
- getManifest: () => SAMPLE_MANIFEST,
124
- listRuns: async () => [],
125
- invokeFlow: async () => {
126
- throw new Error("must not invoke");
127
- },
190
+ const runtime = runtimeWithInvoke(() => {
191
+ throw new Error("must not invoke");
128
192
  });
129
193
  const store = createSessionStore();
130
194
  const issued = await mintMcpSession({
@@ -143,59 +207,211 @@ describe("MCP write confirmation", () => {
143
207
  expect(result.code).toBe("forbidden");
144
208
  expect(result.message).toContain("confirmation");
145
209
  expect(isDataEnvelope(result.data)).toBe(true);
210
+ const confirmationId = stringField(result.data.content, "confirmationId");
211
+ expect(confirmationId.startsWith("mcp_id_")).toBe(true);
212
+ expect(JSON.stringify(result.data)).not.toContain("mcp_c_");
213
+ expect(JSON.stringify(result.data)).not.toContain("confirmToken");
146
214
  }
147
215
  });
148
216
 
149
- test("write succeeds only with fresh per-call confirmation (no consent cache)", async () => {
217
+ test("one session cannot request, confirm, and invoke", async () => {
150
218
  let invoked = 0;
151
- const runtime = createToolRuntime({
152
- getManifest: () => SAMPLE_MANIFEST,
153
- listRuns: async () => [],
154
- invokeFlow: async (input) => {
155
- invoked += 1;
156
- return { ok: true, flowId: input.flowId };
157
- },
219
+ const runtime = runtimeWithInvoke(() => {
220
+ invoked += 1;
158
221
  });
159
- const store = createSessionStore();
160
- const issued = await mintMcpSession({
161
- store,
162
- secret: SECRET,
163
- principalId: "op1",
164
- scopes: ["console:*"],
222
+ const { a } = await twoSessionsOf("op1");
223
+ const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
224
+ const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
225
+
226
+ const confirm = await runtime.callTool(a, "oke.action.confirm", {
227
+ confirmationId,
228
+ reason: CONFIRM_REASON,
165
229
  });
166
- const requester = await authenticateMcpRequest(store, SECRET, issued.accessToken);
230
+ expect(confirm.ok).toBe(false);
231
+ if (!confirm.ok) {
232
+ expect(confirm.message).toContain("different auth session");
233
+ expect(JSON.stringify(confirm.data)).not.toContain("mcp_c_");
234
+ }
235
+
236
+ const invoke = await runtime.callTool(a, "oke.action.invoke", {
237
+ ...actionArgs,
238
+ confirmation: MCP_CONFIRM_PHRASE,
239
+ reason: CONFIRM_REASON,
240
+ });
241
+ expect(invoke.ok).toBe(false);
242
+ expect(invoked).toBe(0);
243
+ });
244
+
245
+ test("a second session of the same principal confirms and the first invokes once", async () => {
246
+ let invoked = 0;
247
+ const runtime = runtimeWithInvoke(() => {
248
+ invoked += 1;
249
+ });
250
+ const { a, b } = await twoSessionsOf("op1");
251
+ expect(a.principalId).toBe(b.principalId);
252
+ expect(a.sessionId).not.toBe(b.sessionId);
167
253
 
168
254
  const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
169
- const confirm = await runtime.callTool(requester, "oke.action.confirm", {
170
- tool: "oke.action.invoke",
171
- args: actionArgs,
172
- reason: "operator approved test invoke",
255
+ const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
256
+ const confirm = await runtime.callTool(b, "oke.action.confirm", {
257
+ confirmationId,
258
+ reason: CONFIRM_REASON,
173
259
  });
174
260
  expect(confirm.ok).toBe(true);
175
261
  if (!confirm.ok) return;
176
- const token = (confirm.data.content as { confirmToken: string }).confirmToken;
262
+ const token = stringField(confirm.data.content, "confirmToken");
177
263
  expect(token.startsWith("mcp_c_")).toBe(true);
264
+ expect(token).not.toBe(confirmationId);
178
265
 
179
- const first = await runtime.callTool(requester, "oke.action.invoke", {
266
+ const first = await runtime.callTool(a, "oke.action.invoke", {
180
267
  ...actionArgs,
181
268
  confirmation: MCP_CONFIRM_PHRASE,
182
269
  confirmToken: token,
183
- reason: "operator approved test invoke",
270
+ reason: CONFIRM_REASON,
184
271
  });
185
272
  expect(first.ok).toBe(true);
186
273
  expect(invoked).toBe(1);
274
+ });
187
275
 
188
- // Same token cannot be reused — no session-level consent cache.
189
- const replay = await runtime.callTool(requester, "oke.action.invoke", {
276
+ test("the issuer cannot consume the token", async () => {
277
+ let invoked = 0;
278
+ const runtime = runtimeWithInvoke(() => {
279
+ invoked += 1;
280
+ });
281
+ const { a, b } = await twoSessionsOf("op1");
282
+ const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
283
+ const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
284
+ const confirm = await runtime.callTool(b, "oke.action.confirm", {
285
+ confirmationId,
286
+ reason: CONFIRM_REASON,
287
+ });
288
+ expect(confirm.ok).toBe(true);
289
+ if (!confirm.ok) return;
290
+ const token = stringField(confirm.data.content, "confirmToken");
291
+
292
+ const stolen = await runtime.callTool(b, "oke.action.invoke", {
293
+ ...actionArgs,
294
+ confirmation: MCP_CONFIRM_PHRASE,
295
+ confirmToken: token,
296
+ reason: CONFIRM_REASON,
297
+ });
298
+ expect(stolen.ok).toBe(false);
299
+ if (!stolen.ok) expect(stolen.message).toContain("session-mismatch");
300
+ expect(invoked).toBe(0);
301
+ });
302
+
303
+ test("replay of a consumed token fails", async () => {
304
+ let invoked = 0;
305
+ const runtime = runtimeWithInvoke(() => {
306
+ invoked += 1;
307
+ });
308
+ const { a, b } = await twoSessionsOf("op1");
309
+ const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
310
+ const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
311
+ const confirm = await runtime.callTool(b, "oke.action.confirm", {
312
+ confirmationId,
313
+ reason: CONFIRM_REASON,
314
+ });
315
+ expect(confirm.ok).toBe(true);
316
+ if (!confirm.ok) return;
317
+ const token = stringField(confirm.data.content, "confirmToken");
318
+
319
+ const first = await runtime.callTool(a, "oke.action.invoke", {
190
320
  ...actionArgs,
191
321
  confirmation: MCP_CONFIRM_PHRASE,
192
322
  confirmToken: token,
193
- reason: "operator approved test invoke",
323
+ reason: CONFIRM_REASON,
324
+ });
325
+ expect(first.ok).toBe(true);
326
+
327
+ const replay = await runtime.callTool(a, "oke.action.invoke", {
328
+ ...actionArgs,
329
+ confirmation: MCP_CONFIRM_PHRASE,
330
+ confirmToken: token,
331
+ reason: CONFIRM_REASON,
194
332
  });
195
333
  expect(replay.ok).toBe(false);
334
+ if (!replay.ok) expect(replay.message).toContain("unknown");
196
335
  expect(invoked).toBe(1);
197
336
  expect(runtime.confirmationSize()).toBe(0);
198
337
  });
338
+
339
+ test("wrong tool or args is rejected", async () => {
340
+ let invoked = 0;
341
+ const runtime = runtimeWithInvoke(() => {
342
+ invoked += 1;
343
+ });
344
+ const { a, b } = await twoSessionsOf("op1");
345
+ const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
346
+
347
+ const argsId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
348
+ const argsConfirm = await runtime.callTool(b, "oke.action.confirm", {
349
+ confirmationId: argsId,
350
+ reason: CONFIRM_REASON,
351
+ });
352
+ expect(argsConfirm.ok).toBe(true);
353
+ if (!argsConfirm.ok) return;
354
+ const argsToken = stringField(argsConfirm.data.content, "confirmToken");
355
+ const wrongArgs = await runtime.callTool(a, "oke.action.invoke", {
356
+ flowId: "bookings.create",
357
+ body: { name: "other" },
358
+ confirmation: MCP_CONFIRM_PHRASE,
359
+ confirmToken: argsToken,
360
+ reason: CONFIRM_REASON,
361
+ });
362
+ expect(wrongArgs.ok).toBe(false);
363
+ if (!wrongArgs.ok) expect(wrongArgs.message).toContain("args-mismatch");
364
+
365
+ const toolId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
366
+ const toolConfirm = await runtime.callTool(b, "oke.action.confirm", {
367
+ confirmationId: toolId,
368
+ reason: CONFIRM_REASON,
369
+ });
370
+ expect(toolConfirm.ok).toBe(true);
371
+ if (!toolConfirm.ok) return;
372
+ const toolToken = stringField(toolConfirm.data.content, "confirmToken");
373
+ const wrongTool = await runtime.callTool(a, "oke.action.structural_propose", {
374
+ title: "rename",
375
+ relativePath: "src/bookings.ts",
376
+ contents: "export {}",
377
+ confirmation: MCP_CONFIRM_PHRASE,
378
+ confirmToken: toolToken,
379
+ reason: CONFIRM_REASON,
380
+ });
381
+ expect(wrongTool.ok).toBe(false);
382
+ if (!wrongTool.ok) expect(wrongTool.message).toContain("tool-mismatch");
383
+ expect(invoked).toBe(0);
384
+ });
385
+
386
+ test("a token issued on another runtime is unknown", async () => {
387
+ let invoked = 0;
388
+ const issuer = runtimeWithInvoke(() => {
389
+ invoked += 1;
390
+ });
391
+ const other = runtimeWithInvoke(() => {
392
+ invoked += 1;
393
+ });
394
+ const { a, b } = await twoSessionsOf("op1");
395
+ const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
396
+ const confirmationId = await openConfirmation(issuer, a, "oke.action.invoke", actionArgs);
397
+ const confirm = await issuer.callTool(b, "oke.action.confirm", {
398
+ confirmationId,
399
+ reason: CONFIRM_REASON,
400
+ });
401
+ expect(confirm.ok).toBe(true);
402
+ if (!confirm.ok) return;
403
+ const token = stringField(confirm.data.content, "confirmToken");
404
+
405
+ const cross = await other.callTool(a, "oke.action.invoke", {
406
+ ...actionArgs,
407
+ confirmation: MCP_CONFIRM_PHRASE,
408
+ confirmToken: token,
409
+ reason: CONFIRM_REASON,
410
+ });
411
+ expect(cross.ok).toBe(false);
412
+ if (!cross.ok) expect(cross.message).toContain("unknown");
413
+ expect(invoked).toBe(0);
414
+ });
199
415
  });
200
416
 
201
417
  describe("MCP inert data envelope", () => {
@@ -293,6 +509,7 @@ describe("MCP HTTP server", () => {
293
509
  result: { tools: { name: string }[] };
294
510
  };
295
511
  expect(listBody.result.tools.some((t) => t.name === "oke.manifest.get")).toBe(true);
512
+ expect(listBody.result.tools.some((t) => t.name === "oke.action.invoke")).toBe(true);
296
513
 
297
514
  const call = await server.fetch(
298
515
  new Request("http://127.0.0.1:6535/mcp", {
@@ -316,6 +533,77 @@ describe("MCP HTTP server", () => {
316
533
  expect(callBody.result.structuredContent.content.manifest.app).toBe("skyport");
317
534
  });
318
535
 
536
+ test("read-only tools/list omits write tools and a hidden write call is 403", async () => {
537
+ let invoked = 0;
538
+ const store = createSessionStore();
539
+ const issued = await mintMcpSession({
540
+ store,
541
+ secret: SECRET,
542
+ principalId: "op-read",
543
+ scopes: ["mcp:manifest:read"],
544
+ });
545
+ const server = createMcpServer({
546
+ sessions: store,
547
+ secret: SECRET,
548
+ context: {
549
+ getManifest: () => SAMPLE_MANIFEST,
550
+ listRuns: async () => [],
551
+ invokeFlow: async () => {
552
+ invoked += 1;
553
+ return { ok: true };
554
+ },
555
+ },
556
+ });
557
+ const headers = {
558
+ host: "127.0.0.1:6535",
559
+ authorization: `Bearer ${issued.accessToken}`,
560
+ "content-type": "application/json",
561
+ };
562
+ const list = await server.fetch(
563
+ new Request("http://127.0.0.1:6535/mcp", {
564
+ method: "POST",
565
+ headers,
566
+ body: JSON.stringify({
567
+ jsonrpc: "2.0",
568
+ id: 1,
569
+ method: "tools/list",
570
+ }),
571
+ }),
572
+ );
573
+ expect(list.status).toBe(200);
574
+ const listBody = (await list.json()) as {
575
+ result: {
576
+ tools: { name: string; annotations: { readOnlyHint: boolean; destructiveHint: boolean } }[];
577
+ };
578
+ };
579
+ expect(listBody.result.tools.some((t) => t.name === "oke.manifest.get")).toBe(true);
580
+ expect(listBody.result.tools.some((t) => t.name === "oke.action.invoke")).toBe(false);
581
+ expect(listBody.result.tools.some((t) => t.name === "oke.action.structural_propose")).toBe(
582
+ false,
583
+ );
584
+ expect(listBody.result.tools.every((t) => t.annotations.destructiveHint === false)).toBe(true);
585
+
586
+ const call = await server.fetch(
587
+ new Request("http://127.0.0.1:6535/mcp", {
588
+ method: "POST",
589
+ headers,
590
+ body: JSON.stringify({
591
+ jsonrpc: "2.0",
592
+ id: 2,
593
+ method: "tools/call",
594
+ params: {
595
+ name: "oke.action.invoke",
596
+ arguments: { flowId: "bookings.create", body: { name: "x" } },
597
+ },
598
+ }),
599
+ }),
600
+ );
601
+ expect(call.status).toBe(403);
602
+ const callBody = (await call.json()) as { error: { message: string } };
603
+ expect(callBody.error.message).toMatch(/missing one of/i);
604
+ expect(invoked).toBe(0);
605
+ });
606
+
319
607
  test("initialize advertises 2024-11-05 and rejects a version the server does not implement", async () => {
320
608
  const store = createSessionStore();
321
609
  const issued = await mintMcpSession({
package/src/mcp/server.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  forbiddenResponse,
15
15
  resolveAllowedHosts,
16
16
  } from "../runtime/security.ts";
17
+ import { HttpBodyRejected, parseBody } from "../compiler/http-parse.ts";
17
18
  import { MCP_PORT, type ServerHandle } from "../runtime/types.ts";
18
19
  import { SessionError, type SessionStore } from "../auth/sessions.ts";
19
20
  import { asData } from "./data.ts";
@@ -135,8 +136,11 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
135
136
 
136
137
  let body: unknown;
137
138
  try {
138
- body = await request.json();
139
- } catch {
139
+ body = await parseBody(request);
140
+ } catch (err) {
141
+ if (err instanceof HttpBodyRejected && err.code === "PayloadTooLarge") {
142
+ return respond(new Response("Payload Too Large", { status: 413 }));
143
+ }
140
144
  return respond(jsonRpcHttp(rpcError(null, RpcErrorCode.parse, "invalid JSON body"), 400));
141
145
  }
142
146
 
@@ -174,7 +178,7 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
174
178
  case "ping":
175
179
  return respond(jsonRpcHttp(rpcSuccess(id, { ok: true })));
176
180
  case "tools/list": {
177
- const listed = tools.listTools().map((t) => ({
181
+ const listed = tools.listTools(requester).map((t) => ({
178
182
  name: t.name,
179
183
  description: t.description,
180
184
  inputSchema: t.inputSchema,
@@ -277,6 +281,7 @@ export async function serveMcp(options: ServeMcpOptions): Promise<McpServerHandl
277
281
  const server = Bun.serve({
278
282
  hostname,
279
283
  port,
284
+ maxRequestBodySize: 1_048_576,
280
285
  fetch: mcp.fetch,
281
286
  });
282
287
  const boundPort = server.port ?? port;
@@ -29,6 +29,13 @@ export interface McpRequester {
29
29
  readonly principalId: string;
30
30
  readonly plane: AuthPlane;
31
31
  readonly scopes: readonly string[];
32
+ /**
33
+ * Auth session id (`claims.sid`).
34
+ *
35
+ * This is not the MCP transport session id minted at `initialize`
36
+ * ({@link newMcpTransportSessionId}). Confirmation binds this sid:
37
+ * the requester and the issuer must be different auth sessions.
38
+ */
32
39
  readonly sessionId: string;
33
40
  }
34
41
 
package/src/mcp/tools.ts CHANGED
@@ -12,7 +12,9 @@ import { authorizeToolCall, MCP_TOOL_POLICIES, type McpToolPolicy } from "./auth
12
12
  import {
13
13
  createConfirmationGate,
14
14
  MCP_CONFIRM_PHRASE,
15
+ type ConfirmationGate,
15
16
  type ConfirmationGateOptions,
17
+ type ConfirmIssueResult,
16
18
  } from "./confirmation.ts";
17
19
  import { asData, freezeData, type McpDataEnvelope } from "./data.ts";
18
20
  import type { McpRequester } from "./session.ts";
@@ -68,6 +70,9 @@ export type ToolCallResult =
68
70
  /**
69
71
  * Create the tool runtime bound to a context + confirmation gate.
70
72
  *
73
+ * `listTools` receives the requester and omits tools their scopes cannot call.
74
+ * Confirmation is not required to appear in the list; `callTool` still enforces it.
75
+ *
71
76
  * @param ctx - Manifest / runs / action adapters
72
77
  * @param confirmOptions - Confirmation gate options
73
78
  */
@@ -75,7 +80,7 @@ export function createToolRuntime(
75
80
  ctx: McpContext,
76
81
  confirmOptions: ConfirmationGateOptions = {},
77
82
  ): {
78
- readonly listTools: () => readonly McpToolPolicy[];
83
+ readonly listTools: (requester: McpRequester) => readonly McpToolPolicy[];
79
84
  readonly callTool: (
80
85
  requester: McpRequester,
81
86
  name: string,
@@ -87,7 +92,14 @@ export function createToolRuntime(
87
92
  const confirm = createConfirmationGate(confirmOptions);
88
93
 
89
94
  return {
90
- listTools: () => MCP_TOOL_POLICIES,
95
+ listTools(requester) {
96
+ // Same scope check as authorizeToolCall. Listing ignores confirmation;
97
+ // a later call to a write tool still requires a token.
98
+ return MCP_TOOL_POLICIES.filter((policy) => {
99
+ const decision = authorizeToolCall(policy.name, {}, requester.scopes, { confirmed: true });
100
+ return decision.ok;
101
+ });
102
+ },
91
103
  confirmationSize: () => confirm.size(),
92
104
  async callTool(requester, name, args) {
93
105
  const policy = MCP_TOOL_POLICIES.find((p) => p.name === name);
@@ -113,15 +125,42 @@ export function createToolRuntime(
113
125
  }
114
126
 
115
127
  // Write path: consume a fresh per-call confirmation (no session cache).
128
+ // A call with no token is rejected and returns an opaque confirmationId
129
+ // bound to this requester — never the token.
116
130
  if (policy?.mutability === "write") {
117
131
  const token = typeof args.confirmToken === "string" ? args.confirmToken : "";
118
132
  const phrase = typeof args.confirmation === "string" ? args.confirmation : "";
119
133
  const reason = typeof args.reason === "string" ? args.reason : "";
120
134
  const boundArgs = stripConfirmFields(args);
135
+ if (token.length === 0) {
136
+ const opened = confirm.open({
137
+ tool: name,
138
+ args: boundArgs,
139
+ target: {
140
+ principalId: requester.principalId,
141
+ sid: requester.sessionId,
142
+ },
143
+ });
144
+ return {
145
+ ok: false,
146
+ code: "forbidden",
147
+ message: "write tool requires fresh human confirmation (missing)",
148
+ data: asData(
149
+ {
150
+ tool: name,
151
+ confirmationId: opened.confirmationId,
152
+ requiredPhrase: MCP_CONFIRM_PHRASE,
153
+ confirmVia: "oke.action.confirm",
154
+ reason: "missing",
155
+ },
156
+ "error",
157
+ ),
158
+ };
159
+ }
121
160
  const consumed = confirm.consume({
122
161
  tool: name,
123
162
  args: boundArgs,
124
- principalId: requester.principalId,
163
+ consumerSid: requester.sessionId,
125
164
  token,
126
165
  phrase,
127
166
  reason,
@@ -412,28 +451,23 @@ async function decisionsList(
412
451
  }
413
452
 
414
453
  function actionConfirm(
415
- confirm: ReturnType<typeof createConfirmationGate>,
454
+ confirm: ConfirmationGate,
416
455
  requester: McpRequester,
417
456
  args: Record<string, unknown>,
418
457
  ): ToolCallResult {
419
- const tool = String(args.tool ?? "");
420
- const reason = String(args.reason ?? "");
421
- const toolArgs =
422
- args.args !== null && typeof args.args === "object" && !Array.isArray(args.args)
423
- ? (args.args as Record<string, unknown>)
424
- : {};
425
- const issued = confirm.request({
426
- tool,
427
- args: toolArgs,
428
- principalId: requester.principalId,
458
+ const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : "";
459
+ const reason = typeof args.reason === "string" ? args.reason : "";
460
+ const issued = confirm.issue({
461
+ confirmationId,
429
462
  reason,
463
+ callerSid: requester.sessionId,
430
464
  });
431
- if ("error" in issued) {
465
+ if (!issued.ok) {
432
466
  return {
433
467
  ok: false,
434
- code: "invalid",
435
- message: "reason must be at least 3 characters",
436
- data: asData({ error: issued.error }, "error"),
468
+ code: issued.reason === "reason-short" ? "invalid" : "forbidden",
469
+ message: confirmIssueMessage(issued.reason),
470
+ data: asData({ error: issued.reason }, "error"),
437
471
  };
438
472
  }
439
473
  return okData(
@@ -443,12 +477,27 @@ function actionConfirm(
443
477
  phrase: MCP_CONFIRM_PHRASE,
444
478
  expiresAt: issued.expiresAt,
445
479
  notice:
446
- "Present confirmToken + confirmation phrase on the write call. Tokens are single-use; there is no session consent cache.",
480
+ "Present confirmToken + confirmation phrase on the write call from the session that requested it. Tokens are single-use; the issuer session cannot consume them.",
447
481
  },
448
482
  "action-result",
449
483
  );
450
484
  }
451
485
 
486
+ function confirmIssueMessage(reason: Extract<ConfirmIssueResult, { ok: false }>["reason"]): string {
487
+ switch (reason) {
488
+ case "reason-short":
489
+ return "reason must be at least 3 characters";
490
+ case "same-session":
491
+ return "confirmation must be issued by a different auth session";
492
+ case "unknown":
493
+ return "unknown confirmation";
494
+ case "expired":
495
+ return "confirmation expired";
496
+ case "already-issued":
497
+ return "confirmation already issued";
498
+ }
499
+ }
500
+
452
501
  async function actionInvoke(
453
502
  ctx: McpContext,
454
503
  requester: McpRequester,
@@ -149,6 +149,7 @@ export function serveBunHttp(options: {
149
149
  readonly fetch: (request: Request) => Response | Promise<Response>;
150
150
  readonly routes?: Record<string, MethodHandlers>;
151
151
  readonly id?: string;
152
+ readonly maxRequestBodySize?: number;
152
153
  }): ReturnType<typeof Bun.serve> {
153
154
  const fetch = holdSseIdle(
154
155
  typeof options.fetch === "function"
@@ -158,6 +159,7 @@ export function serveBunHttp(options: {
158
159
  const base = {
159
160
  port: options.port,
160
161
  hostname: options.hostname,
162
+ maxRequestBodySize: options.maxRequestBodySize ?? 1_048_576,
161
163
  fetch,
162
164
  ...(options.id !== undefined ? { id: options.id } : {}),
163
165
  };
@@ -185,6 +187,9 @@ function listenBun(app: FetchApp, options?: ServeOptions): ServerHandle {
185
187
  fetch: fetchHandler,
186
188
  routes,
187
189
  ...(options?.id !== undefined ? { id: options.id } : {}),
190
+ ...(options?.maxRequestBodySize !== undefined
191
+ ? { maxRequestBodySize: options.maxRequestBodySize }
192
+ : {}),
188
193
  });
189
194
 
190
195
  const closeIdle = (
@@ -72,6 +72,11 @@ export interface ServeOptions {
72
72
  * never replaces the mandatory check.
73
73
  */
74
74
  readonly allowedHosts?: readonly string[];
75
+ /**
76
+ * Bun.serve body backstop in bytes. Default 1 MiB. Raise it together with
77
+ * `maxRequestBodySize` when a route accepts a larger body.
78
+ */
79
+ readonly maxRequestBodySize?: number;
75
80
  }
76
81
 
77
82
  /** Handle returned by {@link Runtime.serve}. */