@loopbacktools/server 0.0.0-stage → 0.4.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -2
  3. package/dist/package.json +57 -0
  4. package/dist/src/domain/ask.d.ts +78 -0
  5. package/dist/src/domain/ask.js +161 -0
  6. package/dist/src/domain/errors.d.ts +70 -0
  7. package/dist/src/domain/errors.js +57 -0
  8. package/dist/src/generated/app.gen.d.ts +2311 -0
  9. package/dist/src/generated/app.gen.js +400 -0
  10. package/dist/src/generated/runtime.gen.d.ts +357 -0
  11. package/dist/src/generated/runtime.gen.js +298 -0
  12. package/dist/src/generated/schemas.gen.d.ts +6416 -0
  13. package/dist/src/generated/schemas.gen.js +306 -0
  14. package/dist/src/generated/wire-contract.gen.d.ts +77 -0
  15. package/dist/src/generated/wire-contract.gen.js +3 -0
  16. package/dist/src/index.d.ts +11 -0
  17. package/dist/src/index.js +8 -0
  18. package/dist/src/loopback.d.ts +27 -0
  19. package/dist/src/loopback.js +16 -0
  20. package/dist/src/space/arguments.d.ts +8 -0
  21. package/dist/src/space/arguments.js +52 -0
  22. package/dist/src/space/migrations.d.ts +10 -0
  23. package/dist/src/space/migrations.js +106 -0
  24. package/dist/src/space/object.d.ts +30 -0
  25. package/dist/src/space/object.js +169 -0
  26. package/dist/src/space/operations.d.ts +149 -0
  27. package/dist/src/space/operations.js +568 -0
  28. package/dist/src/space/read.d.ts +8 -0
  29. package/dist/src/space/read.js +33 -0
  30. package/dist/src/space/rpc.d.ts +61 -0
  31. package/dist/src/space/rpc.js +96 -0
  32. package/dist/src/space/services.d.ts +41 -0
  33. package/dist/src/space/services.js +58 -0
  34. package/dist/src/space/store.d.ts +87 -0
  35. package/dist/src/space/store.js +276 -0
  36. package/dist/src/version.d.ts +2 -0
  37. package/dist/src/version.js +3 -0
  38. package/dist/src/worker/env.d.ts +19 -0
  39. package/dist/src/worker/env.js +3 -0
  40. package/dist/src/worker/files.d.ts +21 -0
  41. package/dist/src/worker/files.js +42 -0
  42. package/dist/src/worker/gate.d.ts +55 -0
  43. package/dist/src/worker/gate.js +83 -0
  44. package/dist/src/worker/http.d.ts +2092 -0
  45. package/dist/src/worker/http.js +291 -0
  46. package/dist/src/worker/jwt.d.ts +49 -0
  47. package/dist/src/worker/jwt.js +142 -0
  48. package/dist/src/worker/mcp.d.ts +15 -0
  49. package/dist/src/worker/mcp.js +401 -0
  50. package/dist/src/worker/run.d.ts +45 -0
  51. package/dist/src/worker/run.js +71 -0
  52. package/dist/src/worker/spaces.d.ts +24 -0
  53. package/dist/src/worker/spaces.js +64 -0
  54. package/package.json +51 -3
