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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- 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
|
}
|
package/src/kernel/pipeline.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/mcp/authorization.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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: ["
|
|
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
|
},
|
package/src/mcp/confirmation.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
22
|
-
readonly
|
|
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
|
|
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
|
-
| "
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
194
|
+
target: input.target,
|
|
195
|
+
issuerSid: null,
|
|
90
196
|
expiresAt: now() + ttlMs,
|
|
91
|
-
reason:
|
|
197
|
+
reason: "",
|
|
92
198
|
};
|
|
93
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
123
|
-
return { ok: false, reason: "
|
|
256
|
+
if (entry.target.sid !== input.consumerSid) {
|
|
257
|
+
return { ok: false, reason: "session-mismatch" };
|
|
124
258
|
}
|
|
125
|
-
|
|
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(
|
|
129
|
-
return
|
|
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(
|
|
164
|
-
|
|
165
|
-
|
|
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
|
}
|
package/src/mcp/docs-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|