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
@@ -6,7 +6,7 @@
6
6
  * including Store-only apps that never enable `gate.auth.tenant`.
7
7
  */
8
8
 
9
- import { tenantScopesForMember } from "../auth/tenants.ts";
9
+ import { tenantRoleForMember, tenantScopesForMember } from "../auth/tenants.ts";
10
10
  import type { FlowFailure } from "./errors.ts";
11
11
  import type { PipelineDeps } from "./pipeline.ts";
12
12
  import type { InvocationContext } from "./hooks.ts";
@@ -41,6 +41,10 @@ export function applyPipelineTenant(
41
41
  for (const scope of tenantScopesForMember(tenant.store, result.id, userId)) {
42
42
  deps.principals.auth.scopes.add(scope);
43
43
  }
44
+ const role = tenantRoleForMember(tenant.store, result.id, userId);
45
+ deps.principals.cacheRoles = role ? [role] : [];
46
+ } else {
47
+ deps.principals.cacheRoles = [];
44
48
  }
45
49
  return undefined;
46
50
  }
@@ -37,6 +37,11 @@ export interface PrincipalBag {
37
37
  readonly tenant: {
38
38
  id: string | null;
39
39
  };
40
+ /**
41
+ * Membership role names for the tier-1 cache key.
42
+ * Gates do not read this field.
43
+ */
44
+ cacheRoles?: string[];
40
45
  }
41
46
 
