create-ailk 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/module-architecture.js +27 -0
  2. package/package.json +3 -3
  3. package/templates/.env.example +23 -9
  4. package/templates/apps/api/CLAUDE.md +21 -17
  5. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  6. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  7. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  8. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  9. package/templates/apps/api/src/config/index.ts +35 -20
  10. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  11. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  12. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  13. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  14. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  15. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  16. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  17. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  18. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  19. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  20. package/templates/apps/api/src/middleware/auth.ts +219 -5
  21. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  22. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  24. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  25. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  26. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  27. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  28. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  29. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  30. package/templates/apps/api/src/server.ts +27 -0
  31. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  32. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  33. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  34. package/templates/apps/api/src/services/audit-log.ts +278 -47
  35. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  36. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  37. package/templates/apps/api/src/services/project-listings.ts +1 -21
  38. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  39. package/templates/apps/mcp/CLAUDE.md +44 -5
  40. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  41. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  42. package/templates/apps/mcp/src/http-transport.ts +107 -1
  43. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  44. package/templates/apps/web/.env.example +64 -37
  45. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  46. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  47. package/templates/apps/web/lib/relay.ts +279 -0
  48. package/templates/apps/web/middleware.ts +5 -4
  49. package/templates/apps/web/next.config.mjs +6 -0
  50. package/templates/database/CHANGELOG.md +25 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/__tests__/build.test.ts +131 -0
  53. package/templates/database/auth/schema.prisma +61 -0
  54. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  55. package/templates/database/package.json +3 -3
  56. package/versions.json +34 -34
