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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- 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/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.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 +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -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 +59 -0
- package/src/elements/store/cache.ts +157 -12
- 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.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- 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/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
package/src/mcp/mcp.test.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
123
|
-
|
|
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("
|
|
217
|
+
test("one session cannot request, confirm, and invoke", async () => {
|
|
150
218
|
let invoked = 0;
|
|
151
|
-
const runtime =
|
|
152
|
-
|
|
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
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
reason:
|
|
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
|
|
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(
|
|
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:
|
|
270
|
+
reason: CONFIRM_REASON,
|
|
184
271
|
});
|
|
185
272
|
expect(first.ok).toBe(true);
|
|
186
273
|
expect(invoked).toBe(1);
|
|
274
|
+
});
|
|
187
275
|
|
|
188
|
-
|
|
189
|
-
|
|
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:
|
|
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
|
|
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;
|
package/src/mcp/session.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
454
|
+
confirm: ConfirmationGate,
|
|
416
455
|
requester: McpRequester,
|
|
417
456
|
args: Record<string, unknown>,
|
|
418
457
|
): ToolCallResult {
|
|
419
|
-
const
|
|
420
|
-
const reason =
|
|
421
|
-
const
|
|
422
|
-
|
|
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 (
|
|
465
|
+
if (!issued.ok) {
|
|
432
466
|
return {
|
|
433
467
|
ok: false,
|
|
434
|
-
code: "invalid",
|
|
435
|
-
message:
|
|
436
|
-
data: asData({ error: issued.
|
|
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;
|
|
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,
|
package/src/runtime/bun.ts
CHANGED
|
@@ -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 = (
|
package/src/runtime/types.ts
CHANGED
|
@@ -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}. */
|