42
47
  /** Resolved identity from auth middleware / test harness. */
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Ambient signal transaction for `fx.store().transaction`.
3
+ *
4
+ * Emits inside the callback stage on {@link SignalTransaction} and publish
5
+ * only after the SQL transaction commits.
6
+ */
7
+
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import type { SignalTransaction } from "../drivers/signal-types.ts";
10
+
11
+ const storage = new AsyncLocalStorage<SignalTransaction>();
12
+
13
+ /**
14
+ * The signal transaction wrapping the current async frame, if any.
15
+ */
16
+ export function currentSignalTransaction(): SignalTransaction | undefined {
17
+ return storage.getStore();
18
+ }
19
+
20
+ /**
21
+ * Run `fn` with `tx` as the ambient signal transaction.
22
+ *
23
+ * @param tx - Open signal transaction
24
+ * @param fn - Body
25
+ */
26
+ export function runInSignalTransaction<T>(tx: SignalTransaction, fn: () => Promise<T>): Promise<T> {
27
+ return storage.run(tx, fn);
28
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `fx.store().transaction` publishes signal emits only after commit.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { SqlConnection } from "../drivers/types.ts";
7
+ import { memorySignalDriver } from "../drivers/signal-memory.ts";
8
+ import { signal } from "../elements/signal.ts";
9
+ import { store } from "../elements/store.ts";
10
+ import { createSqlStoreHandle } from "../elements/store/sql-session.ts";
11
+ import type { StoreRuntime } from "../elements/store/runtime.ts";
12
+ import { createFxContext } from "./fx.ts";
13
+ import type { SignalRuntime } from "../elements/signal/runtime.ts";
14
+
15
+ function fakeSql(): SqlConnection {
16
+ return {
17
+ driverId: "memory",
18
+ role: "primary",
19
+ async query() {
20
+ return [];
21
+ },
22
+ async exec() {
23
+ return { changes: 1 };
24
+ },
25
+ async transaction(fn) {
26
+ return fn(this);
27
+ },
28
+ async close() {},
29
+ };
30
+ }
31
+
32
+ describe("fx.store().transaction", () => {
33
+ test("commit publishes the staged emit; rollback drops it", async () => {
34
+ const ping = signal.once("ping");
35
+ const signals = new Map([[ping.name, ping]]);
36
+ const bus = await memorySignalDriver.open({ signals });
37
+ const got: unknown[] = [];
38
+ await bus.subscribe("ping", "c", async (message) => {
39
+ got.push(message.payload);
40
+ });
41
+ const signalRuntime: SignalRuntime = {
42
+ driverId: "memory",
43
+ get bus() {
44
+ return bus;
45
+ },
46
+ declarations: signals,
47
+ register(decl) {
48
+ signals.set(decl.name, decl);
49
+ },
50
+ async start() {
51
+ return bus;
52
+ },
53
+ emit: (name, payload, options) => bus.emit(name, payload, options),
54
+ deadLetters: (name) => bus.deadLetters(name),
55
+ live: (name, opts) => bus.live(name, opts),
56
+ checkLiveResume: (name, afterId) => bus.checkLiveResume(name, afterId),
57
+ async close() {
58
+ await bus.close();
59
+ },
60
+ };
61
+ const notes = { name: "notes" };
62
+ const storeRuntime = {
63
+ async open() {
64
+ return createSqlStoreHandle("sql:db", {
65
+ connection: fakeSql(),
66
+ classifications: new Map(),
67
+ routedRole: "primary",
68
+ });
69
+ },
70
+ } as unknown as StoreRuntime;
71
+ const db = store.sql("db");
72
+ const { fx } = createFxContext({
73
+ flow: "notes.write",
74
+ effects: { writes: ["sql:notes", "sql:db"], emits: ["ping"] },
75
+ storeRuntime,
76
+ signalRuntime,
77
+ });
78
+
79
+ await fx.store(db).transaction(async (tx) => {
80
+ await tx.insert(notes).values({ id: "1" });
81
+ await fx.emit(ping, { id: "1" });
82
+ });
83
+ await bus.drain();
84
+ expect(got).toEqual([{ id: "1" }]);
85
+
86
+ await expect(
87
+ fx.store(db).transaction(async () => {
88
+ await fx.emit(ping, { id: "no" });
89
+ throw new Error("nope");
90
+ }),
91
+ ).rejects.toThrow("nope");
92
+ await bus.drain();
93
+ expect(got).toEqual([{ id: "1" }]);
94
+ });
95
+ });
@@ -225,25 +225,19 @@ export const MCP_TOOL_POLICIES: readonly McpToolPolicy[] = [
225
225
  name: "oke.action.confirm",
226
226
  scopes: ["mcp:action:invoke", "console:flows:invoke"],
227
227
  mutability: "read",
228
- description: "Request a single-use confirmation token for a write tool (no caching).",
228
+ description:
229
+ "Issue a single-use confirmation token for a pending write. The caller must be a different auth session than the requester (no caching).",
229
230
  inputSchema: {
230
231
  type: "object",
231
232
  properties: {
232
- tool: {
233
- type: "string",
234
- enum: ["oke.action.invoke", "oke.action.structural_propose"],
235
- },
236
- args: { type: "object" },
233
+ confirmationId: { type: "string", minLength: 1 },
237
234
  reason: { type: "string", minLength: 3 },
238
235
  },
239
- required: ["tool", "args", "reason"],
236
+ required: ["confirmationId", "reason"],
240
237
  additionalProperties: false,
241
238
  },
242
239
  params: [
243
- {
244
- name: "tool",
245
- enum: ["oke.action.invoke", "oke.action.structural_propose"],
246
- },
240
+ { name: "confirmationId", maxLength: 200 },
247
241
  { name: "reason", maxLength: 2000 },
248
242
  ],
249
243
  },
@@ -5,27 +5,82 @@
5
5
  * re-validating is how tool poisoning and rug pulls persist. Every write
6
6
  * (or otherwise sensitive) tool invocation must carry a fresh confirmation
7
7
  * token that was issued for that exact tool + arguments digest.
8
+ *
9
+ * The requester and the issuer are different auth sessions (`claims.sid`),
10
+ * even when they are the same principal. A write without a token returns an
11
+ * opaque confirmation id bound to the requester. `oke.action.confirm` mints
12
+ * the token only from another session. Only the requester session can
13
+ * consume it, and the issuer session cannot.
14
+ *
15
+ * Pending rows live in this process only. A token issued on another process
16
+ * is absent here and fails closed with `"unknown"`. Do not add shared storage.
8
17
  */
9
18
 
10
19
  /** Typed confirmation phrase for irreversible MCP writes. */
11
20
  export const MCP_CONFIRM_PHRASE = "CONFIRM" as const;
12
21
 
13
- /** Pending confirmation that has not yet been consumed. */
22
+ /**
23
+ * Auth session a confirmation is bound to.
24
+ *
25
+ * `sid` is `McpRequester.sessionId` (`claims.sid`), not the MCP transport
26
+ * session id from `initialize`.
27
+ */
28
+ export interface ConfirmationTarget {
29
+ /** Operator principal id (`claims.sub`). */
30
+ readonly principalId: string;
31
+ /** Auth session id (`claims.sid`). */
32
+ readonly sid: string;
33
+ }
34
+
35
+ /**
36
+ * Confirmation after a different session has issued its token.
37
+ *
38
+ * The write rejection that opens a confirmation never carries `token`.
39
+ */
14
40
  export interface PendingConfirmation {
15
- /** Cryptographically random token. */
41
+ /** Opaque id returned to the requester. Not a token. */
42
+ readonly confirmationId: string;
43
+ /** Cryptographically random token. Present only after issue. */
16
44
  readonly token: string;
17
45
  /** Tool name the token authorises. */
18
46
  readonly tool: string;
19
47
  /** SHA-256 of canonical JSON arguments. */
20
48
  readonly argsDigest: string;
21
- /** Operator / principal id that requested it. */
22
- readonly principalId: string;
49
+ /** Requester the write is bound to. */
50
+ readonly target: ConfirmationTarget;
51
+ /** Auth session that issued the token. Distinct from {@link ConfirmationTarget.sid}. */
52
+ readonly issuerSid: string;
23
53
  /** Expiry epoch-ms. */
24
54
  readonly expiresAt: number;
25
- /** Human reason recorded at request time. */
55
+ /** Human reason recorded when the token was issued. */
26
56
  readonly reason: string;
27
57
  }
28
58
 
59
+ /**
60
+ * Opaque handle for a write that still needs another session to confirm.
61
+ *
62
+ * This is not a token and must not be accepted by {@link ConfirmationGate.consume}.
63
+ */
64
+ export interface OpenConfirmation {
65
+ /** Id to pass to `oke.action.confirm`. */
66
+ readonly confirmationId: string;
67
+ /** Expiry epoch-ms of the pending row. */
68
+ readonly expiresAt: number;
69
+ }
70
+
71
+ /** Result of issuing a confirmation token from a second session. */
72
+ export type ConfirmIssueResult =
73
+ | {
74
+ readonly ok: true;
75
+ readonly token: string;
76
+ readonly tool: string;
77
+ readonly expiresAt: number;
78
+ }
79
+ | {
80
+ readonly ok: false;
81
+ readonly reason: "unknown" | "expired" | "same-session" | "reason-short" | "already-issued";
82
+ };
83
+
29
84
  /** Result of consuming a confirmation. */
30
85
  export type ConfirmConsumeResult =
31
86
  | { readonly ok: true; readonly pending: PendingConfirmation }
@@ -37,7 +92,8 @@ export type ConfirmConsumeResult =
37
92
  | "expired"
38
93
  | "tool-mismatch"
39
94
  | "args-mismatch"
40
- | "principal-mismatch"
95
+ | "session-mismatch"
96
+ | "issuer-mismatch"
41
97
  | "phrase-mismatch"
42
98
  | "reason-short";
43
99
  };