@@ -0,0 +1,364 @@
1
+ // oss: false
2
+ /**
3
+ * The signed-in workspace surface at the transport (#3591 D15, D16 — spec
4
+ * AC-5 and AC-6).
5
+ *
6
+ * Two real servers on ephemeral ports: apps/mcp's HTTP transport, and a stub
7
+ * of apps/api that answers every twin route and REFUSES any request whose
8
+ * `Authorization` header is not the caller's key — so a tool that forwarded
9
+ * no key, or another one, fails here rather than passing on a mock.
10
+ *
11
+ * - each of the eleven tools forwards the caller's key to its twin route's
12
+ * path, with its query and body, and returns the twin's `data`;
13
+ * - a POST to `/mcp/workspace` with no key, a personal-token-shaped key or
14
+ * another scheme is 401, and the stub sees no call;
15
+ * - GET and DELETE are 405; a JSON-RPC batch is 400;
16
+ * - when the stub answers 403 the tool error carries the code and status,
17
+ * never the key;
18
+ * - `/mcp` is unchanged: a key sent there changes nothing, and it lists no
19
+ * workspace tool.
20
+ *
21
+ * No fixture holds a key-shaped literal: the key is built at run time.
22
+ */
23
+
24
+ import { createServer, type IncomingMessage, type Server } from "node:http";
25
+ import type { AddressInfo } from "node:net";
26
+
27
+ import type { FastifyInstance } from "fastify";
28
+
29
+ import { SITE_ARGS, SITE_WIRE } from "../../../tests/parity/api-parity/fixtures/site-wire";
30
+ import { buildHttpTransportApp } from "../src/http-transport.js";
31
+ import { listRegisteredTools } from "../src/server.js";
32
+ import { listWorkspaceTools } from "../src/workspace-server.js";
33
+
34
+ const KEY = ["ailk", "agt", "w".repeat(43)].join("_");
35
+ const PERSONAL_TOKEN = ["tok", "x".repeat(40)].join("_");
36
+
37
+ const CONFIG = {
38
+ path: "/mcp",
39
+ stateless: true,
40
+ rateLimits: { readPerMin: 1000, writePerMin: 1000 },
41
+ cors: { writeAllowedOrigins: [] as string[] },
42
+ abuse: { captureLeadDailyCapPerIp: 50, blockedUserAgents: "" },
43
+ } as const;
44
+
45
+ const WS = SITE_ARGS.get_inbox_message.workspaceId;
46
+ const LEAD = SITE_ARGS.get_inbox_message.leadId;
47
+ const LISTING = SITE_ARGS.get_inbox_application.id;
48
+
49
+ /** Each tool: the twin request the stub must see, and the `data` it answers. */
50
+ const TWINS: ReadonlyArray<{
51
+ tool: keyof typeof SITE_WIRE;
52
+ method: string;
53
+ path: string;
54
+ body?: unknown;
55
+ }> = [
56
+ { tool: "get_agent_context", method: "GET", path: "/v1/agent/context" },
57
+ {
58
+ tool: "list_inbox_messages",
59
+ method: "GET",
60
+ path: `/v1/workspaces/${WS}/messages?limit=25`,
61
+ },
62
+ {
63
+ tool: "get_inbox_message",
64
+ method: "GET",
65
+ path: `/v1/workspaces/${WS}/messages/${LEAD}`,
66
+ },
67
+ {
68
+ tool: "mark_inbox_message_done",
69
+ method: "PATCH",
70
+ path: `/v1/workspaces/${WS}/messages/${LEAD}`,
71
+ body: { handled: true },
72
+ },
73
+ {
74
+ tool: "accept_inbox_message",
75
+ method: "POST",
76
+ path: `/v1/workspaces/${WS}/messages/${LEAD}/accept`,
77
+ body: { accept: true },
78
+ },
79
+ {
80
+ tool: "list_inbox_submissions",
81
+ method: "GET",
82
+ path: `/v1/workspaces/${WS}/submissions?limit=25&state=unhandled`,
83
+ },
84
+ {
85
+ tool: "get_inbox_submission",
86
+ method: "GET",
87
+ path: `/v1/workspaces/${WS}/submissions/${LEAD}`,
88
+ },
89
+ {
90
+ tool: "mark_inbox_submission_done",
91
+ method: "PATCH",
92
+ path: `/v1/workspaces/${WS}/submissions/${LEAD}`,
93
+ body: { handled: true },
94
+ },
95
+ {
96
+ tool: "list_inbox_applications",
97
+ method: "GET",
98
+ path: "/v1/project-listings",
99
+ },
100
+ {
101
+ tool: "get_inbox_application",
102
+ method: "GET",
103
+ path: `/v1/project-listings/${LISTING}`,
104
+ },
105
+ {
106
+ tool: "decide_inbox_application",
107
+ method: "PATCH",
108
+ path: `/v1/project-listings/${LISTING}`,
109
+ body: { status: "active" },
110
+ },
111
+ ];
112
+
113
+ // ── The apps/api stub ────────────────────────────────────────────────────────
114
+
115
+ interface SeenCall {
116
+ method: string;
117
+ path: string;
118
+ authorization: string | undefined;
119
+ body: unknown;
120
+ }
121
+
122
+ let api: Server;
123
+ let seen: SeenCall[] = [];
124
+ /** When set, every call is answered with this refusal instead. */
125
+ let refuseWith: { status: number; code: string } | null = null;
126
+
127
+ async function readBody(req: IncomingMessage): Promise<unknown> {
128
+ let text = "";
129
+ for await (const chunk of req) text += chunk;
130
+ return text === "" ? undefined : JSON.parse(text);
131
+ }
132
+
133
+ function startApiStub(): Promise<Server> {
134
+ const server = createServer(async (req, res) => {
135
+ const body = await readBody(req);
136
+ seen.push({
137
+ method: req.method ?? "",
138
+ path: req.url ?? "",
139
+ authorization: req.headers["authorization"],
140
+ body,
141
+ });
142
+ const send = (status: number, payload: unknown) => {
143
+ res.writeHead(status, { "content-type": "application/json" });
144
+ res.end(JSON.stringify(payload));
145
+ };
146
+ // The stub is apps/api's one check: no key, no data.
147
+ if (req.headers["authorization"] !== `Bearer ${KEY}`) {
148
+ return send(401, {
149
+ ok: false,
150
+ error: { code: "unauthorized", message: "Authentication required" },
151
+ });
152
+ }
153
+ if (refuseWith) {
154
+ return send(refuseWith.status, {
155
+ ok: false,
156
+ error: { code: refuseWith.code, message: "Refused" },
157
+ });
158
+ }
159
+ const twin = TWINS.find(
160
+ (t) => t.method === req.method && t.path === req.url,
161
+ );
162
+ if (!twin) {
163
+ return send(404, {
164
+ ok: false,
165
+ error: { code: "not_found", message: "No such route" },
166
+ });
167
+ }
168
+ return send(200, { ok: true, data: SITE_WIRE[twin.tool] });
169
+ });
170
+ return new Promise((resolve) =>
171
+ server.listen(0, "127.0.0.1", () => resolve(server)),
172
+ );
173
+ }
174
+
175
+ // ── The apps/mcp transport ───────────────────────────────────────────────────
176
+
177
+ let app: FastifyInstance;
178
+ let port: number;
179
+ const priorModules = process.env.AILK_ENABLED_MODULES;
180
+ const priorApiUrl = process.env.AILK_API_URL;
181
+
182
+ beforeAll(async () => {
183
+ process.env.AILK_ENABLED_MODULES = "workspace,multi-tenant,signal-collectors";
184
+ api = await startApiStub();
185
+ process.env.AILK_API_URL = `http://127.0.0.1:${(api.address() as AddressInfo).port}`;
186
+ app = await buildHttpTransportApp(CONFIG);
187
+ await app.listen({ host: "127.0.0.1", port: 0 });
188
+ port = (app.server.address() as AddressInfo).port;
189
+ });
190
+
191
+ afterAll(async () => {
192
+ await app.close();
193
+ await new Promise((resolve) => api.close(resolve));
194
+ if (priorModules === undefined) delete process.env.AILK_ENABLED_MODULES;
195
+ else process.env.AILK_ENABLED_MODULES = priorModules;
196
+ if (priorApiUrl === undefined) delete process.env.AILK_API_URL;
197
+ else process.env.AILK_API_URL = priorApiUrl;
198
+ });
199
+
200
+ beforeEach(() => {
201
+ seen = [];
202
+ refuseWith = null;
203
+ });
204
+
205
+ type JsonRpcResponse = {
206
+ result?: {
207
+ tools?: { name: string }[];
208
+ content?: { type: string; text: string }[];
209
+ isError?: boolean;
210
+ };
211
+ error?: { code: number; message: string };
212
+ };
213
+
214
+ function rpc(method: string, params: object = {}, id = 1) {
215
+ return { jsonrpc: "2.0", id, method, params };
216
+ }
217
+
218
+ function post(
219
+ path: string,
220
+ payload: unknown,
221
+ headers: Record<string, string> = {},
222
+ ): Promise<Response> {
223
+ return fetch(`http://127.0.0.1:${port}${path}`, {
224
+ method: "POST",
225
+ body: JSON.stringify(payload),
226
+ headers: {
227
+ "content-type": "application/json",
228
+ accept: "application/json, text/event-stream",
229
+ ...headers,
230
+ },
231
+ });
232
+ }
233
+
234
+ const withKey = { authorization: `Bearer ${KEY}` };
235
+
236
+ async function callTool(name: string, args: object) {
237
+ const res = await post(
238
+ "/mcp/workspace",
239
+ rpc("tools/call", { name, arguments: args }),
240
+ withKey,
241
+ );
242
+ expect(res.status).toBe(200);
243
+ const text = await res.text();
244
+ const body = JSON.parse(text) as JsonRpcResponse;
245
+ return { text, result: body.result! };
246
+ }
247
+
248
+ // ── AC-5 — the eleven tools forward the key to their twins ───────────────────
249
+
250
+ describe("/mcp/workspace — each tool forwards the caller's key to its twin (AC-5)", () => {
251
+ it("lists exactly the eleven signed-in tools", async () => {
252
+ const res = await post("/mcp/workspace", rpc("tools/list"), withKey);
253
+ expect(res.status).toBe(200);
254
+ const body = (await res.json()) as JsonRpcResponse;
255
+ const names = body.result!.tools!.map((t) => t.name);
256
+ expect(names).toEqual(TWINS.map((t) => t.tool));
257
+ expect(names).toEqual(listWorkspaceTools());
258
+ });
259
+
260
+ it.each(TWINS)(
261
+ "$tool → $method $path, with the key, returning the twin's data",
262
+ async ({ tool, method, path, body }) => {
263
+ const { result } = await callTool(tool, SITE_ARGS[tool]);
264
+ expect(seen).toEqual([
265
+ { method, path, authorization: `Bearer ${KEY}`, body },
266
+ ]);
267
+ expect(result.isError).toBeFalsy();
268
+ expect(JSON.parse(result.content![0]!.text)).toEqual({
269
+ ok: true,
270
+ data: SITE_WIRE[tool],
271
+ });
272
+ },
273
+ );
274
+ });
275
+
276
+ describe("/mcp/workspace — refused before any tool runs (AC-5)", () => {
277
+ const call = rpc("tools/call", {
278
+ name: "list_inbox_messages",
279
+ arguments: SITE_ARGS.list_inbox_messages,
280
+ });
281
+
282
+ it.each<[string, Record<string, string>]>([
283
+ ["no Authorization header", {}],
284
+ [
285
+ "a personal-token-shaped bearer",
286
+ { authorization: `Bearer ${PERSONAL_TOKEN}` },
287
+ ],
288
+ ["an agent key under another scheme", { authorization: `Basic ${KEY}` }],
289
+ ["a truncated agent key", { authorization: `Bearer ${KEY.slice(0, -1)}` }],
290
+ ])("%s is 401, and apps/api sees no call", async (_label, headers) => {
291
+ const res = await post("/mcp/workspace", call, headers);
292
+ expect(res.status).toBe(401);
293
+ expect(await res.json()).toEqual({ error: "unauthorized" });
294
+ expect(seen).toEqual([]);
295
+ });
296
+
297
+ it.each(["GET", "DELETE"])("%s is 405 — the path keeps no session", async (method) => {
298
+ const res = await fetch(`http://127.0.0.1:${port}/mcp/workspace`, {
299
+ method,
300
+ headers: withKey,
301
+ });
302
+ expect(res.status).toBe(405);
303
+ expect(res.headers.get("allow")).toBe("POST");
304
+ });
305
+
306
+ it("a JSON-RPC batch is 400, and no call in it runs", async () => {
307
+ const res = await post(
308
+ "/mcp/workspace",
309
+ [call, { ...call, id: 2 }],
310
+ withKey,
311
+ );
312
+ expect(res.status).toBe(400);
313
+ expect(seen).toEqual([]);
314
+ });
315
+ });
316
+
317
+ describe("/mcp/workspace — a refusal from apps/api (AC-5)", () => {
318
+ it("a 403 becomes a tool error carrying the code and status, never the key", async () => {
319
+ refuseWith = { status: 403, code: "forbidden" };
320
+ const { text, result } = await callTool(
321
+ "decide_inbox_application",
322
+ SITE_ARGS.decide_inbox_application,
323
+ );
324
+ expect(seen).toHaveLength(1);
325
+ expect(result.isError).toBe(true);
326
+ expect(JSON.parse(result.content![0]!.text)).toEqual({
327
+ ok: false,
328
+ error: { code: "forbidden", status: 403 },
329
+ });
330
+ expect(text).not.toContain(KEY);
331
+ });
332
+ });
333
+
334
+ // ── AC-6 — the anonymous /mcp is unchanged ───────────────────────────────────
335
+
336
+ describe("/mcp — the anonymous surface never reads Authorization (AC-6)", () => {
337
+ async function listAt(headers: Record<string, string>) {
338
+ const res = await post("/mcp", rpc("tools/list"), headers);
339
+ expect(res.status).toBe(200);
340
+ const body = (await res.json()) as JsonRpcResponse;
341
+ return body.result!.tools!.map((t) => t.name);
342
+ }
343
+
344
+ it("lists the same anonymous tools with or without an agent key, and no workspace tool", async () => {
345
+ const bare = await listAt({});
346
+ expect(await listAt(withKey)).toEqual(bare);
347
+ expect(bare.sort()).toEqual(listRegisteredTools().map((t) => t.name).sort());
348
+ for (const name of TWINS.map((t) => t.tool)) {
349
+ expect(bare).not.toContain(name);
350
+ }
351
+ });
352
+
353
+ it("does not serve a workspace tool, even to a caller holding a key", async () => {
354
+ const res = await post(
355
+ "/mcp",
356
+ rpc("tools/call", { name: "get_agent_context", arguments: {} }),
357
+ withKey,
358
+ );
359
+ const text = await res.text();
360
+ expect(text).toMatch(/get_agent_context/);
361
+ expect(text).toMatch(/not found/i);
362
+ expect(seen).toEqual([]);
363
+ });
364
+ });
@@ -16,16 +16,28 @@
16
16
  * The server is stateless: each initialize request creates a fresh
