okengine 0.23.0 → 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 (128) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +4 -4
  3. package/site/content/docs/elements/channel/index.mdx +4 -4
  4. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  5. package/site/content/docs/elements/channel/sms.mdx +2 -1
  6. package/site/content/docs/elements/flow/index.mdx +22 -18
  7. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  8. package/site/content/docs/elements/signal/index.mdx +15 -14
  9. package/site/content/docs/plugins/otp.mdx +3 -3
  10. package/site/content/docs/reference/configuration.mdx +16 -0
  11. package/site/content/docs/reference/errors.mdx +31 -19
  12. package/site/content/docs/reference/fx.mdx +12 -4
  13. package/src/auth/tenants.ts +17 -0
  14. package/src/cli/doctor-diff.test.ts +29 -1
  15. package/src/cli/doctor-diff.ts +35 -0
  16. package/src/cli/manifest-pr-diff.ts +16 -0
  17. package/src/compiler/aot.test.ts +3 -2
  18. package/src/compiler/aot.ts +45 -1
  19. package/src/compiler/dynamic.ts +1 -1
  20. package/src/compiler/effects-fetch.test.ts +77 -0
  21. package/src/compiler/effects-infer.ts +87 -16
  22. package/src/compiler/effects-join.test.ts +48 -0
  23. package/src/compiler/extract.test.ts +28 -0
  24. package/src/compiler/extract.ts +11 -1
  25. package/src/compiler/fx-index.ts +58 -4
  26. package/src/compiler/http-parse.test.ts +101 -0
  27. package/src/compiler/http-parse.ts +188 -9
  28. package/src/compiler/interpret.ts +25 -2
  29. package/src/console/index.ts +1 -1
  30. package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
  31. package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  32. package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
  33. package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
  34. package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
  35. package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
  36. package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
  41. package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
  46. package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
  49. package/src/console/ui-next/dist/index.html +1 -1
  50. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  51. package/src/drivers/journal-postgres.test.ts +106 -2
  52. package/src/drivers/journal-postgres.ts +475 -14
  53. package/src/drivers/postgres.test.ts +60 -0
  54. package/src/drivers/postgres.ts +79 -5
  55. package/src/drivers/signal-compete.test.ts +238 -0
  56. package/src/drivers/signal-postgres.test.ts +170 -0
  57. package/src/drivers/signal-postgres.ts +486 -213
  58. package/src/drivers/signal-redis.test.ts +224 -0
  59. package/src/drivers/signal-redis.ts +934 -93
  60. package/src/drivers/signal-types.ts +12 -0
  61. package/src/elements/ai/approval.test.ts +15 -3
  62. package/src/elements/ai/approval.ts +15 -9
  63. package/src/elements/ai/mcp-protocol.ts +5 -6
  64. package/src/elements/ai/runtime.ts +20 -19
  65. package/src/elements/channel/runtime.ts +42 -4
  66. package/src/elements/channel/sql-ledger.test.ts +404 -0
  67. package/src/elements/channel/sql-ledger.ts +395 -0
  68. package/src/elements/clock/chaos-child.ts +12 -2
  69. package/src/elements/clock/durable.ts +11 -0
  70. package/src/elements/signal/runtime.ts +9 -0
  71. package/src/elements/signal.test.ts +1 -0
  72. package/src/elements/store/cache-bus.test.ts +121 -0
  73. package/src/elements/store/cache-bus.ts +278 -0
  74. package/src/elements/store/cache.test.ts +179 -15
  75. package/src/elements/store/cache.ts +357 -51
  76. package/src/elements/store/prepare-row.test.ts +6 -2
  77. package/src/elements/store/runtime.ts +44 -2
  78. package/src/elements/store/sql-errors.test.ts +131 -0
  79. package/src/elements/store/sql-errors.ts +39 -16
  80. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  81. package/src/elements/store/sql-session.test.ts +2 -0
  82. package/src/elements/store/sql-session.ts +275 -9
  83. package/src/elements/store.ts +1 -0
  84. package/src/kernel/app.ts +207 -43
  85. package/src/kernel/auto-cache.test.ts +228 -0
  86. package/src/kernel/auto-registry.test.ts +5 -1
  87. package/src/kernel/boot-bind/channel.ts +63 -2
  88. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  89. package/src/kernel/boot-bind/signal.ts +72 -25
  90. package/src/kernel/boot-bind/store.ts +16 -1
  91. package/src/kernel/boot.test.ts +2 -2
  92. package/src/kernel/boot.ts +54 -10
  93. package/src/kernel/boundary-contract.ts +7 -0
  94. package/src/kernel/builtin-errors.ts +2 -0
  95. package/src/kernel/call.test.ts +1 -0
  96. package/src/kernel/errors-compiler.ts +11 -0
  97. package/src/kernel/errors-text.ts +16 -0
  98. package/src/kernel/errors.ts +8 -0
  99. package/src/kernel/flow.ts +33 -3
  100. package/src/kernel/fx-call-types.test.ts +32 -0
  101. package/src/kernel/fx-decide.ts +11 -7
  102. package/src/kernel/fx-sql-handle.ts +52 -7
  103. package/src/kernel/fx.test.ts +1 -3
  104. package/src/kernel/fx.ts +27 -16
  105. package/src/kernel/horizontal-child.ts +54 -1
  106. package/src/kernel/horizontal.integration.test.ts +131 -7
  107. package/src/kernel/idempotency.test.ts +4 -4
  108. package/src/kernel/journal-boot.test.ts +83 -6
  109. package/src/kernel/journal.test.ts +158 -0
  110. package/src/kernel/journal.ts +515 -54
  111. package/src/kernel/pipeline-tenant.ts +5 -1
  112. package/src/kernel/pipeline.ts +5 -0
  113. package/src/kernel/signal-tx.ts +28 -0
  114. package/src/kernel/store-transaction.test.ts +95 -0
  115. package/src/mcp/authorization.ts +5 -11
  116. package/src/mcp/confirmation.ts +203 -38
  117. package/src/mcp/docs-server.ts +26 -4
  118. package/src/mcp/index.ts +4 -0
  119. package/src/mcp/mcp.test.ts +404 -33
  120. package/src/mcp/protocol.ts +8 -2
  121. package/src/mcp/server.ts +31 -5
  122. package/src/mcp/session.ts +7 -0
  123. package/src/mcp/tools.ts +68 -19
  124. package/src/mcp/versions.ts +60 -0
  125. package/src/plugins/index.ts +1 -0
  126. package/src/plugins/mena.ts +56 -0
  127. package/src/runtime/bun.ts +5 -0
  128. package/src/runtime/types.ts +5 -0