@@ -50,51 +106,128 @@ export interface ConfirmationGateOptions {
50
106
  }
51
107
 
52
108
  /**
53
- * Create a confirmation gate with **no session-level cache**.
54
- * Tokens are single-use and bound to tool + args + principal.
109
+ * In-process confirmation gate with **no session-level cache**.
110
+ *
111
+ * Tokens are single-use and bound to tool + args digest + target session.
112
+ * The map is per process: an id or token issued elsewhere fails closed
113
+ * with `"unknown"`. Do not add shared storage.
55
114
  */
56
- export function createConfirmationGate(options: ConfirmationGateOptions = {}): {
57
- readonly request: (input: {
115
+ export interface ConfirmationGate {
116
+ /**
117
+ * Bind a write attempt to the requester.
118
+ *
119
+ * Returns an opaque confirmation id and never a token.
120
+ *
121
+ * @param input - Tool, arguments, and requester session
122
+ */
123
+ readonly open: (input: {
58
124
  readonly tool: string;
59
125
  readonly args: unknown;
60
- readonly principalId: string;
126
+ readonly target: ConfirmationTarget;
127
+ }) => OpenConfirmation;
128
+ /**
129
+ * Issue a single-use token for an open confirmation.
130
+ *
131
+ * Succeeds only when `callerSid` is not the target session.
132
+ *
133
+ * @param input - Confirmation id, reason, and issuer session
134
+ */
135
+ readonly issue: (input: {
136
+ readonly confirmationId: string;
61
137
  readonly reason: string;
62
- }) => PendingConfirmation | { readonly error: "reason-short" };
138
+ readonly callerSid: string;
139
+ }) => ConfirmIssueResult;
140
+ /**
141
+ * Consume a token for one write.
142
+ *
143
+ * Requires `consumerSid === target.sid` and `issuerSid !== consumerSid`,
144
+ * plus single-use, tool, args digest, expiry, and phrase checks.
145
+ *
146
+ * @param input - Write attempt presenting the token
147
+ */
63
148
  readonly consume: (input: {
64
149
  readonly tool: string;
65
150
  readonly args: unknown;
66
- readonly principalId: string;
151
+ readonly consumerSid: string;
67
152
  readonly token: string;
68
153
  readonly phrase: string;
69
154
  readonly reason: string;
70
155
  }) => ConfirmConsumeResult;
71
156
  /** Test helper — pending count (never used for consent caching). */
72
157
  readonly size: () => number;
73
- } {
158
+ }
159
+
160
+ /** Mutable row stored in the per-process maps. */
161
+ interface ConfirmationRecord {
162
+ confirmationId: string;
163
+ token: string | null;
164
+ tool: string;
165
+ argsDigest: string;
166
+ target: ConfirmationTarget;
167
+ issuerSid: string | null;
168
+ expiresAt: number;
169
+ reason: string;
170
+ }
171
+
172
+ /**
173
+ * Create a confirmation gate with **no session-level cache**.
174
+ *
175
+ * @param options - Clock and TTL
176
+ */
177
+ export function createConfirmationGate(options: ConfirmationGateOptions = {}): ConfirmationGate {
74
178
  const now = options.now ?? (() => Date.now());
75
179
  const ttlMs = options.ttlMs ?? 2 * 60 * 1000;
76
- const pending = new Map<string, PendingConfirmation>();
180
+ // Per-process only. A token issued on another process is not in these maps
181
+ // and fails closed with "unknown". Do not add shared storage.
182
+ const byId = new Map<string, ConfirmationRecord>();
183
+ const byToken = new Map<string, ConfirmationRecord>();
77
184
 
78
185
  return {
79
- request(input) {
80
- if (input.reason.trim().length < 3) {
81
- return { error: "reason-short" as const };
82
- }
83
- prune(pending, now());
84
- const token = `mcp_c_${cryptoRandomHex(24)}`;
85
- const entry: PendingConfirmation = {
86
- token,
186
+ open(input) {
187
+ prune(byId, byToken, now());
188
+ const confirmationId = `mcp_id_${cryptoRandomHex(24)}`;
189
+ const entry: ConfirmationRecord = {
190
+ confirmationId,
191
+ token: null,
87
192
  tool: input.tool,
88
193
  argsDigest: digestArgs(input.args),
89
- principalId: input.principalId,
194
+ target: input.target,
195
+ issuerSid: null,
90
196
  expiresAt: now() + ttlMs,
91
- reason: input.reason.trim(),
197
+ reason: "",
92
198
  };
93
- pending.set(token, entry);
94
- return entry;
199
+ byId.set(confirmationId, entry);
200
+ return { confirmationId, expiresAt: entry.expiresAt };
201
+ },
202
+ issue(input) {
203
+ if (input.reason.trim().length < 3) {
204
+ return { ok: false, reason: "reason-short" };
205
+ }
206
+ prune(byId, byToken, now());
207
+ const entry = byId.get(input.confirmationId);
208
+ if (!entry) {
209
+ return { ok: false, reason: "unknown" };
210
+ }
211
+ if (entry.expiresAt <= now()) {
212
+ drop(byId, byToken, entry);
213
+ return { ok: false, reason: "expired" };
214
+ }
215
+ // Same auth session cannot request, confirm, and invoke.
216
+ if (input.callerSid === entry.target.sid) {
217
+ return { ok: false, reason: "same-session" };
218
+ }
219
+ if (entry.token !== null) {
220
+ return { ok: false, reason: "already-issued" };
221
+ }
222
+ const token = `mcp_c_${cryptoRandomHex(24)}`;
223
+ entry.token = token;
224
+ entry.issuerSid = input.callerSid;
225
+ entry.reason = input.reason.trim();
226
+ byToken.set(token, entry);
227
+ return { ok: true, token, tool: entry.tool, expiresAt: entry.expiresAt };
95
228
  },
96
229
  consume(input) {
97
- prune(pending, now());
230
+ prune(byId, byToken, now());
98
231
  if (!input.token) {
99
232
  return { ok: false, reason: "missing" };
100
233
  }
@@ -104,12 +237,13 @@ export function createConfirmationGate(options: ConfirmationGateOptions = {}): {
104
237
  if (input.reason.trim().length < 3) {
105
238
  return { ok: false, reason: "reason-short" };
106
239
  }
107
- const entry = pending.get(input.token);
240
+ const entry = byToken.get(input.token);
108
241
  if (!entry) {
242
+ // Missing locally — including a token issued on another process.
109
243
  return { ok: false, reason: "unknown" };
110
244
  }
111
245
  // Single-use: remove before further checks so replay fails.
112
- pending.delete(input.token);
246
+ drop(byId, byToken, entry);
113
247
  if (entry.expiresAt <= now()) {
114
248
  return { ok: false, reason: "expired" };
115
249
  }
@@ -119,14 +253,32 @@ export function createConfirmationGate(options: ConfirmationGateOptions = {}): {
119
253
  if (entry.argsDigest !== digestArgs(input.args)) {
120
254
  return { ok: false, reason: "args-mismatch" };
121
255
  }
122
- if (entry.principalId !== input.principalId) {
123
- return { ok: false, reason: "principal-mismatch" };
256
+ if (entry.target.sid !== input.consumerSid) {
257
+ return { ok: false, reason: "session-mismatch" };
124
258
  }
125
- return { ok: true, pending: entry };
259
+ if (entry.issuerSid === null || entry.issuerSid === input.consumerSid) {
260
+ return { ok: false, reason: "issuer-mismatch" };
261
+ }
262
+ if (entry.token === null) {
263
+ return { ok: false, reason: "unknown" };
264
+ }
265
+ return {
266
+ ok: true,
267
+ pending: {
268
+ confirmationId: entry.confirmationId,
269
+ token: entry.token,
270
+ tool: entry.tool,
271
+ argsDigest: entry.argsDigest,
272
+ target: entry.target,
273
+ issuerSid: entry.issuerSid,
274
+ expiresAt: entry.expiresAt,
275
+ reason: entry.reason,
276
+ },
277
+ };
126
278
  },
127
279
  size() {
128
- prune(pending, now());
129
- return pending.size;
280
+ prune(byId, byToken, now());
281
+ return byId.size;
130
282
  },
131
283
  };
132
284
  }
@@ -142,6 +294,15 @@ export function digestArgs(args: unknown): string {
142
294
  return hasher.digest("hex");
143
295
  }
144
296
 
297
+ function drop(
298
+ byId: Map<string, ConfirmationRecord>,
299
+ byToken: Map<string, ConfirmationRecord>,
300
+ entry: ConfirmationRecord,
301
+ ): void {
302
+ byId.delete(entry.confirmationId);
303
+ if (entry.token !== null) byToken.delete(entry.token);
304
+ }
305
+
145
306
  function cryptoRandomHex(byteLength: number): string {
146
307
  const bytes = new Uint8Array(byteLength);
147
308
  crypto.getRandomValues(bytes);
@@ -160,8 +321,12 @@ function canonicalJson(value: unknown): string {
160
321
  return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
161
322
  }
162
323
 
163
- function prune(pending: Map<string, PendingConfirmation>, t: number): void {
164
- for (const [token, entry] of pending) {
165
- if (entry.expiresAt <= t) pending.delete(token);
324
+ function prune(
325
+ byId: Map<string, ConfirmationRecord>,
326
+ byToken: Map<string, ConfirmationRecord>,
327
+ t: number,
328
+ ): void {
329
+ for (const entry of byId.values()) {
330
+ if (entry.expiresAt <= t) drop(byId, byToken, entry);
166
331
  }
167
332
  }
@@ -11,12 +11,15 @@ import {
11
11
  forbiddenResponse,
12
12
  resolveAllowedHosts,
13
13
  } from "../runtime/security.ts";
14
+ import { HttpBodyRejected, parseBody } from "../compiler/http-parse.ts";
14
15
  import { DOCS_MCP_PORT, type ServerHandle } from "../runtime/types.ts";
15
16
  import { asData } from "./data.ts";
16
17
  import { defaultDocsContentDir, loadDocsIndex, type DocsIndex } from "./docs-index.ts";
17
18
  import { createDocsToolRuntime } from "./docs-tools.ts";
18
19
  import {
19
- MCP_PROTOCOL_VERSION,
20
+ acceptsMcpProtocolVersion,
21
+ negotiateMcpProtocolVersion,
22
+ protocolVersionOf,
20
23
  parseJsonRpcRequest,
21
24
  parseToolsCallParams,
22
25
  rpcError,
@@ -79,8 +82,11 @@ export async function createDocsMcpServer(
79
82
 
80
83
  let body: unknown;
81
84
  try {
82
- body = await request.json();
83
- } catch {
85
+ body = await parseBody(request);
86
+ } catch (err) {
87
+ if (err instanceof HttpBodyRejected && err.code === "PayloadTooLarge") {
88
+ return new Response("Payload Too Large", { status: 413 });
89
+ }
84
90
  return jsonRpcHttp(rpcError(null, RpcErrorCode.parse, "invalid JSON body"), 400);
85
91
  }
86
92
 
@@ -94,10 +100,18 @@ export async function createDocsMcpServer(
94
100
 
95
101
  switch (rpc.method) {
96
102
  case "initialize": {
103
+ const requested = protocolVersionOf(rpc.params);
104
+ const protocolVersion = negotiateMcpProtocolVersion(requested);
105
+ if (protocolVersion === undefined) {
106
+ return jsonRpcHttp(
107
+ rpcError(id, RpcErrorCode.invalidParams, "unsupported protocol version"),
108
+ 400,
109
+ );
110
+ }
97
111
  const sessionId = newMcpTransportSessionId();
98
112
  transportSessions.add(sessionId);
99
113
  const result: McpInitializeResult = {
100
- protocolVersion: MCP_PROTOCOL_VERSION,
114
+ protocolVersion,
101
115
  capabilities: { tools: { listChanged: false } },
102
116
  serverInfo: { name: "okengine-docs-mcp", version },
103
117
  sessionId,
@@ -124,6 +138,13 @@ export async function createDocsMcpServer(
124
138
  );
125
139
  }
126
140
  case "tools/call": {
141
+ const requested = protocolVersionOf(rpc.params);
142
+ if (requested !== undefined && !acceptsMcpProtocolVersion(requested)) {
143
+ return jsonRpcHttp(
144
+ rpcError(id, RpcErrorCode.invalidParams, "unsupported protocol version"),
145
+ 400,
146
+ );
147
+ }
127
148
  const call = parseToolsCallParams(rpc.params);
128
149
  if (!call.ok) {
129
150
  return jsonRpcHttp(rpcError(id, RpcErrorCode.invalidParams, call.message), 400);
@@ -185,6 +206,7 @@ export async function serveDocsMcp(
185
206
  const server = Bun.serve({
186
207
  hostname,
187
208
  port,
209
+ maxRequestBodySize: 1_048_576,
188
210
  fetch: mcp.fetch,
189
211
  });
190
212
  const boundPort = server.port ?? port;
package/src/mcp/index.ts CHANGED
@@ -32,8 +32,12 @@ export {
32
32
  createConfirmationGate,
33
33
  digestArgs,
34
34
  MCP_CONFIRM_PHRASE,
35
+ type ConfirmationGate,
36
+ type ConfirmationTarget,
35
37
  type ConfirmConsumeResult,
38
+ type ConfirmIssueResult,
36
39
  type ConfirmationGateOptions,
40
+ type OpenConfirmation,
37
41
  type PendingConfirmation,
38
42
  } from "./confirmation.ts";
39
43