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
package/src/mcp/mcp.test.ts
CHANGED
|
@@ -12,9 +12,15 @@ import type { WideEvent } from "../runs/types.ts";
|
|
|
12
12
|
import { authorizeToolCall, expandOperatorScopes } from "./authorization.ts";
|
|
13
13
|
import { MCP_CONFIRM_PHRASE } from "./confirmation.ts";
|
|
14
14
|
import { asData, isDataEnvelope, MCP_DATA_KIND } from "./data.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
authenticateMcpRequest,
|
|
17
|
+
mintMcpSession,
|
|
18
|
+
MCP_AUDIENCE,
|
|
19
|
+
type McpRequester,
|
|
20
|
+
} from "./session.ts";
|
|
16
21
|
import { createMcpServer } from "./server.ts";
|
|
17
|
-
import { createToolRuntime } from "./tools.ts";
|
|
22
|
+
import { createToolRuntime, type McpContext } from "./tools.ts";
|
|
23
|
+
import { MCP_CLIENT_PROTOCOL_VERSION, MCP_PROTOCOL_VERSION } from "./versions.ts";
|
|
18
24
|
|
|
19
25
|
const SECRET = "mcp-test-secret";
|
|
20
26
|
|
|
@@ -105,6 +111,69 @@ describe("MCP audience validation", () => {
|
|
|
105
111
|
});
|
|
106
112
|
});
|
|
107
113
|
|
|
114
|
+
const CONFIRM_REASON = "operator approved test invoke";
|
|
115
|
+
|
|
116
|
+
function stringField(content: unknown, key: string): string {
|
|
117
|
+
if (content === null || typeof content !== "object" || Array.isArray(content)) {
|
|
118
|
+
throw new Error(`expected object content for ${key}`);
|
|
119
|
+
}
|
|
120
|
+
const value = (content as Record<string, unknown>)[key];
|
|
121
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
122
|
+
throw new Error(`expected string ${key}`);
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function runtimeWithInvoke(onInvoke: () => void): ReturnType<typeof createToolRuntime> {
|
|
128
|
+
const ctx: McpContext = {
|
|
129
|
+
getManifest: () => SAMPLE_MANIFEST,
|
|
130
|
+
listRuns: async () => [],
|
|
131
|
+
invokeFlow: async (input) => {
|
|
132
|
+
onInvoke();
|
|
133
|
+
return { ok: true, flowId: input.flowId };
|
|
134
|
+
},
|
|
135
|
+
proposeStructural: async () => {
|
|
136
|
+
onInvoke();
|
|
137
|
+
return { ok: true };
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
return createToolRuntime(ctx);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function twoSessionsOf(
|
|
144
|
+
principalId: string,
|
|
145
|
+
): Promise<{ readonly a: McpRequester; readonly b: McpRequester }> {
|
|
146
|
+
const store = createSessionStore();
|
|
147
|
+
const mint = () =>
|
|
148
|
+
mintMcpSession({
|
|
149
|
+
store,
|
|
150
|
+
secret: SECRET,
|
|
151
|
+
principalId,
|
|
152
|
+
scopes: ["console:*"],
|
|
153
|
+
});
|
|
154
|
+
const issuedA = await mint();
|
|
155
|
+
const issuedB = await mint();
|
|
156
|
+
const a = await authenticateMcpRequest(store, SECRET, issuedA.accessToken);
|
|
157
|
+
const b = await authenticateMcpRequest(store, SECRET, issuedB.accessToken);
|
|
158
|
+
return { a, b };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function openConfirmation(
|
|
162
|
+
runtime: ReturnType<typeof createToolRuntime>,
|
|
163
|
+
requester: McpRequester,
|
|
164
|
+
tool: string,
|
|
165
|
+
args: Record<string, unknown>,
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const opened = await runtime.callTool(requester, tool, args);
|
|
168
|
+
expect(opened.ok).toBe(false);
|
|
169
|
+
if (opened.ok) throw new Error("expected confirmation challenge");
|
|
170
|
+
const confirmationId = stringField(opened.data.content, "confirmationId");
|
|
171
|
+
expect(confirmationId.startsWith("mcp_id_")).toBe(true);
|
|
172
|
+
expect(JSON.stringify(opened.data)).not.toContain("mcp_c_");
|
|
173
|
+
expect(JSON.stringify(opened.data)).not.toContain("confirmToken");
|
|
174
|
+
return confirmationId;
|
|
175
|
+
}
|
|
176
|
+
|
|
108
177
|
describe("MCP write confirmation", () => {
|
|
109
178
|
test("agent cannot invoke a write tool without confirmation", async () => {
|
|
110
179
|
const decision = authorizeToolCall(
|
|
@@ -118,12 +187,8 @@ describe("MCP write confirmation", () => {
|
|
|
118
187
|
expect(decision.reason).toBe("confirmation-required");
|
|
119
188
|
}
|
|
120
189
|
|
|
121
|
-
const runtime =
|
|
122
|
-
|
|
123
|
-
listRuns: async () => [],
|
|
124
|
-
invokeFlow: async () => {
|
|
125
|
-
throw new Error("must not invoke");
|
|
126
|
-
},
|
|
190
|
+
const runtime = runtimeWithInvoke(() => {
|
|
191
|
+
throw new Error("must not invoke");
|
|
127
192
|
});
|
|
128
193
|
const store = createSessionStore();
|
|
129
194
|
const issued = await mintMcpSession({
|
|
@@ -142,59 +207,211 @@ describe("MCP write confirmation", () => {
|
|
|
142
207
|
expect(result.code).toBe("forbidden");
|
|
143
208
|
expect(result.message).toContain("confirmation");
|
|
144
209
|
expect(isDataEnvelope(result.data)).toBe(true);
|
|
210
|
+
const confirmationId = stringField(result.data.content, "confirmationId");
|
|
211
|
+
expect(confirmationId.startsWith("mcp_id_")).toBe(true);
|
|
212
|
+
expect(JSON.stringify(result.data)).not.toContain("mcp_c_");
|
|
213
|
+
expect(JSON.stringify(result.data)).not.toContain("confirmToken");
|
|
145
214
|
}
|
|
146
215
|
});
|
|
147
216
|
|
|
148
|
-
test("
|
|
217
|
+
test("one session cannot request, confirm, and invoke", async () => {
|
|
149
218
|
let invoked = 0;
|
|
150
|
-
const runtime =
|
|
151
|
-
|
|
152
|
-
listRuns: async () => [],
|
|
153
|
-
invokeFlow: async (input) => {
|
|
154
|
-
invoked += 1;
|
|
155
|
-
return { ok: true, flowId: input.flowId };
|
|
156
|
-
},
|
|
219
|
+
const runtime = runtimeWithInvoke(() => {
|
|
220
|
+
invoked += 1;
|
|
157
221
|
});
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
222
|
+
const { a } = await twoSessionsOf("op1");
|
|
223
|
+
const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
|
|
224
|
+
const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
|
|
225
|
+
|
|
226
|
+
const confirm = await runtime.callTool(a, "oke.action.confirm", {
|
|
227
|
+
confirmationId,
|
|
228
|
+
reason: CONFIRM_REASON,
|
|
164
229
|
});
|
|
165
|
-
|
|
230
|
+
expect(confirm.ok).toBe(false);
|
|
231
|
+
if (!confirm.ok) {
|
|
232
|
+
expect(confirm.message).toContain("different auth session");
|
|
233
|
+
expect(JSON.stringify(confirm.data)).not.toContain("mcp_c_");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const invoke = await runtime.callTool(a, "oke.action.invoke", {
|
|
237
|
+
...actionArgs,
|
|
238
|
+
confirmation: MCP_CONFIRM_PHRASE,
|
|
239
|
+
reason: CONFIRM_REASON,
|
|
240
|
+
});
|
|
241
|
+
expect(invoke.ok).toBe(false);
|
|
242
|
+
expect(invoked).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a second session of the same principal confirms and the first invokes once", async () => {
|
|
246
|
+
let invoked = 0;
|
|
247
|
+
const runtime = runtimeWithInvoke(() => {
|
|
248
|
+
invoked += 1;
|
|
249
|
+
});
|
|
250
|
+
const { a, b } = await twoSessionsOf("op1");
|
|
251
|
+
expect(a.principalId).toBe(b.principalId);
|
|
252
|
+
expect(a.sessionId).not.toBe(b.sessionId);
|
|
166
253
|
|
|
167
254
|
const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
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,
|
|
172
259
|
});
|
|
173
260
|
expect(confirm.ok).toBe(true);
|
|
174
261
|
if (!confirm.ok) return;
|
|
175
|
-
const token = (confirm.data.content
|
|
262
|
+
const token = stringField(confirm.data.content, "confirmToken");
|
|
176
263
|
expect(token.startsWith("mcp_c_")).toBe(true);
|
|
264
|
+
expect(token).not.toBe(confirmationId);
|
|
177
265
|
|
|
178
|
-
const first = await runtime.callTool(
|
|
266
|
+
const first = await runtime.callTool(a, "oke.action.invoke", {
|
|
179
267
|
...actionArgs,
|
|
180
268
|
confirmation: MCP_CONFIRM_PHRASE,
|
|
181
269
|
confirmToken: token,
|
|
182
|
-
reason:
|
|
270
|
+
reason: CONFIRM_REASON,
|
|
183
271
|
});
|
|
184
272
|
expect(first.ok).toBe(true);
|
|
185
273
|
expect(invoked).toBe(1);
|
|
274
|
+
});
|
|
186
275
|
|
|
187
|
-
|
|
188
|
-
|
|
276
|
+
test("the issuer cannot consume the token", async () => {
|
|
277
|
+
let invoked = 0;
|
|
278
|
+
const runtime = runtimeWithInvoke(() => {
|
|
279
|
+
invoked += 1;
|
|
280
|
+
});
|
|
281
|
+
const { a, b } = await twoSessionsOf("op1");
|
|
282
|
+
const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
|
|
283
|
+
const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
|
|
284
|
+
const confirm = await runtime.callTool(b, "oke.action.confirm", {
|
|
285
|
+
confirmationId,
|
|
286
|
+
reason: CONFIRM_REASON,
|
|
287
|
+
});
|
|
288
|
+
expect(confirm.ok).toBe(true);
|
|
289
|
+
if (!confirm.ok) return;
|
|
290
|
+
const token = stringField(confirm.data.content, "confirmToken");
|
|
291
|
+
|
|
292
|
+
const stolen = await runtime.callTool(b, "oke.action.invoke", {
|
|
189
293
|
...actionArgs,
|
|
190
294
|
confirmation: MCP_CONFIRM_PHRASE,
|
|
191
295
|
confirmToken: token,
|
|
192
|
-
reason:
|
|
296
|
+
reason: CONFIRM_REASON,
|
|
297
|
+
});
|
|
298
|
+
expect(stolen.ok).toBe(false);
|
|
299
|
+
if (!stolen.ok) expect(stolen.message).toContain("session-mismatch");
|
|
300
|
+
expect(invoked).toBe(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("replay of a consumed token fails", async () => {
|
|
304
|
+
let invoked = 0;
|
|
305
|
+
const runtime = runtimeWithInvoke(() => {
|
|
306
|
+
invoked += 1;
|
|
307
|
+
});
|
|
308
|
+
const { a, b } = await twoSessionsOf("op1");
|
|
309
|
+
const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
|
|
310
|
+
const confirmationId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
|
|
311
|
+
const confirm = await runtime.callTool(b, "oke.action.confirm", {
|
|
312
|
+
confirmationId,
|
|
313
|
+
reason: CONFIRM_REASON,
|
|
314
|
+
});
|
|
315
|
+
expect(confirm.ok).toBe(true);
|
|
316
|
+
if (!confirm.ok) return;
|
|
317
|
+
const token = stringField(confirm.data.content, "confirmToken");
|
|
318
|
+
|
|
319
|
+
const first = await runtime.callTool(a, "oke.action.invoke", {
|
|
320
|
+
...actionArgs,
|
|
321
|
+
confirmation: MCP_CONFIRM_PHRASE,
|
|
322
|
+
confirmToken: token,
|
|
323
|
+
reason: CONFIRM_REASON,
|
|
324
|
+
});
|
|
325
|
+
expect(first.ok).toBe(true);
|
|
326
|
+
|
|
327
|
+
const replay = await runtime.callTool(a, "oke.action.invoke", {
|
|
328
|
+
...actionArgs,
|
|
329
|
+
confirmation: MCP_CONFIRM_PHRASE,
|
|
330
|
+
confirmToken: token,
|
|
331
|
+
reason: CONFIRM_REASON,
|
|
193
332
|
});
|
|
194
333
|
expect(replay.ok).toBe(false);
|
|
334
|
+
if (!replay.ok) expect(replay.message).toContain("unknown");
|
|
195
335
|
expect(invoked).toBe(1);
|
|
196
336
|
expect(runtime.confirmationSize()).toBe(0);
|
|
197
337
|
});
|
|
338
|
+
|
|
339
|
+
test("wrong tool or args is rejected", async () => {
|
|
340
|
+
let invoked = 0;
|
|
341
|
+
const runtime = runtimeWithInvoke(() => {
|
|
342
|
+
invoked += 1;
|
|
343
|
+
});
|
|
344
|
+
const { a, b } = await twoSessionsOf("op1");
|
|
345
|
+
const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
|
|
346
|
+
|
|
347
|
+
const argsId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
|
|
348
|
+
const argsConfirm = await runtime.callTool(b, "oke.action.confirm", {
|
|
349
|
+
confirmationId: argsId,
|
|
350
|
+
reason: CONFIRM_REASON,
|
|
351
|
+
});
|
|
352
|
+
expect(argsConfirm.ok).toBe(true);
|
|
353
|
+
if (!argsConfirm.ok) return;
|
|
354
|
+
const argsToken = stringField(argsConfirm.data.content, "confirmToken");
|
|
355
|
+
const wrongArgs = await runtime.callTool(a, "oke.action.invoke", {
|
|
356
|
+
flowId: "bookings.create",
|
|
357
|
+
body: { name: "other" },
|
|
358
|
+
confirmation: MCP_CONFIRM_PHRASE,
|
|
359
|
+
confirmToken: argsToken,
|
|
360
|
+
reason: CONFIRM_REASON,
|
|
361
|
+
});
|
|
362
|
+
expect(wrongArgs.ok).toBe(false);
|
|
363
|
+
if (!wrongArgs.ok) expect(wrongArgs.message).toContain("args-mismatch");
|
|
364
|
+
|
|
365
|
+
const toolId = await openConfirmation(runtime, a, "oke.action.invoke", actionArgs);
|
|
366
|
+
const toolConfirm = await runtime.callTool(b, "oke.action.confirm", {
|
|
367
|
+
confirmationId: toolId,
|
|
368
|
+
reason: CONFIRM_REASON,
|
|
369
|
+
});
|
|
370
|
+
expect(toolConfirm.ok).toBe(true);
|
|
371
|
+
if (!toolConfirm.ok) return;
|
|
372
|
+
const toolToken = stringField(toolConfirm.data.content, "confirmToken");
|
|
373
|
+
const wrongTool = await runtime.callTool(a, "oke.action.structural_propose", {
|
|
374
|
+
title: "rename",
|
|
375
|
+
relativePath: "src/bookings.ts",
|
|
376
|
+
contents: "export {}",
|
|
377
|
+
confirmation: MCP_CONFIRM_PHRASE,
|
|
378
|
+
confirmToken: toolToken,
|
|
379
|
+
reason: CONFIRM_REASON,
|
|
380
|
+
});
|
|
381
|
+
expect(wrongTool.ok).toBe(false);
|
|
382
|
+
if (!wrongTool.ok) expect(wrongTool.message).toContain("tool-mismatch");
|
|
383
|
+
expect(invoked).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("a token issued on another runtime is unknown", async () => {
|
|
387
|
+
let invoked = 0;
|
|
388
|
+
const issuer = runtimeWithInvoke(() => {
|
|
389
|
+
invoked += 1;
|
|
390
|
+
});
|
|
391
|
+
const other = runtimeWithInvoke(() => {
|
|
392
|
+
invoked += 1;
|
|
393
|
+
});
|
|
394
|
+
const { a, b } = await twoSessionsOf("op1");
|
|
395
|
+
const actionArgs = { flowId: "bookings.create", body: { name: "Ada" } };
|
|
396
|
+
const confirmationId = await openConfirmation(issuer, a, "oke.action.invoke", actionArgs);
|
|
397
|
+
const confirm = await issuer.callTool(b, "oke.action.confirm", {
|
|
398
|
+
confirmationId,
|
|
399
|
+
reason: CONFIRM_REASON,
|
|
400
|
+
});
|
|
401
|
+
expect(confirm.ok).toBe(true);
|
|
402
|
+
if (!confirm.ok) return;
|
|
403
|
+
const token = stringField(confirm.data.content, "confirmToken");
|
|
404
|
+
|
|
405
|
+
const cross = await other.callTool(a, "oke.action.invoke", {
|
|
406
|
+
...actionArgs,
|
|
407
|
+
confirmation: MCP_CONFIRM_PHRASE,
|
|
408
|
+
confirmToken: token,
|
|
409
|
+
reason: CONFIRM_REASON,
|
|
410
|
+
});
|
|
411
|
+
expect(cross.ok).toBe(false);
|
|
412
|
+
if (!cross.ok) expect(cross.message).toContain("unknown");
|
|
413
|
+
expect(invoked).toBe(0);
|
|
414
|
+
});
|
|
198
415
|
});
|
|
199
416
|
|
|
200
417
|
describe("MCP inert data envelope", () => {
|
|
@@ -292,6 +509,7 @@ describe("MCP HTTP server", () => {
|
|
|
292
509
|
result: { tools: { name: string }[] };
|
|
293
510
|
};
|
|
294
511
|
expect(listBody.result.tools.some((t) => t.name === "oke.manifest.get")).toBe(true);
|
|
512
|
+
expect(listBody.result.tools.some((t) => t.name === "oke.action.invoke")).toBe(true);
|
|
295
513
|
|
|
296
514
|
const call = await server.fetch(
|
|
297
515
|
new Request("http://127.0.0.1:6535/mcp", {
|
|
@@ -314,4 +532,157 @@ describe("MCP HTTP server", () => {
|
|
|
314
532
|
expect(callBody.result.structuredContent.kind).toBe("data");
|
|
315
533
|
expect(callBody.result.structuredContent.content.manifest.app).toBe("skyport");
|
|
316
534
|
});
|
|
535
|
+
|
|
536
|
+
test("read-only tools/list omits write tools and a hidden write call is 403", async () => {
|
|
537
|
+
let invoked = 0;
|
|
538
|
+
const store = createSessionStore();
|
|
539
|
+
const issued = await mintMcpSession({
|
|
540
|
+
store,
|
|
541
|
+
secret: SECRET,
|
|
542
|
+
principalId: "op-read",
|
|
543
|
+
scopes: ["mcp:manifest:read"],
|
|
544
|
+
});
|
|
545
|
+
const server = createMcpServer({
|
|
546
|
+
sessions: store,
|
|
547
|
+
secret: SECRET,
|
|
548
|
+
context: {
|
|
549
|
+
getManifest: () => SAMPLE_MANIFEST,
|
|
550
|
+
listRuns: async () => [],
|
|
551
|
+
invokeFlow: async () => {
|
|
552
|
+
invoked += 1;
|
|
553
|
+
return { ok: true };
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
const headers = {
|
|
558
|
+
host: "127.0.0.1:6535",
|
|
559
|
+
authorization: `Bearer ${issued.accessToken}`,
|
|
560
|
+
"content-type": "application/json",
|
|
561
|
+
};
|
|
562
|
+
const list = await server.fetch(
|
|
563
|
+
new Request("http://127.0.0.1:6535/mcp", {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers,
|
|
566
|
+
body: JSON.stringify({
|
|
567
|
+
jsonrpc: "2.0",
|
|
568
|
+
id: 1,
|
|
569
|
+
method: "tools/list",
|
|
570
|
+
}),
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
expect(list.status).toBe(200);
|
|
574
|
+
const listBody = (await list.json()) as {
|
|
575
|
+
result: {
|
|
576
|
+
tools: { name: string; annotations: { readOnlyHint: boolean; destructiveHint: boolean } }[];
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
expect(listBody.result.tools.some((t) => t.name === "oke.manifest.get")).toBe(true);
|
|
580
|
+
expect(listBody.result.tools.some((t) => t.name === "oke.action.invoke")).toBe(false);
|
|
581
|
+
expect(listBody.result.tools.some((t) => t.name === "oke.action.structural_propose")).toBe(
|
|
582
|
+
false,
|
|
583
|
+
);
|
|
584
|
+
expect(listBody.result.tools.every((t) => t.annotations.destructiveHint === false)).toBe(true);
|
|
585
|
+
|
|
586
|
+
const call = await server.fetch(
|
|
587
|
+
new Request("http://127.0.0.1:6535/mcp", {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers,
|
|
590
|
+
body: JSON.stringify({
|
|
591
|
+
jsonrpc: "2.0",
|
|
592
|
+
id: 2,
|
|
593
|
+
method: "tools/call",
|
|
594
|
+
params: {
|
|
595
|
+
name: "oke.action.invoke",
|
|
596
|
+
arguments: { flowId: "bookings.create", body: { name: "x" } },
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
expect(call.status).toBe(403);
|
|
602
|
+
const callBody = (await call.json()) as { error: { message: string } };
|
|
603
|
+
expect(callBody.error.message).toMatch(/missing one of/i);
|
|
604
|
+
expect(invoked).toBe(0);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("initialize advertises 2024-11-05 and rejects a version the server does not implement", async () => {
|
|
608
|
+
const store = createSessionStore();
|
|
609
|
+
const issued = await mintMcpSession({
|
|
610
|
+
store,
|
|
611
|
+
secret: SECRET,
|
|
612
|
+
principalId: "op1",
|
|
613
|
+
scopes: ["console:*"],
|
|
614
|
+
});
|
|
615
|
+
const server = createMcpServer({
|
|
616
|
+
sessions: store,
|
|
617
|
+
secret: SECRET,
|
|
618
|
+
context: {
|
|
619
|
+
getManifest: () => SAMPLE_MANIFEST,
|
|
620
|
+
listRuns: async () => [],
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
const headers = {
|
|
624
|
+
host: "127.0.0.1:6535",
|
|
625
|
+
authorization: `Bearer ${issued.accessToken}`,
|
|
626
|
+
"content-type": "application/json",
|
|
627
|
+
};
|
|
628
|
+
const init = await server.fetch(
|
|
629
|
+
new Request("http://127.0.0.1:6535/mcp", {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers,
|
|
632
|
+
body: JSON.stringify({
|
|
633
|
+
jsonrpc: "2.0",
|
|
634
|
+
id: 1,
|
|
635
|
+
method: "initialize",
|
|
636
|
+
params: { protocolVersion: MCP_PROTOCOL_VERSION },
|
|
637
|
+
}),
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
expect(init.status).toBe(200);
|
|
641
|
+
const initBody = (await init.json()) as { result: { protocolVersion: string } };
|
|
642
|
+
expect(initBody.result.protocolVersion).toBe("2024-11-05");
|
|
643
|
+
const omitted = await server.fetch(
|
|
644
|
+
new Request("http://127.0.0.1:6535/mcp", {
|
|
645
|
+
method: "POST",
|
|
646
|
+
headers,
|
|
647
|
+
body: JSON.stringify({
|
|
648
|
+
jsonrpc: "2.0",
|
|
649
|
+
id: 2,
|
|
650
|
+
method: "initialize",
|
|
651
|
+
params: {},
|
|
652
|
+
}),
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
655
|
+
const omittedBody = (await omitted.json()) as { result: { protocolVersion: string } };
|
|
656
|
+
expect(omittedBody.result.protocolVersion).toBe("2024-11-05");
|
|
657
|
+
const rejected = await server.fetch(
|
|
658
|
+
new Request("http://127.0.0.1:6535/mcp", {
|
|
659
|
+
method: "POST",
|
|
660
|
+
headers,
|
|
661
|
+
body: JSON.stringify({
|
|
662
|
+
jsonrpc: "2.0",
|
|
663
|
+
id: 3,
|
|
664
|
+
method: "initialize",
|
|
665
|
+
params: { protocolVersion: MCP_CLIENT_PROTOCOL_VERSION },
|
|
666
|
+
}),
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
expect(rejected.status).toBe(400);
|
|
670
|
+
const call = await server.fetch(
|
|
671
|
+
new Request("http://127.0.0.1:6535/mcp", {
|
|
672
|
+
method: "POST",
|
|
673
|
+
headers,
|
|
674
|
+
body: JSON.stringify({
|
|
675
|
+
jsonrpc: "2.0",
|
|
676
|
+
id: 4,
|
|
677
|
+
method: "tools/call",
|
|
678
|
+
params: {
|
|
679
|
+
name: "oke.manifest.get",
|
|
680
|
+
arguments: {},
|
|
681
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
682
|
+
},
|
|
683
|
+
}),
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
expect(call.status).toBe(200);
|
|
687
|
+
});
|
|
317
688
|
});
|
package/src/mcp/protocol.ts
CHANGED
|
@@ -129,8 +129,14 @@ export interface McpInitializeResult {
|
|
|
129
129
|
readonly sessionId: string;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
/**
|
|
133
|
-
export
|
|
132
|
+
/** Highest protocol version the servers implement (`2024-11-05`). */
|
|
133
|
+
export {
|
|
134
|
+
MCP_PROTOCOL_VERSION,
|
|
135
|
+
MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
136
|
+
acceptsMcpProtocolVersion,
|
|
137
|
+
negotiateMcpProtocolVersion,
|
|
138
|
+
protocolVersionOf,
|
|
139
|
+
} from "./versions.ts";
|
|
134
140
|
|
|
135
141
|
/**
|
|
136
142
|
* Parse tools/call params.
|
package/src/mcp/server.ts
CHANGED
|
@@ -14,11 +14,14 @@ import {
|
|
|
14
14
|
forbiddenResponse,
|
|
15
15
|
resolveAllowedHosts,
|
|
16
16
|
} from "../runtime/security.ts";
|
|
17
|
+
import { HttpBodyRejected, parseBody } from "../compiler/http-parse.ts";
|
|
17
18
|
import { MCP_PORT, type ServerHandle } from "../runtime/types.ts";
|
|
18
19
|
import { SessionError, type SessionStore } from "../auth/sessions.ts";
|
|
19
20
|
import { asData } from "./data.ts";
|
|
20
21
|
import {
|
|
21
|
-
|
|
22
|
+
acceptsMcpProtocolVersion,
|
|
23
|
+
negotiateMcpProtocolVersion,
|
|
24
|
+
protocolVersionOf,
|
|
22
25
|
parseJsonRpcRequest,
|
|
23
26
|
parseToolsCallParams,
|
|
24
27
|
rpcError,
|
|
@@ -133,8 +136,11 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
|
|
|
133
136
|
|
|
134
137
|
let body: unknown;
|
|
135
138
|
try {
|
|
136
|
-
body = await request
|
|
137
|
-
} catch {
|
|
139
|
+
body = await parseBody(request);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err instanceof HttpBodyRejected && err.code === "PayloadTooLarge") {
|
|
142
|
+
return respond(new Response("Payload Too Large", { status: 413 }));
|
|
143
|
+
}
|
|
138
144
|
return respond(jsonRpcHttp(rpcError(null, RpcErrorCode.parse, "invalid JSON body"), 400));
|
|
139
145
|
}
|
|
140
146
|
|
|
@@ -149,10 +155,20 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
|
|
|
149
155
|
|
|
150
156
|
switch (rpc.method) {
|
|
151
157
|
case "initialize": {
|
|
158
|
+
const requested = protocolVersionOf(rpc.params);
|
|
159
|
+
const protocolVersion = negotiateMcpProtocolVersion(requested);
|
|
160
|
+
if (protocolVersion === undefined) {
|
|
161
|
+
return respond(
|
|
162
|
+
jsonRpcHttp(
|
|
163
|
+
rpcError(id, RpcErrorCode.invalidParams, "unsupported protocol version"),
|
|
164
|
+
400,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
152
168
|
const sessionId = newMcpTransportSessionId();
|
|
153
169
|
transportSessions.add(sessionId);
|
|
154
170
|
const result: McpInitializeResult = {
|
|
155
|
-
protocolVersion
|
|
171
|
+
protocolVersion,
|
|
156
172
|
capabilities: { tools: { listChanged: false } },
|
|
157
173
|
serverInfo: { name: "okengine-mcp", version },
|
|
158
174
|
sessionId,
|
|
@@ -162,7 +178,7 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
|
|
|
162
178
|
case "ping":
|
|
163
179
|
return respond(jsonRpcHttp(rpcSuccess(id, { ok: true })));
|
|
164
180
|
case "tools/list": {
|
|
165
|
-
const listed = tools.listTools().map((t) => ({
|
|
181
|
+
const listed = tools.listTools(requester).map((t) => ({
|
|
166
182
|
name: t.name,
|
|
167
183
|
description: t.description,
|
|
168
184
|
inputSchema: t.inputSchema,
|
|
@@ -182,6 +198,15 @@ export function createMcpServer(options: CreateMcpServerOptions): McpServer {
|
|
|
182
198
|
);
|
|
183
199
|
}
|
|
184
200
|
case "tools/call": {
|
|
201
|
+
const requested = protocolVersionOf(rpc.params);
|
|
202
|
+
if (requested !== undefined && !acceptsMcpProtocolVersion(requested)) {
|
|
203
|
+
return respond(
|
|
204
|
+
jsonRpcHttp(
|
|
205
|
+
rpcError(id, RpcErrorCode.invalidParams, "unsupported protocol version"),
|
|
206
|
+
400,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
185
210
|
const call = parseToolsCallParams(rpc.params);
|
|
186
211
|
if (!call.ok) {
|
|
187
212
|
return respond(jsonRpcHttp(rpcError(id, RpcErrorCode.invalidParams, call.message), 400));
|
|
@@ -256,6 +281,7 @@ export async function serveMcp(options: ServeMcpOptions): Promise<McpServerHandl
|
|
|
256
281
|
const server = Bun.serve({
|
|
257
282
|
hostname,
|
|
258
283
|
port,
|
|
284
|
+
maxRequestBodySize: 1_048_576,
|
|
259
285
|
fetch: mcp.fetch,
|
|
260
286
|
});
|
|
261
287
|
const boundPort = server.port ?? port;
|
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
|
|