17
17
  * StreamableHTTPServerTransport + McpServer pair. Session IDs are not
18
18
  * persisted across restarts (acceptable for OSS; paid layer adds durability).
19
+ *
20
+ * The anonymous `config.path` never reads `Authorization`. Beside it,
21
+ * `${config.path}/workspace` serves the signed-in workspace tools (#3591 D15):
22
+ * POST only and always stateless, each request carrying an agent key that is
23
+ * handed to that request's tools and nothing else — see
24
+ * {@link registerWorkspaceRoute}.
19
25
  */
20
26
 
21
27
  import { randomUUID } from "node:crypto";
28
+ import type { IncomingMessage } from "node:http";
22
29
 
23
30
  import cors from "@fastify/cors";
24
31
  import rateLimit from "@fastify/rate-limit";
32
+ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
25
33
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
26
34
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
27
35
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
28
- import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
36
+ import Fastify, {
37
+ type FastifyInstance,
38
+ type FastifyReply,
39
+ type FastifyRequest,
40
+ } from "fastify";
29
41
 
30
42
  import {
31
43
  AbuseGuard,
@@ -33,6 +45,7 @@ import {
33
45
  type AbuseConfig,
34
46
  } from "./abuse/index.js";
35
47
  import { createMcpServer } from "./server.js";
48
+ import { createWorkspaceMcpServer } from "./workspace-server.js";
36
49
 
37
50
  /**
38
51
  * HTTP status for each {@link AbuseReasonCode} an AbuseGuard refusal can
@@ -170,6 +183,96 @@ function isOriginAllowedForWrite(
170
183
  return allowedOrigins.includes(origin);
171
184
  }
172
185
 
186
+ /**
187
+ * An agent key's shape (#3591 D2): `ailk_agt_` plus 32 random bytes in
188
+ * base64url. apps/mcp checks only this; apps/api is where the key is checked.
189
+ */
190
+ const AGENT_BEARER = /^Bearer (ailk_agt_[A-Za-z0-9_-]{43})$/;
191
+
192
+ /** The agent key an `Authorization` header carries, or null. */
193
+ function agentKeyOf(header: string | string[] | undefined): string | null {
194
+ if (typeof header !== "string") return null;
195
+ return AGENT_BEARER.exec(header)?.[1] ?? null;
196
+ }
197
+
198
+ /**
199
+ * The signed-in workspace surface (#3591 D15) at `path`: POST only, always
200
+ * stateless — a fresh transport and `createWorkspaceMcpServer()` per request,
201
+ * closed when the response ends, and no session map, so GET and DELETE are
202
+ * 405. In order, a POST must:
203
+ *
204
+ * 1. carry `Authorization: Bearer ailk_agt_<43 base64url>` — else 401 in
205
+ * `onRequest`, before the body is read and before any tool runs. A
206
+ * personal token (`tok_…`) is refused here like any other shape;
207
+ * 2. be one JSON-RPC message — a batch (array) body is 400;
208
+ * 3. pass the abuse guard's User-Agent check.
209
+ *
210
+ * Rate limiting is apps/api's per-agent limiter, which every forwarded call
211
+ * meets after the key is verified (D4); the app-wide per-IP limit still
212
+ * applies here. The key is attached as the SDK's `req.auth` for this request
213
+ * only, which the SDK hands each tool as `extra.authInfo`.
214
+ */
215
+ function registerWorkspaceRoute(
216
+ app: FastifyInstance,
217
+ path: string,
218
+ abuseGuard: AbuseGuard,
219
+ ): void {
220
+ const notAllowed = async (_request: FastifyRequest, reply: FastifyReply) =>
221
+ reply
222
+ .code(405)
223
+ .header("allow", "POST")
224
+ .send({ error: "method_not_allowed" });
225
+ app.get(path, notAllowed);
226
+ app.delete(path, notAllowed);
227
+
228
+ app.post(path, {
229
+ onRequest: async (request, reply) => {
230
+ if (agentKeyOf(request.headers["authorization"]) === null) {
231
+ return reply.code(401).send({ error: "unauthorized" });
232
+ }
233
+ },
234
+ preHandler: async (request, reply) => {
235
+ if (Array.isArray(request.body)) {
236
+ return reply.code(400).send({ error: "batch_not_supported" });
237
+ }
238
+ const ua = abuseGuard.validate("__read__", {}, request);
239
+ if (!ua.allowed) {
240
+ return reply.code(403).send({ error: ua.reason });
241
+ }
242
+ },
243
+ handler: async (request, reply) => {
244
+ const raw = request.raw as IncomingMessage & { auth?: AuthInfo };
245
+ raw.auth = {
246
+ token: agentKeyOf(request.headers["authorization"])!,
247
+ clientId: "ailk-agent",
248
+ scopes: [],
249
+ };
250
+ const transport = new StreamableHTTPServerTransport({
251
+ enableJsonResponse: true,
252
+ });
253
+ const server = createWorkspaceMcpServer();
254
+ try {
255
+ // The cast is the same exactOptionalPropertyTypes one the anonymous
256
+ // route carries.
257
+ await server.connect(transport as unknown as Transport);
258
+ await transport.handleRequest(raw, reply.raw, request.body);
259
+ } catch (err) {
260
+ if (!reply.sent) {
261
+ reply.code(500).send({
262
+ jsonrpc: "2.0",
263
+ error: { code: -32603, message: "Internal server error" },
264
+ id: null,
265
+ });
266
+ }
267
+ throw err;
268
+ } finally {
269
+ await transport.close().catch(() => undefined);
270
+ await server.close().catch(() => undefined);
271
+ }
272
+ },
273
+ });
274
+ }
275
+
173
276
  /**
174
277
  * Build and return a configured Fastify instance that exposes the MCP HTTP
175
278
  * transport at `config.path`. The instance is not yet listening — callers
@@ -360,6 +463,9 @@ export async function buildHttpTransportApp(
360
463
  await transport.handleRequest(request.raw, reply.raw);
361
464
  });
362
465
 
466
+ // ── Signed-in workspace route (#3591 D15) ─────────────────────────────────
467
+ registerWorkspaceRoute(app, `${mcpPath}/workspace`, abuseGuard);
468
+
363
469
  return app;
364
470
  }
365
471
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The signed-in workspace MCP server (#3591 D15, D16).
3
+ *
4
+ * Serves the catalog's `surface: "workspace"` tools — the agent's own context
5
+ * and the inbox actions — at `/mcp/workspace` (`http-transport.ts`), one
6
+ * fresh server per request. Each tool is the twin of one apps/api route: its
7
+ * handler forwards the caller's agent key to that route through
8
+ * `@working-theory/api-client` and returns the route's `data`.
9
+ *
10
+ * The key reaches a handler only as `extra.authInfo.token`, which the
11
+ * transport sets from the request's `Authorization` header for that request
12
+ * alone. Nothing here logs or serialises `extra`, `extra.authInfo` or
13
+ * `extra.requestInfo` (the SDK puts the raw `authorization` header there,
14
+ * SEC-3591-12), and no result carries the key: a refusal from the route is
15
+ * an MCP tool error naming the route's `error.code` and HTTP status only.
16
+ * apps/mcp checks nothing about the key beyond its shape; apps/api is the one
17
+ * place it is checked.
18
+ *
19
+ * A tool registers exactly when its route does: when any module in its
20
+ * catalog `modulesAnyOf` is enabled, the same disjunction the route's
21
+ * `server.ts` gate in apps/api is. The anonymous server (`server.ts`) never
22
+ * sees these entries — the catalog returns them only for
23
+ * `surface: "workspace"`.
24
+ */
25
+
26
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
+ import {
28
+ getMcpToolRegistrations,
29
+ type McpToolRegistration,
30
+ } from "@working-theory/metadata";
31
+
32
+ import { isModuleEnabled } from "./config/modules.js";
33
+
34
+ /** What a twin call answers — `@working-theory/api-client`'s `ApiResult`. */
35
+ type ForwardResult =
36
+ | { ok: true; body: unknown }
37
+ | { ok: false; status: number; body: unknown };
38
+
39
+ type WorkspaceToolHandler = (
40
+ key: string,
41
+ input: unknown,
42
+ ) => Promise<ForwardResult>;
43
+
44
+ /**
45
+ * Tool name → its handler's file and export. Every file is a paid slice the
46
+ * OSS strip deletes, so — exactly as `server.ts`'s `MODULE_TOOL_ENTRIES` — the
47
+ * specifier is read from this table, never written at an `import()` call
48
+ * site, which `tsc` would resolve on the stripped tree.
49
+ */
50
+ const WORKSPACE_TOOL_ENTRIES: Record<
51
+ string,
52
+ { readonly specifier: string; readonly exportName: string }
53
+ > = {
54
+ get_agent_context: {
55
+ specifier: "./tools/agent_context.js",
56
+ exportName: "ailkGetAgentContext",
57
+ },
58
+ list_inbox_messages: {
59
+ specifier: "./tools/inbox_messages.js",
60
+ exportName: "ailkListInboxMessages",
61
+ },
62
+ get_inbox_message: {
63
+ specifier: "./tools/inbox_messages.js",
64
+ exportName: "ailkGetInboxMessage",
65
+ },
66
+ mark_inbox_message_done: {
67
+ specifier: "./tools/inbox_messages.js",
68
+ exportName: "ailkMarkInboxMessageDone",
69
+ },
70
+ accept_inbox_message: {
71
+ specifier: "./tools/inbox_messages.js",
72
+ exportName: "ailkAcceptInboxMessage",
73
+ },
74
+ list_inbox_submissions: {
75
+ specifier: "./tools/inbox_submissions.js",
76
+ exportName: "ailkListInboxSubmissions",
77
+ },
78
+ get_inbox_submission: {
79
+ specifier: "./tools/inbox_submissions.js",
80
+ exportName: "ailkGetInboxSubmission",
81
+ },
82
+ mark_inbox_submission_done: {
83
+ specifier: "./tools/inbox_submissions.js",
84
+ exportName: "ailkMarkInboxSubmissionDone",
85
+ },
86
+ list_inbox_applications: {
87
+ specifier: "./tools/inbox_applications.js",
88
+ exportName: "ailkListInboxApplications",
89
+ },
90
+ get_inbox_application: {
91
+ specifier: "./tools/inbox_applications.js",
92
+ exportName: "ailkGetInboxApplication",
93
+ },
94
+ decide_inbox_application: {
95
+ specifier: "./tools/inbox_applications.js",
96
+ exportName: "ailkDecideInboxApplication",
97
+ },
98
+ };
99
+
100
+ async function resolveWorkspaceHandler(
101
+ name: string,
102
+ ): Promise<WorkspaceToolHandler> {
103
+ const entry = WORKSPACE_TOOL_ENTRIES[name];
104
+ if (!entry) {
105
+ throw new Error(`MCP catalog drift: no handler for tool '${name}'`);
106
+ }
107
+ const mod = (await import(entry.specifier)) as Record<
108
+ string,
109
+ WorkspaceToolHandler | undefined
110
+ >;
111
+ const fn = mod[entry.exportName];
112
+ if (!fn) {
113
+ throw new Error(
114
+ `MCP slice drift: '${entry.specifier}' does not export '${entry.exportName}'`,
115
+ );
116
+ }
117
+ return fn;
118
+ }
119
+
120
+ /** Whether this build registers the tool: any of its modules is enabled. */
121
+ function isWorkspaceToolEnabled(reg: McpToolRegistration): boolean {
122
+ return (reg.modulesAnyOf ?? []).some((name) => isModuleEnabled(name));
123
+ }
124
+
125
+ /** The names {@link createWorkspaceMcpServer} registers on this build. */
126
+ export function listWorkspaceTools(): string[] {
127
+ return getMcpToolRegistrations({ surface: "workspace" })
128
+ .filter(isWorkspaceToolEnabled)
129
+ .map((reg) => reg.name);
130
+ }
131
+
132
+ /** The route's `error.code`, or a fixed code when the body carries none. */
133
+ function errorCodeOf(body: unknown): string {
134
+ if (typeof body === "object" && body !== null) {
135
+ const error = (body as { error?: unknown }).error;
136
+ if (typeof error === "object" && error !== null) {
137
+ const code = (error as { code?: unknown }).code;
138
+ if (typeof code === "string") return code;
139
+ }
140
+ }
141
+ return "api_error";
142
+ }
143
+
144
+ function toolError(code: string, status: number) {
145
+ return {
146
+ isError: true,
147
+ content: [
148
+ {
149
+ type: "text" as const,
150
+ text: JSON.stringify({ ok: false, error: { code, status } }),
151
+ },
152
+ ],
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Creates the signed-in workspace server: every `surface: "workspace"` catalog
158
+ * entry whose module is enabled, each forwarding the caller's key to its twin
159
+ * route. Unconnected — the transport attaches one per request.
160
+ */
161
+ export function createWorkspaceMcpServer(): McpServer {
162
+ const server = new McpServer({ name: "ailk-workspace", version: "0.1.0" });
163
+
164
+ for (const reg of getMcpToolRegistrations({ surface: "workspace" })) {
165
+ if (!isWorkspaceToolEnabled(reg)) continue;
166
+ // Boot-time drift detection, as `server.ts` does it.
167
+ if (!WORKSPACE_TOOL_ENTRIES[reg.name]) {
168
+ throw new Error(`MCP catalog drift: no handler for tool '${reg.name}'`);
169
+ }
170
+ server.tool(
171
+ reg.name,
172
+ reg.description,
173
+ reg.inputShape,
174
+ async (input, extra) => {
175
+ const key = extra.authInfo?.token;
176
+ if (!key) return toolError("unauthorized", 401);
177
+ const handler = await resolveWorkspaceHandler(reg.name);
178
+ const res = await handler(key, input);
179
+ if (!res.ok) return toolError(errorCodeOf(res.body), res.status);
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text" as const,
184
+ text: JSON.stringify({ ok: true, data: res.body }),
185
+ },
186
+ ],
187
+ };
188
+ },
189
+ );
190
+ }
191
+
192
+ return server;
193
+ }