@@ -0,0 +1,401 @@
1
+ import { findPoint, foldBriefing, } from "@loopbacktools/protocol";
2
+ import { createMcpHandler, McpServer, MissingRequiredClientCapabilityError, ProtocolError, ProtocolErrorCode, } from "@modelcontextprotocol/server";
3
+ import { Effect } from "effect";
4
+ import { z } from "zod";
5
+ import { briefingAnswerInputSchema, briefingInputSchema, followUpInputSchema, inboxLinkSchema, inboxRequestSchema, questionInputSchema, reviewInputSchema, uploadRequestSchema, uploadTicketSchema, } from "../generated/schemas.gen.js";
6
+ import { incident, run, Unhandled } from "./run.js";
7
+ import { VERSION } from "../version.js";
8
+ import { Spaces } from "./spaces.js";
9
+ /** How long each wait for a reply is held before the tool reports progress and waits again. */
10
+ const LAP_MS = 25_000;
11
+ /** The MCP Tasks extension: a tool call that returns at once and completes later. */
12
+ const TASKS_EXTENSION = "io.modelcontextprotocol/tasks";
13
+ /**
14
+ * How often a client should poll a task: the most a reply waits before the agent sees it. Briskly
15
+ * while a quick answer is likely, then less often, since each poll is a request to the space.
16
+ */
17
+ const pollEvery = (startedAt, now) => {
18
+ const age = now - Date.parse(startedAt);
19
+ return age < 2 * 60_000 ? 2_000 : age < 30 * 60_000 ? 10_000 : 30_000;
20
+ };
21
+ const INSTRUCTIONS = `Loopback puts what you need from the person you are working for in front of them, in a live inbox, and hands their reply back to you as the tool result.
22
+
23
+ Use it whenever you need a decision, a preference, information only they have, or their sign-off. Use it instead of guessing, instead of asking in your reply, and instead of your client's built-in question prompt: the person works across many projects at once and answers everything from this one inbox.
24
+ - ask_question for anything you would otherwise ask in chat. Offer choices when there are clear ones.
25
+ - request_review when they say "show me", "let me see" or "run it by me", or before you finalise work they should approve: copy, a plan, code, a diff, a screenshot. Put the work itself in the content.
26
+ - send_briefing when they ask you to explain or walk them through something that breaks into several points: findings, options, steps, a list of changes. They reply on each point on its own, instead of on a list in your reply.
27
+ - create_image_upload first when the review includes an image.
28
+ - open_inbox when the person asks where their inbox or dashboard is, or to send them to an ask.
29
+
30
+ People can take minutes to reply. Where your client supports background tasks, each of these returns at once: carry on with other work, raise more asks if you need to, and the person's reply reaches you when they give it. Otherwise the call waits for the reply.
31
+
32
+ A reply whose outcome is follow_up is a question back to you: answer it with answer_follow_up, which brings their decision the same way. After changes_requested, revise and call request_review again with revises set to the earlier review's id. Replies on a briefing come back keyed to its points: answer every point they wrote on with answer_briefing, which brings their next replies the same way, until every point is resolved.
33
+
34
+ Every ask names its project: the name of the folder at the root of the repository you are working in (the last part of \`git rev-parse --show-toplevel\`), or of the working directory outside one. Use the same name every time.`;
35
+ /** The inbox this Worker serves itself, as `@loopbacktools/app` routes it: the space's, or one ask in it. */
36
+ export const defaultInboxUrl = ({ origin, space, askId }) => `${origin}/w/${encodeURIComponent(space)}${askId === undefined ? "" : `/asks/${encodeURIComponent(askId)}`}`;
37
+ const clientInfoSchema = z.object({ name: z.string(), version: z.string().optional() });
38
+ /** The MCP client that sent this request, when the request says. */
39
+ const clientOf = (ctx) => {
40
+ const envelope = ctx.mcpReq.envelope ?? {};
41
+ const meta = ctx.mcpReq._meta ?? {};
42
+ const parsed = clientInfoSchema.safeParse(envelope["io.modelcontextprotocol/clientInfo"] ?? meta["io.modelcontextprotocol/clientInfo"]);
43
+ if (!parsed.success)
44
+ return undefined;
45
+ return parsed.data.version === undefined
46
+ ? parsed.data.name
47
+ : `${parsed.data.name} ${parsed.data.version}`;
48
+ };
49
+ const capabilitiesSchema = z.object({
50
+ extensions: z.record(z.string(), z.unknown()).optional(),
51
+ });
52
+ /** Whether the client declared the Tasks extension on this request; only then may a tool answer with a task. */
53
+ const supportsTasks = (ctx) => {
54
+ const envelope = ctx.mcpReq.envelope ?? {};
55
+ const parsed = capabilitiesSchema.safeParse(envelope["io.modelcontextprotocol/clientCapabilities"]);
56
+ return parsed.success && parsed.data.extensions?.[TASKS_EXTENSION] !== undefined;
57
+ };
58
+ const requireTasks = (ctx) => {
59
+ if (!supportsTasks(ctx)) {
60
+ throw new MissingRequiredClientCapabilityError({
61
+ requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } },
62
+ });
63
+ }
64
+ };
65
+ /**
66
+ * A task follows the reply to one of the agent's turns on an ask: `<askId>:<turn>`, where the turn
67
+ * is the thread entry the agent's move ended with, 0 for opening the ask. Ask ids are random, and
68
+ * every task request passes the same gate as the ask itself.
69
+ */
70
+ const taskIdOf = (askId, turn) => `${askId}:${turn}`;
71
+ const unknownTask = (taskId) => new ProtocolError(ProtocolErrorCode.InvalidParams, `No task "${taskId}".`);
72
+ const taskOf = (taskId) => {
73
+ const split = taskId.lastIndexOf(":");
74
+ const turn = Number(taskId.slice(split + 1));
75
+ if (split <= 0 || !Number.isSafeInteger(turn) || turn < 0)
76
+ throw unknownTask(taskId);
77
+ return { askId: taskId.slice(0, split), turn };
78
+ };
79
+ const text = (message) => ({ content: [{ type: "text", text: message }] });
80
+ const refused = (message) => ({ ...text(message), isError: true });
81
+ /** "2", "2 and 3", "1, 2 and 3". */
82
+ const listed = (items) => items.length <= 1 ? (items[0] ?? "") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
83
+ /**
84
+ * Replies on a briefing, each point named with its title, and what the agent owes now. The briefing
85
+ * is read as it stood when this reply landed, so a task polled again later says the same.
86
+ */
87
+ const describeResponded = (askId, reply, landed) => {
88
+ const tree = landed === undefined
89
+ ? undefined
90
+ : foldBriefing(landed.briefing.prompt, landed.briefing.thread.slice(0, landed.seq));
91
+ const named = (id) => {
92
+ const title = tree === undefined ? undefined : findPoint(tree, id)?.title;
93
+ return title === undefined ? id : `${id} (${title})`;
94
+ };
95
+ const lines = reply.points.map((item) => {
96
+ const wrote = item.text?.trim() ?? "";
97
+ // As the fold reads it: words on or beneath a point resolved in the same reply are closing
98
+ // notes, and its record of this turn on the point says so.
99
+ const turn = tree === undefined ? undefined : findPoint(tree, item.point)?.turns.at(-1);
100
+ const settled = turn?.author === "human" ? turn.resolved : item.resolved === true;
101
+ const said = settled
102
+ ? wrote === ""
103
+ ? "resolved."
104
+ : `resolved, saying "${wrote}"`
105
+ : `they wrote "${wrote}"`;
106
+ return `- Point ${named(item.point)}: ${said}`;
107
+ });
108
+ const next = tree === undefined || tree.resolved
109
+ ? "The briefing is closed: every point is resolved. Act on what they said, or ask a new question if something is still unclear."
110
+ : `Answer ${tree.owed.length === 1 ? "point" : "points"} ${listed(tree.owed.map(named))} with answer_briefing (askId: "${askId}"): each is owed an answer. You may answer other open points too, and break any point into sub-points.`;
111
+ return ["The person replied on the briefing:", ...lines, "", next].join("\n");
112
+ };
113
+ /** What a reply means for the agent, said plainly. A briefing's replies name its points' titles. */
114
+ const describe = ({ askId, reply }, landed) => {
115
+ switch (reply.outcome) {
116
+ case "answered": {
117
+ const chose = reply.selected.length === 0
118
+ ? ""
119
+ : `They chose: ${reply.selected.map((label) => `"${label}"`).join(", ")}.`;
120
+ const wrote = reply.text === undefined || reply.text.trim() === "" ? "" : `They wrote: ${reply.text}`;
121
+ return ["The person answered.", chose, wrote].filter((part) => part !== "").join(" ");
122
+ }
123
+ case "approved":
124
+ return reply.note === undefined ? "Approved." : `Approved, with a note: ${reply.note}`;
125
+ case "changes_requested":
126
+ return `Changes requested: ${reply.feedback}\n\nRevise the work, then call request_review again with revises: "${askId}".`;
127
+ case "follow_up":
128
+ return `The person asked a question before deciding: ${reply.question}\n\nAnswer it with answer_follow_up (askId: "${askId}"). This ask stays open, and that call returns their decision.`;
129
+ case "dismissed":
130
+ return "The person dismissed this without answering. Carry on with your best judgement, or ask differently.";
131
+ case "responded":
132
+ return describeResponded(askId, reply, landed);
133
+ }
134
+ };
135
+ /** A reply as the agent receives it: in words, and as the contract's `ToolReply`. */
136
+ const answered = (delivery, briefing) => {
137
+ const reply = { askId: delivery.askId, reply: delivery.reply };
138
+ return {
139
+ ...text(describe(reply, briefing === undefined ? undefined : { briefing, seq: delivery.seq })),
140
+ structuredContent: reply,
141
+ };
142
+ };
143
+ /** The briefing a reply was given on, read only when the reply is one, to name its points. */
144
+ const briefingFor = (space, reply) => reply?.reply.outcome === "responded"
145
+ ? Effect.map(space.get(reply.askId), (ask) => (ask.kind === "briefing" ? ask : undefined))
146
+ : Effect.succeed(undefined);
147
+ /**
148
+ * The turn an opened ask is followed from: its opening for a new ask, and the agent's latest move
149
+ * for one a retried call rejoined, so its task completes with the reply still to come, not the first.
150
+ */
151
+ const latestTurn = (ask) => {
152
+ const last = ask.thread.findLastIndex((entry) => entry.author === "agent");
153
+ const entry = ask.thread[last];
154
+ return entry === undefined ? [0, ask.createdAt] : [last + 1, entry.at];
155
+ };
156
+ /** A task in the shape `tasks/get` and a task-creating `tools/call` share. */
157
+ const taskView = (taskId, state, briefing) => ({
158
+ taskId,
159
+ status: state.status,
160
+ ...(state.status === "working"
161
+ ? { statusMessage: "Waiting for the person to reply in Loopback." }
162
+ : {}),
163
+ createdAt: state.createdAt,
164
+ lastUpdatedAt: state.lastUpdatedAt,
165
+ ttlMs: null,
166
+ pollIntervalMs: pollEvery(state.createdAt, Date.now()),
167
+ ...(state.reply === null ? {} : { result: answered(state.reply, briefing) }),
168
+ });
169
+ /**
170
+ * The answer to a call that became a background task. The SDK types a tool's result as a
171
+ * `CallToolResult`, but passes a task through to the client unchanged: this is the one place the
172
+ * two shapes meet.
173
+ */
174
+ const started = (askId, turn, at) => ({
175
+ resultType: "task",
176
+ ...taskView(taskIdOf(askId, turn), {
177
+ status: "working",
178
+ createdAt: at,
179
+ lastUpdatedAt: at,
180
+ reply: null,
181
+ }),
182
+ });
183
+ /** Tells the client the call is alive, which keeps its idle timer from giving up on a long wait. */
184
+ const reportProgress = (ctx, step) => Effect.promise(async () => {
185
+ const progressToken = ctx.mcpReq._meta?.progressToken;
186
+ if (progressToken === undefined)
187
+ return;
188
+ await ctx.mcpReq.notify({
189
+ method: "notifications/progress",
190
+ params: { progressToken, progress: step, message: "Waiting for the person to reply" },
191
+ });
192
+ });
193
+ /**
194
+ * Waits, in laps, until the person replies to the ask, reporting progress between laps, and takes
195
+ * the reply. A call whose client has gone is interrupted before it takes it, which leaves the reply
196
+ * for the client's retry. The ask was just opened or answered, so one that has vanished is a broken
197
+ * invariant, not an error to report.
198
+ */
199
+ const replyTo = (ctx, space, askId) => Effect.gen(function* () {
200
+ for (let step = 0;; step += 1) {
201
+ yield* reportProgress(ctx, step);
202
+ const delivery = yield* space.awaitReply(askId, LAP_MS);
203
+ if (delivery !== null) {
204
+ yield* space.markDelivered(askId, delivery.seq);
205
+ return answered(delivery, yield* briefingFor(space, delivery));
206
+ }
207
+ }
208
+ }).pipe(Effect.catchTag("AskNotFound", Effect.die));
209
+ const server = ({ runtime, access, origin, requestId, inboxUrl }) => {
210
+ const mcp = new McpServer({ name: "loopback", version: VERSION }, { instructions: INSTRUCTIONS });
211
+ /**
212
+ * A call that died is logged, and the agent hears only the incident to quote. One its client
213
+ * abandoned has no one to tell.
214
+ */
215
+ const died = (error) => {
216
+ if (error instanceof Unhandled && error.interrupted)
217
+ throw error;
218
+ return incident(error, requestId);
219
+ };
220
+ const serve = (ctx, program) => run(runtime, ctx.mcpReq.signal, requestId, Spaces.use((spaces) => program(spaces.of(access)))).catch((error) => refused(died(error)));
221
+ /**
222
+ * A client that can follow a background task gets one at once; any other waits for the reply,
223
+ * since that is the only way it can receive one.
224
+ */
225
+ const settle = (ctx, space, askId, turn, at) => supportsTasks(ctx) ? Effect.succeed(started(askId, turn, at)) : replyTo(ctx, space, askId);
226
+ /** Runs a `tasks/*` request against the task's ask; an ask or turn the space does not know is no task. */
227
+ const task = async (ctx, taskId, program) => {
228
+ requireTasks(ctx);
229
+ const { askId, turn } = taskOf(taskId);
230
+ const found = await run(runtime, ctx.mcpReq.signal, requestId, Spaces.use((spaces) => {
231
+ const space = spaces.of(access);
232
+ return Effect.flatMap(program(space, askId, turn), (state) => Effect.map(briefingFor(space, state.reply), (briefing) => ({ state, briefing })));
233
+ }).pipe(Effect.catchTag("AskNotFound", () => Effect.succeed(null)))).catch((error) => {
234
+ throw new ProtocolError(ProtocolErrorCode.InternalError, died(error));
235
+ });
236
+ if (found === null)
237
+ throw unknownTask(taskId);
238
+ return taskView(taskId, found.state, found.briefing);
239
+ };
240
+ mcp.server.registerCapabilities({ extensions: { [TASKS_EXTENSION]: {} } });
241
+ const taskParams = z.object({ taskId: z.string() });
242
+ mcp.server.setRequestHandler("tasks/get", { params: taskParams }, ({ taskId }, ctx) => task(ctx, taskId, (space, askId, turn) => space.followTask(askId, turn)));
243
+ mcp.server.setRequestHandler("tasks/cancel", { params: taskParams }, async ({ taskId }, ctx) => {
244
+ await task(ctx, taskId, (space, askId, turn) => space.cancelTask(askId, turn));
245
+ return {};
246
+ });
247
+ // Loopback's tasks never ask the client for input, so there is nothing to update: input
248
+ // responses a server does not recognise are ignored.
249
+ mcp.server.setRequestHandler("tasks/update", { params: taskParams }, async ({ taskId }, ctx) => {
250
+ await task(ctx, taskId, (space, askId, turn) => space.followTask(askId, turn));
251
+ return {};
252
+ });
253
+ const waits = {
254
+ readOnlyHint: false,
255
+ destructiveHint: false,
256
+ idempotentHint: true,
257
+ openWorldHint: false,
258
+ };
259
+ mcp.registerTool("ask_question", {
260
+ title: "Ask the person a question",
261
+ description: "Ask the person you are working for a question. Use it whenever you need a decision, a preference or information only they have, instead of guessing, asking in your reply, or using a built-in question prompt: they answer everything from their Loopback inbox. Offer two to six choices when there are clear ones; they can always answer in their own words, ask you something back, or dismiss it. Their reply can take minutes: where your client supports background tasks this returns at once and the reply reaches you when it comes; otherwise it waits.",
262
+ inputSchema: questionInputSchema,
263
+ annotations: waits,
264
+ }, (input, ctx) => serve(ctx, (space) => Effect.gen(function* () {
265
+ const client = clientOf(ctx);
266
+ const ask = yield* space.open({
267
+ kind: "question",
268
+ input,
269
+ actor: access.actor,
270
+ ...(client === undefined ? {} : { client }),
271
+ });
272
+ return yield* settle(ctx, space, ask.id, ...latestTurn(ask));
273
+ }).pipe(Effect.catchTag(["ContentInvalid", "Declined"], (error) => Effect.succeed(refused(error.reason))))));
274
+ mcp.registerTool("request_review", {
275
+ title: "Show the person work for review",
276
+ description: 'Show the person something you made and wait for their verdict: copy, a plan, code, a diff or a screenshot. Use it when they say "show me", "let me see" or "run it by me", and before you finalise anything they should sign off. Put the work itself in content, not a description of it. They can approve (perhaps with a note), request changes, ask you a question first, or dismiss it. For an image, call create_image_upload first. Returns at once as a background task where your client supports it; otherwise waits for their verdict.',
277
+ inputSchema: reviewInputSchema,
278
+ annotations: waits,
279
+ }, (input, ctx) => serve(ctx, (space) => Effect.gen(function* () {
280
+ const client = clientOf(ctx);
281
+ const ask = yield* space.open({
282
+ kind: "review",
283
+ input,
284
+ actor: access.actor,
285
+ ...(client === undefined ? {} : { client }),
286
+ });
287
+ return yield* settle(ctx, space, ask.id, ...latestTurn(ask));
288
+ }).pipe(Effect.catchTag(["ContentInvalid", "Declined"], (error) => Effect.succeed(refused(error.reason))))));
289
+ mcp.registerTool("answer_follow_up", {
290
+ title: "Answer the person's question",
291
+ description: "Answer a question the person asked back about one of your asks (a reply whose outcome is follow_up), then get their decision the same way the ask itself brings it. Include content when the answer involves something new to look at.",
292
+ inputSchema: followUpInputSchema,
293
+ annotations: waits,
294
+ }, (input, ctx) => serve(ctx, (space) => Effect.gen(function* () {
295
+ const ask = yield* space.answerFollowUp(input);
296
+ const answer = ask.thread.at(-1);
297
+ return yield* settle(ctx, space, ask.id, ask.thread.length, answer?.at ?? ask.updatedAt);
298
+ }).pipe(Effect.catchTags({
299
+ AskNotFound: () => Effect.succeed(refused(`No ask "${input.askId}".`)),
300
+ AskClosed: () => Effect.succeed(refused("That ask is closed; the person has already decided.")),
301
+ NotYourTurn: () => Effect.succeed(refused("The person has not asked anything back on that ask, so there is nothing to answer.")),
302
+ ContentInvalid: (error) => Effect.succeed(refused(error.reason)),
303
+ }))));
304
+ mcp.registerTool("send_briefing", {
305
+ title: "Brief the person, point by point",
306
+ description: "Explain something that breaks into several points (findings, options, steps, a list of changes) so the person can reply on each point on its own: ask about one, push back on another, resolve the rest. Use it when they ask you to explain or walk them through something, instead of writing the list in your reply. Give each point a one-line title, which they see folded, and its detail beneath. Points are numbered for you, 1, 2, 3 and on, straight through every section, and their replies come back keyed to those numbers: answer them with answer_briefing. Returns at once as a background task where your client supports it; otherwise waits for their replies.",
307
+ inputSchema: briefingInputSchema,
308
+ annotations: waits,
309
+ }, (input, ctx) => serve(ctx, (space) => Effect.gen(function* () {
310
+ const client = clientOf(ctx);
311
+ const ask = yield* space.open({
312
+ kind: "briefing",
313
+ input,
314
+ actor: access.actor,
315
+ ...(client === undefined ? {} : { client }),
316
+ });
317
+ return yield* settle(ctx, space, ask.id, ...latestTurn(ask));
318
+ }).pipe(Effect.catchTag(["ContentInvalid", "Declined"], (error) => Effect.succeed(refused(error.reason))))));
319
+ mcp.registerTool("answer_briefing", {
320
+ title: "Answer the person, point by point",
321
+ description: "Answer the person's replies on a briefing: one answer for every point they wrote on without resolving it, and for any other open point you have more to say on. When a point needs drilling into, break it into sub-points, numbered beneath it (3.1, 3.2), which they can reply on in turn. Then get their next replies the same way the briefing brought these. The briefing closes once they resolve every point.",
322
+ inputSchema: briefingAnswerInputSchema,
323
+ annotations: waits,
324
+ }, (input, ctx) => serve(ctx, (space) => Effect.gen(function* () {
325
+ // A retried move rejoins the turn it first took, which may no longer be the last.
326
+ const { ask, turn } = yield* space.answerBriefing(input);
327
+ const answer = ask.thread[turn - 1];
328
+ return yield* settle(ctx, space, ask.id, turn, answer?.at ?? ask.updatedAt);
329
+ }).pipe(Effect.catchTags({
330
+ AskNotFound: () => Effect.succeed(refused(`No briefing "${input.askId}".`)),
331
+ AskClosed: () => Effect.succeed(refused("The person closed that briefing. Act on what they said, or ask a new question if something is still unclear.")),
332
+ NotYourTurn: () => Effect.succeed(refused("The person has not replied on that briefing since your last answers, so there is nothing to answer yet.")),
333
+ ContentInvalid: (error) => Effect.succeed(refused(error.reason)),
334
+ }))));
335
+ mcp.registerTool("open_inbox", {
336
+ title: "Open the person's inbox",
337
+ description: "Get the address of the person's Loopback inbox: the live page where they read and answer everything agents ask, across all their projects. Use it when they ask where their inbox or dashboard is, or to point them at one ask by its askId. Open the address for them with the system's open command if you can; otherwise give it to them.",
338
+ inputSchema: inboxRequestSchema,
339
+ outputSchema: inboxLinkSchema,
340
+ annotations: { readOnlyHint: true, openWorldHint: false },
341
+ }, (input) => {
342
+ const url = inboxUrl(input.askId === undefined
343
+ ? { origin, space: access.space }
344
+ : { origin, space: access.space, askId: input.askId });
345
+ return { ...text(`The person's inbox: ${url}`), structuredContent: { url } };
346
+ });
347
+ mcp.registerTool("create_image_upload", {
348
+ title: "Upload an image for a review",
349
+ description: "Get a one-time URL for uploading an image, such as a screenshot, to show in request_review. Run the command it returns with the file's path, then put the uploadId in an image block. The URL expires after fifteen minutes.",
350
+ inputSchema: uploadRequestSchema,
351
+ outputSchema: uploadTicketSchema,
352
+ annotations: {
353
+ readOnlyHint: false,
354
+ destructiveHint: false,
355
+ idempotentHint: false,
356
+ openWorldHint: false,
357
+ },
358
+ }, (input, ctx) => serve(ctx, (space) => Effect.gen(function* () {
359
+ const issued = yield* space.createUpload(input.contentType);
360
+ const uploadUrl = `${origin}/api/v1/uploads/${issued.spaceId}/${issued.token}`;
361
+ const command = `curl -sS --fail-with-body -X PUT -H 'Content-Type: ${issued.contentType}' --data-binary @FILE '${uploadUrl}'`;
362
+ const ticket = {
363
+ uploadId: issued.uploadId,
364
+ uploadUrl,
365
+ expiresAt: issued.expiresAt,
366
+ maxBytes: issued.maxBytes,
367
+ command,
368
+ };
369
+ return {
370
+ ...text(`Upload the image by running this, with FILE replaced by its path:\n\n${command}\n\nThen show it with an image block: { "type": "image", "uploadId": "${issued.uploadId}" }.`),
371
+ structuredContent: ticket,
372
+ };
373
+ }).pipe(Effect.catchTag("Declined", (error) => Effect.succeed(refused(error.reason))))));
374
+ return mcp;
375
+ };
376
+ /**
377
+ * The MCP endpoint. The gate runs first; what it established reaches the per-request server
378
+ * through the `authInfo` the SDK passes along untouched.
379
+ */
380
+ export const makeMcp = (inboxUrl) => {
381
+ const callers = new WeakMap();
382
+ const handler = createMcpHandler((ctx) => {
383
+ const caller = ctx.authInfo === undefined ? undefined : callers.get(ctx.authInfo);
384
+ if (caller === undefined)
385
+ throw new Error("An MCP request reached the server without access.");
386
+ return server(caller);
387
+ },
388
+ // Always stream, so a long wait starts its response at once and keep-alive frames flow.
389
+ { responseMode: "sse" });
390
+ return (request, runtime, access, requestId) => {
391
+ const authInfo = { token: "", clientId: access.actor.id, scopes: [] };
392
+ callers.set(authInfo, {
393
+ runtime,
394
+ access,
395
+ origin: new URL(request.url).origin,
396
+ requestId,
397
+ inboxUrl,
398
+ });
399
+ return handler.fetch(request, { authInfo });
400
+ };
401
+ };
@@ -0,0 +1,45 @@
1
+ import type { Problem } from "@loopbacktools/protocol";
2
+ import { Cause, Effect, Layer, ManagedRuntime } from "effect";
3
+ import { type WorkerBindings, WorkerEnv } from "./env.ts";
4
+ import { Files } from "./files.ts";
5
+ import { type Access, type Gate } from "./gate.ts";
6
+ import { Spaces } from "./spaces.ts";
7
+ /** Everything a request handler may use. */
8
+ export type WorkerServices = Gate | Spaces | Files | WorkerEnv;
9
+ export type WorkerRuntime = ManagedRuntime.ManagedRuntime<WorkerServices, never>;
10
+ /** Who is calling, established once per request. */
11
+ export interface Caller {
12
+ readonly access: Access | null;
13
+ readonly requestId: string;
14
+ readonly signal: AbortSignal;
15
+ }
16
+ export declare const problem: (code: string, message: string) => Problem;
17
+ /**
18
+ * One runtime per environment, built only from binding-backed layers. Nothing request-scoped is
19
+ * cached in it, so no request's pending work can be left for a later one to wait on. Configuration
20
+ * is read from the environment, and logs are JSON, which Workers Logs indexes.
21
+ */
22
+ export declare const runtimes: (gate: Layer.Layer<Gate, never, WorkerEnv>, spaces: Layer.Layer<Spaces, never, WorkerEnv>) => (env: WorkerBindings) => WorkerRuntime;
23
+ /**
24
+ * A request that died rather than failing with a declared error: the deployment is not configured,
25
+ * the request was abandoned, or something is broken. Thrown past the handler to `answerUnhandled`.
26
+ */
27
+ export declare class Unhandled extends Error {
28
+ readonly name = "Unhandled";
29
+ readonly defect: unknown;
30
+ readonly interrupted: boolean;
31
+ constructor(cause: Cause.Cause<unknown>);
32
+ }
33
+ /**
34
+ * Runs a handler's effect on the runtime. Its error channel is `never`: every declared error has
35
+ * already become a declared response. What remains dies: it is logged under the request's id, and
36
+ * thrown as `Unhandled`. A request its client abandoned is only interrupted, and not logged.
37
+ */
38
+ export declare const run: <A>(runtime: WorkerRuntime, signal: AbortSignal, requestId: string, effect: Effect.Effect<A, never, WorkerServices>) => Promise<A>;
39
+ /**
40
+ * What the caller of a request that died is told: only its id. `run` has logged it already; anything
41
+ * else that reached here died outside it, and is logged now.
42
+ */
43
+ export declare const incident: (error: unknown, requestId: string) => string;
44
+ /** What an unhandled request answers: a 500 naming a missing setting, or one with an incident id. */
45
+ export declare const answerUnhandled: (error: unknown, requestId: string) => Response;
@@ -0,0 +1,71 @@
1
+ import { Cause, ConfigProvider, Effect, Exit, Layer, Logger, ManagedRuntime } from "effect";
2
+ import { WorkerEnv } from "./env.js";
3
+ import { Files } from "./files.js";
4
+ import { NotConfigured } from "./gate.js";
5
+ import { Spaces } from "./spaces.js";
6
+ export const problem = (code, message) => ({ code, message });
7
+ /**
8
+ * One runtime per environment, built only from binding-backed layers. Nothing request-scoped is
9
+ * cached in it, so no request's pending work can be left for a later one to wait on. Configuration
10
+ * is read from the environment, and logs are JSON, which Workers Logs indexes.
11
+ */
12
+ export const runtimes = (gate, spaces) => {
13
+ const built = new WeakMap();
14
+ return (env) => {
15
+ let runtime = built.get(env);
16
+ if (runtime === undefined) {
17
+ runtime = ManagedRuntime.make(Layer.mergeAll(gate, spaces, Files.layer, ConfigProvider.layer(ConfigProvider.fromUnknown(env)), Logger.layer([Logger.consoleJson])).pipe(Layer.provideMerge(Layer.succeed(WorkerEnv, env))));
18
+ built.set(env, runtime);
19
+ }
20
+ return runtime;
21
+ };
22
+ };
23
+ /**
24
+ * A request that died rather than failing with a declared error: the deployment is not configured,
25
+ * the request was abandoned, or something is broken. Thrown past the handler to `answerUnhandled`.
26
+ */
27
+ export class Unhandled extends Error {
28
+ name = "Unhandled";
29
+ defect;
30
+ interrupted;
31
+ constructor(cause) {
32
+ const defect = Cause.squash(cause);
33
+ super(defect instanceof Error ? defect.message : String(defect));
34
+ this.defect = defect;
35
+ this.interrupted = Cause.hasInterrupts(cause);
36
+ }
37
+ }
38
+ /**
39
+ * Runs a handler's effect on the runtime. Its error channel is `never`: every declared error has
40
+ * already become a declared response. What remains dies: it is logged under the request's id, and
41
+ * thrown as `Unhandled`. A request its client abandoned is only interrupted, and not logged.
42
+ */
43
+ export const run = async (runtime, signal, requestId, effect) => {
44
+ const exit = await runtime.runPromiseExit(effect.pipe(Effect.tapCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logError("Request died", cause)), Effect.annotateLogs({ requestId })), { signal });
45
+ if (Exit.isSuccess(exit))
46
+ return exit.value;
47
+ throw new Unhandled(exit.cause);
48
+ };
49
+ /**
50
+ * What the caller of a request that died is told: only its id. `run` has logged it already; anything
51
+ * else that reached here died outside it, and is logged now.
52
+ */
53
+ export const incident = (error, requestId) => {
54
+ if (!(error instanceof Unhandled)) {
55
+ console.error(JSON.stringify({
56
+ level: "ERROR",
57
+ message: "Request died",
58
+ requestId,
59
+ cause: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
60
+ }));
61
+ }
62
+ return `Something went wrong in Loopback. Incident ${requestId}.`;
63
+ };
64
+ /** What an unhandled request answers: a 500 naming a missing setting, or one with an incident id. */
65
+ export const answerUnhandled = (error, requestId) => {
66
+ const defect = error instanceof Unhandled ? error.defect : error;
67
+ if (defect instanceof NotConfigured) {
68
+ return Response.json(problem("server.not_configured", defect.message), { status: 500 });
69
+ }
70
+ return Response.json(problem("server.defect", incident(error, requestId)), { status: 500 });
71
+ };
@@ -0,0 +1,24 @@
1
+ import { Context, Effect, Layer, Option } from "effect";
2
+ import { type SpaceClient } from "../space/rpc.ts";
3
+ import type { WorkerBindings } from "./env.ts";
4
+ import type { Access } from "./gate.ts";
5
+ declare const Spaces_base: Context.ServiceClass<Spaces, "loopback/Spaces", {
6
+ readonly of: (access: Access) => SpaceClient;
7
+ /** The object id of the space an access opens: how uploads and files name it. */
8
+ readonly idOf: (access: Access) => string;
9
+ /** A space by its object id, as an upload URL names it. None when the id is not one. */
10
+ readonly byId: (spaceId: string) => Option.Option<SpaceClient>;
11
+ /** Hands an inbox's WebSocket upgrade to the space, named by an access or by its object id. */
12
+ readonly connect: (space: Access | string, request: Request) => Effect.Effect<Response>;
13
+ }>;
14
+ /** Reaches the space a caller's access opens. */
15
+ export declare class Spaces extends Spaces_base {
16
+ static readonly layer: Layer.Layer<Spaces, never, never>;
17
+ }
18
+ /**
19
+ * Deletes everything a space holds: its asks and their threads, its tickets, and the files its
20
+ * uploads stored. Inboxes watching it are closed, and it carries on as an empty space. For a host
21
+ * removing a tenant; no request can reach it. Running it again finishes what a failed run left.
22
+ */
23
+ export declare const eraseSpace: (env: WorkerBindings, space: string) => Promise<void>;
24
+ export {};
@@ -0,0 +1,64 @@
1
+ import { BUILD_HEADER, REVISION_HEADER } from "@loopbacktools/protocol";
2
+ import { REVISION } from "@loopbacktools/protocol/schema";
3
+ import { env as workerEnv, exports } from "cloudflare:workers";
4
+ import { Context, Effect, Layer, Option } from "effect";
5
+ import { buildOf, spaceClient } from "../space/rpc.js";
6
+ import { filesOf } from "./files.js";
7
+ /**
8
+ * The deployment's `Space` class, as the runtime binds every Durable Object class a Worker exports:
9
+ * no binding in its configuration names the Worker, so a copy deployed under any name reaches its own.
10
+ */
11
+ const namespace = () => exports.Space;
12
+ /** Reaches the space a caller's access opens. */
13
+ export class Spaces extends Context.Service()("loopback/Spaces") {
14
+ static layer = Layer.sync(Spaces, () => {
15
+ const spaces = namespace();
16
+ const stubOf = (access) => spaces.getByName(access.space);
17
+ // This code's version, which every call names, so a space still on older code knows to restart.
18
+ const caller = { revision: REVISION, build: buildOf(workerEnv) };
19
+ return Spaces.of({
20
+ of: (access) => spaceClient(() => stubOf(access), caller),
21
+ idOf: (access) => spaces.idFromName(access.space).toString(),
22
+ byId: (spaceId) => {
23
+ if (!/^[0-9a-f]{64}$/.test(spaceId))
24
+ return Option.none();
25
+ try {
26
+ const id = spaces.idFromString(spaceId);
27
+ return Option.some(spaceClient(() => spaces.get(id), caller));
28
+ }
29
+ catch {
30
+ // A well-formed id this namespace did not mint.
31
+ return Option.none();
32
+ }
33
+ },
34
+ connect: (space, request) => Effect.promise(() => {
35
+ // Named by this code alone, whatever the request brought with it.
36
+ const named = new Request(request);
37
+ named.headers.set(REVISION_HEADER, String(caller.revision));
38
+ if (caller.build === undefined)
39
+ named.headers.delete(BUILD_HEADER);
40
+ else
41
+ named.headers.set(BUILD_HEADER, caller.build);
42
+ return (typeof space === "string" ? spaces.get(spaces.idFromString(space)) : stubOf(space)).fetch(named);
43
+ }),
44
+ });
45
+ });
46
+ }
47
+ /**
48
+ * Deletes everything a space holds: its asks and their threads, its tickets, and the files its
49
+ * uploads stored. Inboxes watching it are closed, and it carries on as an empty space. For a host
50
+ * removing a tenant; no request can reach it. Running it again finishes what a failed run left.
51
+ */
52
+ export const eraseSpace = async (env, space) => {
53
+ const spaces = namespace();
54
+ await spaces.getByName(space).erase();
55
+ await eraseFiles(env.FILES, filesOf(spaces.idFromName(space).toString()));
56
+ };
57
+ /** Deletes every file under a prefix, a page of listings at a time. */
58
+ const eraseFiles = async (files, prefix) => {
59
+ const listed = await files.list({ prefix, limit: 1000 });
60
+ if (listed.objects.length > 0)
61
+ await files.delete(listed.objects.map(({ key }) => key));
62
+ if (listed.truncated)
63
+ await eraseFiles(files, prefix);
64
+ };