@@ -12,9 +12,15 @@ 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";
23
+ import { MCP_CLIENT_PROTOCOL_VERSION, MCP_PROTOCOL_VERSION } from "./versions.ts";
18
24
 
19
25
  const SECRET = "mcp-test-secret";
20
26
 
@@ -105,6 +111,69 @@ describe("MCP audience validation", () => {
105
111
  });
106
112
  });
107
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
+
108
177
  describe("MCP write confirmation", () => {
109
178
  test("agent cannot invoke a write tool without confirmation", async () => {
110
179
  const decision = authorizeToolCall(
@@ -118,12 +187,8 @@ describe("MCP write confirmation", () => {
118
187
  expect(decision.reason).toBe("confirmation-required");
119
188
  }
120
189
 
121
- const runtime = createToolRuntime({
122
- getManifest: () => SAMPLE_MANIFEST,
123
- listRuns: async () => [],
124
- invokeFlow: async () => {
125
- throw new Error("must not invoke");
126
- },
190
+ const runtime = runtimeWithInvoke(() => {
191
+ throw new Error("must not invoke");
127
192
  });
128
193
  const store = createSessionStore();
129
194
  const issued = await mintMcpSession({
@@ -142,59 +207,211 @@ describe("MCP write confirmation", () => {
142
207
  expect(result.code).toBe("forbidden");
143
208
  expect(result.message).toContain("confirmation");
144
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");
145
214
  }
146
215
  });
147
216
 
148
- test("write succeeds only with fresh per-call confirmation (no consent cache)", async () => {
217
+ test("one session cannot request, confirm, and invoke", async () => {
149
218
  let invoked = 0;
150
- const runtime = createToolRuntime({
151
- getManifest: () => SAMPLE_MANIFEST,
152
- listRuns: async () => [],
153
- invokeFlow: async (input) => {
154
- invoked += 1;
155
- return { ok: true, flowId: input.flowId };
156
- },
219
+ const runtime = runtimeWithInvoke(() => {
220
+ invoked += 1;
157
221
  });
158
- const store = createSessionStore();
159
- const issued = await mintMcpSession({
160
- store,
161
- secret: SECRET,
162
- principalId: "op1",
163
- 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,
164
229
  });
165
- 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);
166
253
 
167
254
  const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
168
- const confirm = await runtime.callTool(requester, "oke.action.confirm", {
169
- tool: "oke.action.invoke",
170
- args: actionArgs,
171
- 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,
172
259
  });
173
260
  expect(confirm.ok).toBe(true);
174
261
  if (!confirm.ok) return;
175
- const token = (confirm.data.content as { confirmToken: string }).confirmToken;
262
+ const token = stringField(confirm.data.content, "confirmToken");
176
263
  expect(token.startsWith("mcp_c_")).toBe(true);
264
+ expect(token).not.toBe(confirmationId);
177
265
 
178
- const first = await runtime.callTool(requester, "oke.action.invoke", {
266
+ const first = await runtime.callTool(a, "oke.action.invoke", {
179
267
  ...actionArgs,
180
268
  confirmation: MCP_CONFIRM_PHRASE,
181
269
  confirmToken: token,
182
- reason: "operator approved test invoke",
270
+ reason: CONFIRM_REASON,
183
271
  });
184
272
  expect(first.ok).toBe(true);
185
273
  expect(invoked).toBe(1);
274
+ });
186
275
 
187
- // Same token cannot be reused — no session-level consent cache.
188
- 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", {
189
293
  ...actionArgs,
190
294
  confirmation: MCP_CONFIRM_PHRASE,
191
295
  confirmToken: token,
192
- reason: "operator approved test invoke",
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", {
320
+ ...actionArgs,
321
+ confirmation: MCP_CONFIRM_PHRASE,
322
+ confirmToken: token,
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,
193
332
  });
194
333
  expect(replay.ok).toBe(false);
334
+ if (!replay.ok) expect(replay.message).toContain("unknown");
195
335
  expect(invoked).toBe(1);
196
336
  expect(runtime.confirmationSize()).toBe(0);
197
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
+ });
198
415
  });
199
416
 
200
417
  describe("MCP inert data envelope", () => {
@@ -292,6 +509,7 @@ describe("MCP HTTP server", () => {
292
509
  result: { tools: { name: string }[] };
293
510
  };
294
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);
295
513
 
296
514
  const call = await server.fetch(
297
515
  new Request("http://127.0.0.1:6535/mcp", {
@@ -314,4 +532,157 @@ describe("MCP HTTP server", () => {
314
532
  expect(callBody.result.structuredContent.kind).toBe("data");
315
533
  expect(callBody.result.structuredContent.content.manifest.app).toBe("skyport");
316
534
  });
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
+
607
+ test("initialize advertises 2024-11-05 and rejects a version the server does not implement", async () => {
608
+ const store = createSessionStore();
609
+ const issued = await mintMcpSession({
610
+ store,
611
+ secret: SECRET,
612
+ principalId: "op1",
613
+ scopes: ["console:*"],
614
+ });
615
+ const server = createMcpServer({
616
+ sessions: store,
617
+ secret: SECRET,
618
+ context: {
619
+ getManifest: () => SAMPLE_MANIFEST,
620
+ listRuns: async () => [],
621
+ },
622
+ });
623
+ const headers = {
624
+ host: "127.0.0.1:6535",
625
+ authorization: `Bearer ${issued.accessToken}`,
626
+ "content-type": "application/json",
627
+ };
628
+ const init = await server.fetch(
629
+ new Request("http://127.0.0.1:6535/mcp", {
630
+ method: "POST",
631
+ headers,
632
+ body: JSON.stringify({
633
+ jsonrpc: "2.0",
634
+ id: 1,
635
+ method: "initialize",
636
+ params: { protocolVersion: MCP_PROTOCOL_VERSION },
637
+ }),
638
+ }),
639
+ );
640
+ expect(init.status).toBe(200);
641
+ const initBody = (await init.json()) as { result: { protocolVersion: string } };
642
+ expect(initBody.result.protocolVersion).toBe("2024-11-05");
643
+ const omitted = await server.fetch(
644
+ new Request("http://127.0.0.1:6535/mcp", {
645
+ method: "POST",
646
+ headers,
647
+ body: JSON.stringify({
648
+ jsonrpc: "2.0",
649
+ id: 2,
650
+ method: "initialize",
651
+ params: {},
652
+ }),
653
+ }),
654
+ );
655
+ const omittedBody = (await omitted.json()) as { result: { protocolVersion: string } };
656
+ expect(omittedBody.result.protocolVersion).toBe("2024-11-05");
657
+ const rejected = await server.fetch(
658
+ new Request("http://127.0.0.1:6535/mcp", {
659
+ method: "POST",
660
+ headers,
661
+ body: JSON.stringify({
662
+ jsonrpc: "2.0",
663
+ id: 3,
664
+ method: "initialize",
665
+ params: { protocolVersion: MCP_CLIENT_PROTOCOL_VERSION },
666
+ }),
667
+ }),
668
+ );
669
+ expect(rejected.status).toBe(400);
670
+ const call = await server.fetch(
671
+ new Request("http://127.0.0.1:6535/mcp", {
672
+ method: "POST",
673
+ headers,
674
+ body: JSON.stringify({
675
+ jsonrpc: "2.0",
676
+ id: 4,
677
+ method: "tools/call",
678
+ params: {
679
+ name: "oke.manifest.get",
680
+ arguments: {},
681
+ protocolVersion: MCP_PROTOCOL_VERSION,
682
+ },
683
+ }),
684
+ }),
685
+ );
686
+ expect(call.status).toBe(200);
687
+ });
317
688
  });
@@ -129,8 +129,14 @@ export interface McpInitializeResult {
129
129
  readonly sessionId: string;
130
130
  }
131
131
 
132
- /** Supported MCP protocol version. */
133
- export const MCP_PROTOCOL_VERSION = "2024-11-05";
132
+ /** Highest protocol version the servers implement (`2024-11-05`). */
133
+ export {
134
+ MCP_PROTOCOL_VERSION,
135
+ MCP_SUPPORTED_PROTOCOL_VERSIONS,
136
+ acceptsMcpProtocolVersion,
137
+ negotiateMcpProtocolVersion,
138
+ protocolVersionOf,
139
+ } from "./versions.ts";
134
140
 
135
141
  /**
136
142
  * Parse tools/call params.
package/src/mcp/server.ts CHANGED
@@ -14,11 +14,14 @@ 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";
20
21
  import {
21
- MCP_PROTOCOL_VERSION,
22
+ acceptsMcpProtocolVersion,
23
+ negotiateMcpProtocolVersion,
24
+ protocolVersionOf,
22
25
  parseJsonRpcRequest,
23
26
  parseToolsCallParams,
24
27
  rpcError,
@@ -133,8 +136,11 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
133
136
 
134
137
  let body: unknown;
135
138
  try {
136
- body = await request.json();
137
- } 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
+ }
138
144
  return respond(jsonRpcHttp(rpcError(null, RpcErrorCode.parse, "invalid JSON body"), 400));
139
145
  }
140
146
 
@@ -149,10 +155,20 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
149
155
 
150
156
  switch (rpc.method) {
151
157
  case "initialize": {
158
+ const requested = protocolVersionOf(rpc.params);
159
+ const protocolVersion = negotiateMcpProtocolVersion(requested);
160
+ if (protocolVersion === undefined) {
161
+ return respond(
162
+ jsonRpcHttp(
163
+ rpcError(id, RpcErrorCode.invalidParams, "unsupported protocol version"),
164
+ 400,
165
+ ),
166
+ );
167
+ }
152
168
  const sessionId = newMcpTransportSessionId();
153
169
  transportSessions.add(sessionId);
154
170
  const result: McpInitializeResult = {
155
- protocolVersion: MCP_PROTOCOL_VERSION,
171
+ protocolVersion,
156
172
  capabilities: { tools: { listChanged: false } },
157
173
  serverInfo: { name: "okengine-mcp", version },
158
174
  sessionId,
@@ -162,7 +178,7 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
162
178
  case "ping":
163
179
  return respond(jsonRpcHttp(rpcSuccess(id, { ok: true })));
164
180
  case "tools/list": {
165
- const listed = tools.listTools().map((t) => ({
181
+ const listed = tools.listTools(requester).map((t) => ({
166
182
  name: t.name,
167
183
  description: t.description,
168
184
  inputSchema: t.inputSchema,
@@ -182,6 +198,15 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
182
198
  );
183
199
  }
184
200
  case "tools/call": {
201
+ const requested = protocolVersionOf(rpc.params);
202
+ if (requested !== undefined && !acceptsMcpProtocolVersion(requested)) {
203
+ return respond(
204
+ jsonRpcHttp(
205
+ rpcError(id, RpcErrorCode.invalidParams, "unsupported protocol version"),
206
+ 400,
207
+ ),
208
+ );
209
+ }
185
210
  const call = parseToolsCallParams(rpc.params);
186
211
  if (!call.ok) {
187
212
  return respond(jsonRpcHttp(rpcError(id, RpcErrorCode.invalidParams, call.message), 400));
@@ -256,6 +281,7 @@ export async function serveMcp(options: ServeMcpOptions): Promise<McpServerHandl
256
281
  const server = Bun.serve({
257
282
  hostname,
258
283
  port,
284
+ maxRequestBodySize: 1_048_576,
259
285
  fetch: mcp.fetch,
260
286
  });
261
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