@floomhq/floom 3.1.0 → 5.0.1

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 (72) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +346 -168
  3. package/bin/floom-mcp +9 -0
  4. package/bin/workeros-mcp +13 -0
  5. package/dist/cli.d.ts +6 -0
  6. package/dist/cli.js +336 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/commands/completion.d.ts +2 -0
  9. package/dist/commands/completion.js +81 -0
  10. package/dist/commands/completion.js.map +1 -0
  11. package/dist/commands/connections.d.ts +22 -0
  12. package/dist/commands/connections.js +158 -0
  13. package/dist/commands/connections.js.map +1 -0
  14. package/dist/commands/contexts.d.ts +34 -0
  15. package/dist/commands/contexts.js +247 -0
  16. package/dist/commands/contexts.js.map +1 -0
  17. package/dist/commands/doctor.d.ts +3 -0
  18. package/dist/commands/doctor.js +158 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/login.d.ts +14 -0
  21. package/dist/commands/login.js +349 -0
  22. package/dist/commands/login.js.map +1 -0
  23. package/dist/commands/logout.d.ts +1 -0
  24. package/dist/commands/logout.js +15 -0
  25. package/dist/commands/logout.js.map +1 -0
  26. package/dist/commands/mcp.d.ts +42 -0
  27. package/dist/commands/mcp.js +382 -0
  28. package/dist/commands/mcp.js.map +1 -0
  29. package/dist/commands/run.d.ts +15 -0
  30. package/dist/commands/run.js +154 -0
  31. package/dist/commands/run.js.map +1 -0
  32. package/dist/commands/runs.d.ts +25 -0
  33. package/dist/commands/runs.js +324 -0
  34. package/dist/commands/runs.js.map +1 -0
  35. package/dist/commands/secrets.d.ts +9 -0
  36. package/dist/commands/secrets.js +97 -0
  37. package/dist/commands/secrets.js.map +1 -0
  38. package/dist/commands/whoami.d.ts +3 -0
  39. package/dist/commands/whoami.js +70 -0
  40. package/dist/commands/whoami.js.map +1 -0
  41. package/dist/commands/workers.d.ts +30 -0
  42. package/dist/commands/workers.js +773 -0
  43. package/dist/commands/workers.js.map +1 -0
  44. package/dist/commands/workspaces.d.ts +10 -0
  45. package/dist/commands/workspaces.js +171 -0
  46. package/dist/commands/workspaces.js.map +1 -0
  47. package/dist/lib/api.d.ts +38 -0
  48. package/dist/lib/api.js +311 -0
  49. package/dist/lib/api.js.map +1 -0
  50. package/dist/lib/cli-errors.d.ts +1 -0
  51. package/dist/lib/cli-errors.js +20 -0
  52. package/dist/lib/cli-errors.js.map +1 -0
  53. package/dist/lib/command-name.d.ts +4 -0
  54. package/dist/lib/command-name.js +23 -0
  55. package/dist/lib/command-name.js.map +1 -0
  56. package/dist/lib/credentials.d.ts +21 -0
  57. package/dist/lib/credentials.js +138 -0
  58. package/dist/lib/credentials.js.map +1 -0
  59. package/dist/lib/output.d.ts +18 -0
  60. package/dist/lib/output.js +44 -0
  61. package/dist/lib/output.js.map +1 -0
  62. package/dist/lib/prompt.d.ts +2 -0
  63. package/dist/lib/prompt.js +55 -0
  64. package/dist/lib/prompt.js.map +1 -0
  65. package/dist/server.d.ts +5 -0
  66. package/dist/server.js +1171 -0
  67. package/dist/server.js.map +1 -0
  68. package/package.json +43 -51
  69. package/dist/index.d.ts +0 -1
  70. package/dist/index.js +0 -8278
  71. package/dist/version.d.ts +0 -1
  72. package/dist/version.js +0 -1
package/dist/server.js ADDED
@@ -0,0 +1,1171 @@
1
+ #!/usr/bin/env node
2
+ import { Buffer } from "node:buffer";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+ import { readCredentials } from "./lib/credentials.js";
9
+ const DEFAULT_API_BASE = "http://localhost:8000";
10
+ const TERMINAL_RUN_STATUSES = new Set([
11
+ "success",
12
+ "error",
13
+ "completed",
14
+ "failed",
15
+ "approved",
16
+ "rejected",
17
+ ]);
18
+ class FloomApiError extends Error {
19
+ status;
20
+ body;
21
+ constructor(message, status, body) {
22
+ super(message);
23
+ this.status = status;
24
+ this.body = body;
25
+ this.name = "FloomApiError";
26
+ }
27
+ }
28
+ function apiBase() {
29
+ return (process.env.WORKEROS_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, "");
30
+ }
31
+ function hostedModeRequested() {
32
+ const value = (process.env.WORKEROS_CLOUD || "").trim().toLowerCase();
33
+ return value === "1" || value === "true" || value === "yes" || value === "on";
34
+ }
35
+ function isHostedApi() {
36
+ return Boolean(process.env.WORKEROS_API_TOKEN) || hostedModeRequested();
37
+ }
38
+ function resolvePath(path) {
39
+ if (!isHostedApi())
40
+ return path;
41
+ if (path.startsWith("/api/"))
42
+ return path;
43
+ if (path.startsWith("/auth/"))
44
+ return path;
45
+ if (path === "/healthz")
46
+ return path;
47
+ return `/api${path.startsWith("/") ? "" : "/"}${path}`;
48
+ }
49
+ // #1455: the workspace id resolved once at startup from readCredentials() (env
50
+ // OR ~/.config/floom/credentials.json). authHeader() is synchronous and runs
51
+ // per-request, so we cache it here instead of reading the creds file each call.
52
+ let resolvedWorkspaceId;
53
+ // #1455: hosted APIs may require x-workeros-workspace on worker WRITES
54
+ // (WORKEROS_REQUIRE_WORKSPACE_HEADER_FOR_WRITES=1). The CLI's lib/api.ts always
55
+ // sends it; this server used to omit it, so every MCP worker mutation 400'd on
56
+ // hosted APIs while reads/secrets/contexts (which fall back to the PAT's default
57
+ // workspace) silently worked - a confusing partial failure. Mirror the CLI:
58
+ // send the header whenever we know the id, in every auth mode.
59
+ function activeWorkspaceId() {
60
+ return process.env.WORKEROS_WORKSPACE_ID?.trim() || resolvedWorkspaceId;
61
+ }
62
+ function authHeader() {
63
+ const headers = {};
64
+ const token = process.env.WORKEROS_API_TOKEN?.trim();
65
+ if (token) {
66
+ headers["x-floom-token"] = token;
67
+ }
68
+ else {
69
+ const secret = process.env.WORKEROS_API_SECRET?.trim();
70
+ if (!secret) {
71
+ throw new Error("WORKEROS_API_TOKEN or WORKEROS_API_SECRET is required");
72
+ }
73
+ headers["x-floom-secret"] = secret;
74
+ // Self-hosted engines with user-header scope require x-floom-user (OSS only).
75
+ const user = (process.env.WORKEROS_USER || process.env.FLOOM_USER || "").trim();
76
+ if (user) {
77
+ headers["x-floom-user"] = user;
78
+ }
79
+ }
80
+ const workspace = activeWorkspaceId();
81
+ if (workspace) {
82
+ headers["x-workeros-workspace"] = workspace;
83
+ }
84
+ return headers;
85
+ }
86
+ function jsonResult(data, summary) {
87
+ const safeData = redactSecrets(data);
88
+ const structuredContent = safeData && typeof safeData === "object" && !Array.isArray(safeData)
89
+ ? safeData
90
+ : { data: safeData };
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: summary ? `${summary}\n${JSON.stringify(safeData, null, 2)}` : JSON.stringify(safeData, null, 2),
96
+ },
97
+ ],
98
+ structuredContent,
99
+ };
100
+ }
101
+ export function redactSecrets(value) {
102
+ if (Array.isArray(value)) {
103
+ return value.map((item) => redactSecrets(item));
104
+ }
105
+ if (typeof value === "string") {
106
+ return redactSecretText(value);
107
+ }
108
+ if (!value || typeof value !== "object") {
109
+ return value;
110
+ }
111
+ const redacted = {};
112
+ for (const [key, nested] of Object.entries(value)) {
113
+ if (/(secret|token|password|api[_-]?key)/i.test(key)) {
114
+ redacted[key] = "[redacted]";
115
+ }
116
+ else {
117
+ redacted[key] = redactSecrets(nested);
118
+ }
119
+ }
120
+ return redacted;
121
+ }
122
+ function errorResult(error) {
123
+ const message = redactSecretText(error instanceof Error ? error.message : String(error));
124
+ const status = error instanceof FloomApiError ? error.status : undefined;
125
+ const body = error instanceof FloomApiError ? redactSecrets(error.body) : undefined;
126
+ const structuredContent = { error: message };
127
+ if (status !== undefined) {
128
+ structuredContent.status = status;
129
+ }
130
+ if (body !== undefined) {
131
+ structuredContent.body = body;
132
+ }
133
+ return {
134
+ isError: true,
135
+ content: [{ type: "text", text: message }],
136
+ structuredContent,
137
+ };
138
+ }
139
+ function redactSecretText(text) {
140
+ return text
141
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]")
142
+ .replace(/((?:secret|token|password|api[_-]?key|authorization)["']?\s*[:=]\s*["']?)([^"',}\s]+)/gi, "$1[redacted]")
143
+ .replace(/([?&](?:token|key|secret|signature|sig|code|api[_-]?key)=)([^&\s]+)/gi, "$1[redacted]")
144
+ .replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})\b/g, "[redacted]");
145
+ }
146
+ function renderErrorDetail(value) {
147
+ if (typeof value === "string") {
148
+ return redactSecretText(value);
149
+ }
150
+ return JSON.stringify(redactSecrets(value));
151
+ }
152
+ async function callTool(handler) {
153
+ try {
154
+ return await handler();
155
+ }
156
+ catch (error) {
157
+ return errorResult(error);
158
+ }
159
+ }
160
+ async function parseResponse(response) {
161
+ const text = await response.text();
162
+ if (!text) {
163
+ return {};
164
+ }
165
+ const contentType = response.headers.get("content-type") || "";
166
+ if (contentType.includes("application/json")) {
167
+ return JSON.parse(text);
168
+ }
169
+ try {
170
+ return JSON.parse(text);
171
+ }
172
+ catch {
173
+ return { text };
174
+ }
175
+ }
176
+ function buildUrl(path, query) {
177
+ const url = new URL(`${apiBase()}${resolvePath(path)}`);
178
+ for (const [key, value] of Object.entries(query || {})) {
179
+ if (value !== undefined) {
180
+ url.searchParams.set(key, String(value));
181
+ }
182
+ }
183
+ return url.toString();
184
+ }
185
+ async function request(method, path, body, query) {
186
+ const response = await fetch(buildUrl(path, query), {
187
+ method,
188
+ headers: {
189
+ "accept": "application/json, text/event-stream",
190
+ "content-type": "application/json",
191
+ ...authHeader(),
192
+ },
193
+ body: body === undefined ? undefined : JSON.stringify(body),
194
+ });
195
+ const parsed = await parseResponse(response);
196
+ if (!response.ok) {
197
+ const safeParsed = redactSecrets(parsed);
198
+ const detail = typeof safeParsed === "object" && safeParsed && "detail" in safeParsed
199
+ ? renderErrorDetail(safeParsed.detail)
200
+ : JSON.stringify(safeParsed);
201
+ throw new FloomApiError(`Floom API ${method} ${path} failed with HTTP ${response.status}: ${detail}`, response.status, parsed);
202
+ }
203
+ return parsed;
204
+ }
205
+ async function requestBytes(method, path, body, contentType = "application/octet-stream") {
206
+ const response = await fetch(buildUrl(path), {
207
+ method,
208
+ headers: {
209
+ "accept": "application/json",
210
+ "content-type": contentType,
211
+ ...authHeader(),
212
+ },
213
+ body: Buffer.from(body),
214
+ });
215
+ const parsed = await parseResponse(response);
216
+ if (!response.ok) {
217
+ const safeParsed = redactSecrets(parsed);
218
+ const detail = typeof safeParsed === "object" && safeParsed && "detail" in safeParsed
219
+ ? renderErrorDetail(safeParsed.detail)
220
+ : JSON.stringify(safeParsed);
221
+ throw new FloomApiError(`Floom API ${method} ${path} failed with HTTP ${response.status}: ${detail}`, response.status, parsed);
222
+ }
223
+ return parsed;
224
+ }
225
+ async function readContextFile(name, path) {
226
+ const detail = await request("GET", `/contexts/${encodeURIComponent(name)}`);
227
+ const files = Array.isArray(detail.files) ? detail.files : [];
228
+ const file = files.find((item) => item.path === path);
229
+ if (!file) {
230
+ throw new FloomApiError(`Context file ${name}/${path} was not found`, 404);
231
+ }
232
+ const downloadPath = `/contexts/${encodeURIComponent(name)}/files/${path.split("/").map(encodeURIComponent).join("/")}`;
233
+ if (file.is_binary) {
234
+ return {
235
+ name,
236
+ path,
237
+ size: file.size,
238
+ mime_type: file.mime_type,
239
+ is_binary: true,
240
+ download_url: buildUrl(downloadPath),
241
+ note: "Binary context file. Use the HTTP API download URL to fetch bytes.",
242
+ };
243
+ }
244
+ const response = await fetch(buildUrl(downloadPath), {
245
+ method: "GET",
246
+ headers: {
247
+ "accept": "text/plain, application/json, text/*",
248
+ ...authHeader(),
249
+ },
250
+ });
251
+ if (!response.ok) {
252
+ const parsed = await parseResponse(response);
253
+ throw new FloomApiError(`Floom API GET ${downloadPath} failed with HTTP ${response.status}: ${JSON.stringify(redactSecrets(parsed))}`, response.status, parsed);
254
+ }
255
+ return {
256
+ name,
257
+ path,
258
+ size: file.size,
259
+ mime_type: file.mime_type,
260
+ is_binary: false,
261
+ content: await response.text(),
262
+ };
263
+ }
264
+ async function listTriggers(workerId, app) {
265
+ if (app) {
266
+ return request("GET", "/integrations/triggers", undefined, { app });
267
+ }
268
+ if (!workerId) {
269
+ return request("GET", "/integrations/triggers");
270
+ }
271
+ const worker = await request("GET", `/workers/${encodeURIComponent(workerId)}`);
272
+ const config = (worker.config && typeof worker.config === "object") ? worker.config : {};
273
+ const connections = Array.isArray(config.connections)
274
+ ? config.connections.flatMap((item) => {
275
+ if (typeof item === "string")
276
+ return [item];
277
+ if (item && typeof item === "object") {
278
+ const record = item;
279
+ const composio = record.composio;
280
+ if (composio && typeof composio === "object" && typeof composio.app === "string") {
281
+ return [String(composio.app)];
282
+ }
283
+ if (typeof record.app === "string")
284
+ return [String(record.app)];
285
+ }
286
+ return [];
287
+ })
288
+ : [];
289
+ if (!connections.length) {
290
+ return { items: [] };
291
+ }
292
+ const merged = [];
293
+ const seen = new Set();
294
+ for (const connection of connections) {
295
+ const payload = await request("GET", "/integrations/triggers", undefined, { app: connection });
296
+ const items = Array.isArray(payload.items) ? payload.items : [];
297
+ for (const item of items) {
298
+ if (!item || typeof item !== "object") {
299
+ continue;
300
+ }
301
+ const eventName = String(item.name || item.slug || JSON.stringify(item));
302
+ const dedupeKey = `${connection}:${eventName}`;
303
+ if (seen.has(dedupeKey)) {
304
+ continue;
305
+ }
306
+ seen.add(dedupeKey);
307
+ merged.push(item);
308
+ }
309
+ }
310
+ return { items: merged };
311
+ }
312
+ async function watchRunEvents(runId, timeoutMs) {
313
+ const controller = new AbortController();
314
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
315
+ const events = [];
316
+ let status;
317
+ let buffer = "";
318
+ let sawTerminalStatus = false;
319
+ try {
320
+ const response = await fetch(buildUrl(`/runs/${encodeURIComponent(runId)}/events`), {
321
+ method: "GET",
322
+ headers: {
323
+ "accept": "text/event-stream",
324
+ ...authHeader(),
325
+ },
326
+ signal: controller.signal,
327
+ });
328
+ if (!response.ok) {
329
+ const parsed = await parseResponse(response);
330
+ const safeParsed = redactSecrets(parsed);
331
+ const detail = typeof safeParsed === "object" && safeParsed && "detail" in safeParsed
332
+ ? redactSecretText(String(safeParsed.detail))
333
+ : JSON.stringify(safeParsed);
334
+ throw new FloomApiError(`Floom API GET /runs/${runId}/events failed with HTTP ${response.status}: ${detail}`, response.status, parsed);
335
+ }
336
+ if (!response.body) {
337
+ throw new FloomApiError("Floom run events response did not include a body", response.status);
338
+ }
339
+ const reader = response.body.getReader();
340
+ const decoder = new TextDecoder();
341
+ while (true) {
342
+ const { done, value } = await reader.read();
343
+ if (done) {
344
+ break;
345
+ }
346
+ buffer += decoder.decode(value, { stream: true });
347
+ const chunks = buffer.split(/\r?\n\r?\n/);
348
+ buffer = chunks.pop() || "";
349
+ for (const chunk of chunks) {
350
+ const event = parseSseEvent(chunk);
351
+ if (!event) {
352
+ continue;
353
+ }
354
+ events.push(event);
355
+ const candidate = event.status ?? (event.data && typeof event.data === "object" ? event.data.status : undefined);
356
+ if (typeof candidate === "string") {
357
+ status = candidate;
358
+ }
359
+ if (status && TERMINAL_RUN_STATUSES.has(status)) {
360
+ sawTerminalStatus = true;
361
+ }
362
+ if (event.type === "close" || (event.data && typeof event.data === "object" && event.data.type === "close")) {
363
+ await reader.cancel();
364
+ return { run_id: runId, status: status || "closed", events };
365
+ }
366
+ }
367
+ if (sawTerminalStatus && events.length > 0 && buffer === "") {
368
+ await reader.cancel();
369
+ return { run_id: runId, status, events };
370
+ }
371
+ }
372
+ }
373
+ catch (error) {
374
+ if (error instanceof DOMException && error.name === "AbortError") {
375
+ throw new FloomApiError(`Timed out watching run ${runId} after ${timeoutMs}ms`);
376
+ }
377
+ throw error;
378
+ }
379
+ finally {
380
+ clearTimeout(timeout);
381
+ }
382
+ return { run_id: runId, status: status || "unknown", events };
383
+ }
384
+ function parseSseEvent(chunk) {
385
+ const event = {};
386
+ const dataLines = [];
387
+ for (const line of chunk.split(/\r?\n/)) {
388
+ if (!line || line.startsWith(":")) {
389
+ continue;
390
+ }
391
+ const separator = line.indexOf(":");
392
+ const field = separator === -1 ? line : line.slice(0, separator);
393
+ const value = separator === -1 ? "" : line.slice(separator + 1).trimStart();
394
+ if (field === "data") {
395
+ dataLines.push(value);
396
+ }
397
+ else {
398
+ event[field] = value;
399
+ }
400
+ }
401
+ if (dataLines.length) {
402
+ const rawData = dataLines.join("\n");
403
+ try {
404
+ event.data = JSON.parse(rawData);
405
+ }
406
+ catch {
407
+ event.data = rawData;
408
+ }
409
+ if (typeof event.data === "object" && event.data && "status" in event.data) {
410
+ event.status = event.data.status;
411
+ }
412
+ }
413
+ return Object.keys(event).length ? event : null;
414
+ }
415
+ function extractEnvSecrets(runPy) {
416
+ const secrets = new Set();
417
+ const patterns = [
418
+ /\bos\.environ\s*\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]/g,
419
+ /\bos\.environ\.get\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
420
+ /\bos\.getenv\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
421
+ ];
422
+ for (const pattern of patterns) {
423
+ for (const match of runPy.matchAll(pattern)) {
424
+ secrets.add(match[1]);
425
+ }
426
+ }
427
+ return [...secrets].sort();
428
+ }
429
+ function extractConnections(workerYml) {
430
+ const connections = new Set();
431
+ const inline = workerYml.match(/^connections:\s*\[([^\]]*)\]\s*$/m);
432
+ if (inline) {
433
+ for (const item of inline[1].split(",")) {
434
+ const value = item.trim().replace(/^["']|["']$/g, "");
435
+ if (value) {
436
+ connections.add(value);
437
+ }
438
+ }
439
+ }
440
+ const lines = workerYml.split(/\r?\n/);
441
+ const start = lines.findIndex((line) => /^connections:\s*$/.test(line));
442
+ if (start !== -1) {
443
+ for (let index = start + 1; index < lines.length; index += 1) {
444
+ const line = lines[index];
445
+ if (/^\S/.test(line) && line.trim()) {
446
+ break;
447
+ }
448
+ const match = line.match(/^\s*-\s*([^#\s]+|"[^"]+"|'[^']+')/);
449
+ if (match) {
450
+ connections.add(match[1].trim().replace(/^["']|["']$/g, ""));
451
+ }
452
+ }
453
+ }
454
+ return [...connections].sort();
455
+ }
456
+ function hasCapabilityList(workerYml, key) {
457
+ const lines = workerYml.split(/\r?\n/);
458
+ const start = lines.findIndex((line) => /^capabilities:\s*(?:#.*)?$/.test(line));
459
+ if (start === -1) {
460
+ return false;
461
+ }
462
+ for (let index = start + 1; index < lines.length; index += 1) {
463
+ const line = lines[index];
464
+ if (/^\S/.test(line) && line.trim()) {
465
+ break;
466
+ }
467
+ if (new RegExp(`^\\s{2}${key}:`).test(line)) {
468
+ return true;
469
+ }
470
+ }
471
+ return false;
472
+ }
473
+ function capabilityBlock(key, values) {
474
+ return [` ${key}:`, ...values.map((value) => ` - ${value}`)];
475
+ }
476
+ function addCapabilityList(workerYml, key, values) {
477
+ if (values.length === 0 || hasCapabilityList(workerYml, key)) {
478
+ return workerYml;
479
+ }
480
+ const lines = workerYml.split(/\r?\n/);
481
+ const capsIndex = lines.findIndex((line) => /^capabilities:\s*(?:#.*)?$/.test(line));
482
+ if (capsIndex === -1) {
483
+ const suffix = workerYml.endsWith("\n") ? "" : "\n";
484
+ return `${workerYml}${suffix}capabilities:\n${capabilityBlock(key, values).join("\n")}\n`;
485
+ }
486
+ lines.splice(capsIndex + 1, 0, ...capabilityBlock(key, values));
487
+ return lines.join("\n");
488
+ }
489
+ function autoFillCapabilities(workerYml, runPy) {
490
+ let updated = workerYml;
491
+ updated = addCapabilityList(updated, "secrets", extractEnvSecrets(runPy));
492
+ updated = addCapabilityList(updated, "connections", extractConnections(workerYml));
493
+ return updated;
494
+ }
495
+ const workerIdSchema = z.object({
496
+ id: z.string().min(1).describe("Floom worker id."),
497
+ });
498
+ const runIdSchema = z.object({
499
+ id: z.string().min(1).describe("Floom run id."),
500
+ });
501
+ async function consumeChatStream(message, conversationId, timeoutMs) {
502
+ const controller = new AbortController();
503
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
504
+ const textParts = [];
505
+ const toolCalls = [];
506
+ let finishEvent = null;
507
+ let buffer = "";
508
+ try {
509
+ const body = { message, source: "mcp" };
510
+ if (conversationId) {
511
+ body.conversation_id = conversationId;
512
+ }
513
+ const response = await fetch(buildUrl("/chat"), {
514
+ method: "POST",
515
+ headers: {
516
+ "accept": "text/event-stream",
517
+ "content-type": "application/json",
518
+ ...authHeader(),
519
+ },
520
+ body: JSON.stringify(body),
521
+ signal: controller.signal,
522
+ });
523
+ if (!response.ok) {
524
+ const parsed = await parseResponse(response);
525
+ const safeParsed = redactSecrets(parsed);
526
+ const detail = typeof safeParsed === "object" && safeParsed && "detail" in safeParsed
527
+ ? redactSecretText(String(safeParsed.detail))
528
+ : JSON.stringify(safeParsed);
529
+ throw new FloomApiError(`POST /chat failed with HTTP ${response.status}: ${detail}`, response.status, parsed);
530
+ }
531
+ if (!response.body) {
532
+ throw new FloomApiError("POST /chat response has no body");
533
+ }
534
+ const reader = response.body.getReader();
535
+ const decoder = new TextDecoder();
536
+ while (true) {
537
+ const { done, value } = await reader.read();
538
+ if (done) {
539
+ break;
540
+ }
541
+ buffer += decoder.decode(value, { stream: true });
542
+ const chunks = buffer.split(/\r?\n\r?\n/);
543
+ buffer = chunks.pop() || "";
544
+ for (const chunk of chunks) {
545
+ if (!chunk || chunk.startsWith(":")) {
546
+ continue;
547
+ }
548
+ const dataLine = chunk.split(/\r?\n/).find((l) => l.startsWith("data:"));
549
+ if (!dataLine) {
550
+ continue;
551
+ }
552
+ const raw = dataLine.slice(5).trimStart();
553
+ let part;
554
+ try {
555
+ part = JSON.parse(raw);
556
+ }
557
+ catch {
558
+ continue;
559
+ }
560
+ const partType = part.type;
561
+ if (partType === "text") {
562
+ textParts.push(String(part.text || ""));
563
+ }
564
+ else if (partType === "tool-call") {
565
+ toolCalls.push(part);
566
+ }
567
+ else if (partType === "finish") {
568
+ finishEvent = part;
569
+ await reader.cancel();
570
+ return buildChatResult(textParts, toolCalls, finishEvent);
571
+ }
572
+ }
573
+ }
574
+ }
575
+ catch (error) {
576
+ if (error instanceof DOMException && error.name === "AbortError") {
577
+ throw new FloomApiError(`workspace.chat timed out after ${timeoutMs}ms`);
578
+ }
579
+ throw error;
580
+ }
581
+ finally {
582
+ clearTimeout(timeout);
583
+ }
584
+ return buildChatResult(textParts, toolCalls, finishEvent);
585
+ }
586
+ function buildChatResult(textParts, toolCalls, finishEvent) {
587
+ return {
588
+ reply: textParts.join(""),
589
+ tool_calls: toolCalls,
590
+ conversation_id: finishEvent?.conversation_id ?? null,
591
+ message_id: finishEvent?.message_id ?? null,
592
+ };
593
+ }
594
+ export function createServer() {
595
+ const server = new McpServer({
596
+ name: "floom-mcp",
597
+ version: "0.1.0",
598
+ });
599
+ const workerContractYamlDescription = "WorkerContract YAML content. Required top-level fields: schema_version: \"0.3\", name, title, description, version, exec, and trigger. " +
600
+ "For script workers, exec must include entry: \"run.py\", runtime: \"python311\", runner: \"e2b\", command: \"python run.py\", plus exec.inputs and exec.outputs arrays. " +
601
+ "Example script output path: write result.json at the worker root after reading inputs.json at the worker root.";
602
+ server.registerTool("workers.list", {
603
+ title: "List Workers",
604
+ description: "List Floom workers.",
605
+ inputSchema: {},
606
+ annotations: { readOnlyHint: true, openWorldHint: true },
607
+ }, async () => callTool(async () => jsonResult(await request("GET", "/workers"))));
608
+ server.registerTool("workers.get", {
609
+ title: "Get Worker",
610
+ description: "Get a Floom worker by id.",
611
+ inputSchema: workerIdSchema.shape,
612
+ annotations: { readOnlyHint: true, openWorldHint: true },
613
+ }, async ({ id }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}`))));
614
+ server.registerTool("workers.create", {
615
+ title: "Create Worker",
616
+ description: "Create a Floom worker from WorkerContract YAML. " +
617
+ "The YAML must include schema_version, name, title, description, version, exec, and trigger. " +
618
+ "For script-mode workers supply run_py. For agent/skill-mode workers supply skill_md (the agent system prompt) and a minimal run_py stub.",
619
+ inputSchema: {
620
+ worker_yml: z.string().min(1).describe(workerContractYamlDescription),
621
+ run_py: z.string().min(1).describe("Python source for run.py. For skill workers use a minimal stub: 'def run(inputs, context): pass'"),
622
+ skill_md: z.string().optional().describe("Agent system prompt (SKILL.md) for skill/agent-mode workers. Omit for script-mode workers."),
623
+ },
624
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
625
+ }, async ({ worker_yml, run_py, skill_md }) => callTool(async () => jsonResult(await request("POST", "/workers", {
626
+ worker_yml: autoFillCapabilities(worker_yml, run_py),
627
+ run_py,
628
+ ...(skill_md ? { skill_md } : {}),
629
+ }), "Worker created.")));
630
+ server.registerTool("workers.update", {
631
+ title: "Update Worker",
632
+ description: "Update worker instance settings such as trigger, cron, input defaults, and documented capabilities.",
633
+ inputSchema: {
634
+ id: z.string().min(1).describe("Floom worker id."),
635
+ trigger_type: z.string().optional().describe("Trigger type, for example manual, cron, or webhook."),
636
+ cron_expr: z.string().optional().describe("Cron expression for cron workers."),
637
+ cron_timezone: z.string().optional().describe("IANA timezone for cron workers."),
638
+ input_values: z.record(z.string(), z.unknown()).optional().describe("Saved default input values for future runs."),
639
+ capabilities: z.record(z.string(), z.unknown()).optional().describe("Optional documented capabilities; not enforced."),
640
+ webhook_secret_rotate: z.boolean().optional().describe("Rotate the worker webhook secret and return the new raw secret once."),
641
+ },
642
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
643
+ }, async ({ id, ...updates }) => callTool(async () => jsonResult(await request("PATCH", `/workers/${encodeURIComponent(id)}`, updates), "Worker updated.")));
644
+ server.registerTool("workers.delete", {
645
+ title: "Delete Worker",
646
+ description: "Delete a Floom worker.",
647
+ inputSchema: workerIdSchema.shape,
648
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
649
+ }, async ({ id }) => callTool(async () => jsonResult(await request("DELETE", `/workers/${encodeURIComponent(id)}`), "Worker deleted.")));
650
+ server.registerTool("workers.run", {
651
+ title: "Run Worker",
652
+ description: "Start a manual Floom worker run.",
653
+ inputSchema: {
654
+ id: z.string().min(1).describe("Floom worker id."),
655
+ inputs: z.record(z.string(), z.unknown()).default({}).describe("Input values for this run."),
656
+ trigger_source: z.string().default("manual").describe("Run trigger source."),
657
+ },
658
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
659
+ }, async ({ id, inputs, trigger_source }) => callTool(async () => jsonResult(await request("POST", `/workers/${encodeURIComponent(id)}/runs`, { inputs, trigger_source }), "Worker run started.")));
660
+ server.registerTool("runs.list", {
661
+ title: "List Runs",
662
+ description: "List Floom runs, optionally filtered by worker id.",
663
+ inputSchema: {
664
+ worker_id: z.string().optional().describe("Optional worker id filter."),
665
+ status: z.string().optional().describe("Optional run status filter."),
666
+ limit: z.number().int().min(1).max(500).default(50).describe("Maximum runs to return."),
667
+ offset: z.number().int().min(0).default(0).describe("Pagination offset."),
668
+ },
669
+ annotations: { readOnlyHint: true, openWorldHint: true },
670
+ }, async ({ worker_id, status, limit, offset }) => callTool(async () => jsonResult(await request("GET", "/runs", undefined, { worker_id, status, limit, offset }))));
671
+ server.registerTool("runs.get", {
672
+ title: "Get Run",
673
+ description: "Get a Floom run by id, including logs, outputs, artifacts, and approval status.",
674
+ inputSchema: runIdSchema.shape,
675
+ annotations: { readOnlyHint: true, openWorldHint: true },
676
+ }, async ({ id }) => callTool(async () => jsonResult(await request("GET", `/runs/${encodeURIComponent(id)}`))));
677
+ server.registerTool("runs.watch", {
678
+ title: "Watch Run",
679
+ description: "Read server-sent events for a Floom run until a terminal status is reached.",
680
+ inputSchema: {
681
+ id: z.string().min(1).describe("Floom run id."),
682
+ timeout_ms: z.number().int().min(1000).max(600000).default(120000).describe("Maximum watch duration in milliseconds."),
683
+ },
684
+ annotations: { readOnlyHint: true, openWorldHint: true },
685
+ }, async ({ id, timeout_ms }) => callTool(async () => jsonResult(await watchRunEvents(id, timeout_ms), "Run watch completed.")));
686
+ server.registerTool("secrets.list", {
687
+ title: "List Secrets",
688
+ description: "List configured secret names and status.",
689
+ inputSchema: {},
690
+ annotations: { readOnlyHint: true, openWorldHint: true },
691
+ }, async () => callTool(async () => jsonResult(await request("GET", "/secrets"))));
692
+ server.registerTool("secrets.set", {
693
+ title: "Set Secret",
694
+ description: "Create or update a secret value.",
695
+ inputSchema: {
696
+ key: z.string().min(1).describe("Secret name."),
697
+ value: z.string().min(1).describe("Secret value."),
698
+ },
699
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
700
+ }, async ({ key, value }) => callTool(async () => jsonResult(await request("POST", `/secrets/${encodeURIComponent(key)}`, { value }), "Secret saved.")));
701
+ server.registerTool("secrets.delete", {
702
+ title: "Delete Secret",
703
+ description: "Delete a secret by key.",
704
+ inputSchema: {
705
+ key: z.string().min(1).describe("Secret name."),
706
+ },
707
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
708
+ }, async ({ key }) => callTool(async () => jsonResult(await request("DELETE", `/secrets/${encodeURIComponent(key)}`), "Secret deleted.")));
709
+ server.registerTool("connections.list", {
710
+ title: "List Connections",
711
+ description: "List configured app connections.",
712
+ inputSchema: {},
713
+ annotations: { readOnlyHint: true, openWorldHint: true },
714
+ }, async () => callTool(async () => jsonResult(await request("GET", "/connections"))));
715
+ server.registerTool("connections.add_mcp", {
716
+ title: "Add MCP Connection",
717
+ description: "Save an MCP server connection. Supports streamable_http, sse, and stdio transports.",
718
+ inputSchema: {
719
+ label: z.string().min(1).describe("Stable MCP label."),
720
+ transport: z.enum(["streamable_http", "sse", "stdio"]).default("streamable_http"),
721
+ url: z.string().optional().describe("HTTP/SSE endpoint URL."),
722
+ command: z.string().optional().describe("Stdio command, for example npx."),
723
+ args: z.array(z.string()).optional().default([]).describe("Stdio command arguments."),
724
+ env: z.record(z.string(), z.string()).optional().default({}).describe("Stdio env map. Use secret:SECRET_NAME values for secrets."),
725
+ cwd: z.string().optional().describe("Optional stdio working directory."),
726
+ auth_secret: z.string().optional().describe("Secret name for HTTP/SSE bearer auth."),
727
+ allowed_tools: z.array(z.string()).optional().default([]).describe("Optional allowed tool names."),
728
+ },
729
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
730
+ }, async (payload) => callTool(async () => jsonResult(await request("POST", "/connections/mcp", payload), "MCP connection saved.")));
731
+ server.registerTool("contexts.list", {
732
+ title: "List Contexts",
733
+ description: "List Floom context folders.",
734
+ inputSchema: {},
735
+ annotations: { readOnlyHint: true, openWorldHint: true },
736
+ }, async () => callTool(async () => jsonResult(await request("GET", "/contexts"))));
737
+ server.registerTool("contexts.read", {
738
+ title: "Read Context File",
739
+ description: "Read a UTF-8 context file, or return metadata and a download URL for binary files.",
740
+ inputSchema: {
741
+ name: z.string().min(1).describe("Context name."),
742
+ path: z.string().min(1).describe("File path inside the context."),
743
+ },
744
+ annotations: { readOnlyHint: true, openWorldHint: true },
745
+ }, async ({ name, path }) => callTool(async () => jsonResult(await readContextFile(name, path))));
746
+ server.registerTool("contexts.write", {
747
+ title: "Write Context File",
748
+ description: "Create or update a UTF-8 text file inside a context.",
749
+ inputSchema: {
750
+ name: z.string().min(1).describe("Context name."),
751
+ path: z.string().min(1).describe("File path inside the context."),
752
+ content: z.string().describe("UTF-8 text content."),
753
+ },
754
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
755
+ }, async ({ name, path, content }) => callTool(async () => jsonResult(await request("PUT", `/contexts/${encodeURIComponent(name)}/files/${path.split("/").map(encodeURIComponent).join("/")}`, { content }), "Context file saved.")));
756
+ server.registerTool("contexts.upload", {
757
+ title: "Upload Context File",
758
+ description: "Create or update a binary file inside a context from base64 bytes.",
759
+ inputSchema: {
760
+ name: z.string().min(1).describe("Context name."),
761
+ path: z.string().min(1).describe("File path inside the context."),
762
+ base64_bytes: z.string().min(1).describe("Base64-encoded file bytes."),
763
+ },
764
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
765
+ }, async ({ name, path, base64_bytes }) => callTool(async () => {
766
+ const bytes = Buffer.from(base64_bytes, "base64");
767
+ return jsonResult(await requestBytes("PUT", `/contexts/${encodeURIComponent(name)}/files/${path.split("/").map(encodeURIComponent).join("/")}`, bytes), "Context file uploaded.");
768
+ }));
769
+ server.registerTool("triggers.list", {
770
+ title: "List Triggers",
771
+ description: "List integration triggers, globally or filtered by worker/app.",
772
+ inputSchema: {
773
+ worker_id: z.string().optional().describe("Optional worker id to scope triggers by worker connections."),
774
+ app: z.string().optional().describe("Optional app slug filter."),
775
+ },
776
+ annotations: { readOnlyHint: true, openWorldHint: true },
777
+ }, async ({ worker_id, app }) => callTool(async () => jsonResult(await listTriggers(worker_id, app))));
778
+ server.registerTool("workspace.chat", {
779
+ title: "Chat with Workspace Agent",
780
+ description: "Send a message to the Floom workspace agent and receive a streamed reply. " +
781
+ "The agent can list workers, inspect runs, create workers, manage secrets, and more. " +
782
+ "Supply conversation_id to continue an existing conversation (enables anaphor resolution).",
783
+ inputSchema: {
784
+ message: z.string().min(1).describe("The message to send to the workspace agent."),
785
+ conversation_id: z.string().optional().describe("Optional conversation ID to continue a previous session."),
786
+ timeout_ms: z.number().optional().default(120000).describe("Maximum wait time in milliseconds."),
787
+ },
788
+ annotations: { readOnlyHint: false, openWorldHint: true },
789
+ }, async ({ message, conversation_id, timeout_ms = 120000 }) => callTool(async () => {
790
+ const parts = await consumeChatStream(message, conversation_id, timeout_ms);
791
+ return jsonResult(parts);
792
+ }));
793
+ server.registerTool("workers.write_file", {
794
+ title: "Write Worker File",
795
+ description: "Write or update source files inside a worker directory (worker.yml, SKILL.md, run.py, requirements.txt). Atomically replaces all provided files. You must include worker.yml in every call.",
796
+ inputSchema: {
797
+ id: z.string().min(1).describe("Worker ID."),
798
+ files: z.array(z.object({
799
+ path: z.string().min(1).describe("File path relative to worker root, e.g. 'SKILL.md' or 'run.py'."),
800
+ content: z.string().describe("UTF-8 file content."),
801
+ })).min(1).describe("Files to write. Must include worker.yml."),
802
+ },
803
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
804
+ }, async ({ id, files }) => callTool(async () => jsonResult(await request("PUT", `/workers/${encodeURIComponent(id)}/files`, { files }), "Worker files updated.")));
805
+ // ---------------------------------------------------------------------------
806
+ // CRITICAL: Approvals
807
+ // ---------------------------------------------------------------------------
808
+ server.registerTool("approvals.list", {
809
+ title: "List Approvals",
810
+ description: "List pending approval requests. Returns runs waiting for human approval before execution continues. Check this to see what needs a decision.",
811
+ inputSchema: {
812
+ limit: z.number().int().min(1).max(200).default(50).describe("Maximum approvals to return."),
813
+ },
814
+ annotations: { readOnlyHint: true, openWorldHint: true },
815
+ }, async ({ limit }) => callTool(async () => jsonResult(await request("GET", "/approvals", undefined, { limit }))));
816
+ server.registerTool("approvals.approve", {
817
+ title: "Approve Run",
818
+ description: "Approve a pending run so it continues executing. Use runs.get to inspect the run and its approval details before approving.",
819
+ inputSchema: {
820
+ run_id: z.string().min(1).describe("ID of the run to approve."),
821
+ comment: z.string().optional().describe("Optional comment explaining the approval decision."),
822
+ },
823
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
824
+ }, async ({ run_id, comment }) => callTool(async () => jsonResult(await request("POST", `/runs/${encodeURIComponent(run_id)}/approve`, { comment }), "Run approved.")));
825
+ server.registerTool("approvals.reject", {
826
+ title: "Reject Run",
827
+ description: "Reject a pending run, stopping it from continuing. Use runs.get to inspect before rejecting.",
828
+ inputSchema: {
829
+ run_id: z.string().min(1).describe("ID of the run to reject."),
830
+ comment: z.string().optional().describe("Optional reason for rejection."),
831
+ },
832
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
833
+ }, async ({ run_id, comment }) => callTool(async () => jsonResult(await request("POST", `/runs/${encodeURIComponent(run_id)}/reject`, { comment }), "Run rejected.")));
834
+ // ---------------------------------------------------------------------------
835
+ // CRITICAL: Run control
836
+ // ---------------------------------------------------------------------------
837
+ server.registerTool("runs.cancel", {
838
+ title: "Cancel Run",
839
+ description: "Cancel an in-progress run. Use when a run is stuck, taking too long, or was triggered by mistake.",
840
+ inputSchema: runIdSchema.shape,
841
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
842
+ }, async ({ id }) => callTool(async () => jsonResult(await request("POST", `/runs/${encodeURIComponent(id)}/cancel`), "Run cancelled.")));
843
+ server.registerTool("runs.replay", {
844
+ title: "Replay Run",
845
+ description: "Replay a completed or failed run with the same inputs. Useful for retrying after a transient error.",
846
+ inputSchema: {
847
+ worker_id: z.string().min(1).describe("Worker ID the run belongs to."),
848
+ run_id: z.string().min(1).describe("ID of the run to replay."),
849
+ },
850
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
851
+ }, async ({ worker_id, run_id }) => callTool(async () => jsonResult(await request("POST", `/workers/${encodeURIComponent(worker_id)}/runs/${encodeURIComponent(run_id)}/replay`), "Run replayed.")));
852
+ // ---------------------------------------------------------------------------
853
+ // CRITICAL: Logging
854
+ // ---------------------------------------------------------------------------
855
+ server.registerTool("workers.logs", {
856
+ title: "Get Worker Logs",
857
+ description: "Fetch cross-run logs for a worker, optionally filtered by level or time. Use to debug persistent failures without knowing a specific run ID.",
858
+ inputSchema: {
859
+ id: z.string().min(1).describe("Worker ID."),
860
+ level: z.enum(["info", "warning", "error", "debug"]).optional().describe("Filter by log level."),
861
+ since: z.string().optional().describe("ISO 8601 timestamp lower bound, e.g. 2026-06-01T00:00:00Z."),
862
+ limit: z.number().int().min(1).max(1000).default(200).describe("Maximum log entries to return."),
863
+ },
864
+ annotations: { readOnlyHint: true, openWorldHint: true },
865
+ }, async ({ id, level, since, limit }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}/logs`, undefined, { level, since, limit }))));
866
+ // ---------------------------------------------------------------------------
867
+ // CRITICAL: System health
868
+ // ---------------------------------------------------------------------------
869
+ server.registerTool("system.overview", {
870
+ title: "System Overview",
871
+ description: "Full workspace dashboard — worker health, recent run counts, pending approvals, system alerts, and scheduler status. Use for morning briefings or health checks.",
872
+ inputSchema: {},
873
+ annotations: { readOnlyHint: true, openWorldHint: true },
874
+ }, async () => callTool(async () => jsonResult(await request("GET", "/system/overview"))));
875
+ server.registerTool("system.stats", {
876
+ title: "Workspace Stats",
877
+ description: "Aggregate run statistics across the workspace for the last 7 days — total runs, success rate, error rate, worker health breakdown.",
878
+ inputSchema: {},
879
+ annotations: { readOnlyHint: true, openWorldHint: true },
880
+ }, async () => callTool(async () => jsonResult(await request("GET", "/stats"))));
881
+ // ---------------------------------------------------------------------------
882
+ // HIGH: Versioning — workers
883
+ // ---------------------------------------------------------------------------
884
+ server.registerTool("workers.versions", {
885
+ title: "List Worker Versions",
886
+ description: "List saved versions of a worker, newest first. Use before rollback to find the right version_id.",
887
+ inputSchema: {
888
+ id: z.string().min(1).describe("Worker ID."),
889
+ limit: z.number().int().min(1).max(100).default(50).describe("Maximum versions to return."),
890
+ },
891
+ annotations: { readOnlyHint: true, openWorldHint: true },
892
+ }, async ({ id, limit }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}/versions`, undefined, { limit }))));
893
+ server.registerTool("workers.rollback", {
894
+ title: "Rollback Worker",
895
+ description: "Restore a worker to a previous version. Use workers.versions to list available versions and find the version_id.",
896
+ inputSchema: {
897
+ id: z.string().min(1).describe("Worker ID."),
898
+ version_id: z.string().min(1).describe("Version ID to restore (from workers.versions)."),
899
+ },
900
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
901
+ }, async ({ id, version_id }) => callTool(async () => jsonResult(await request("POST", `/workers/${encodeURIComponent(id)}/rollback/${encodeURIComponent(version_id)}`), "Worker rolled back.")));
902
+ // ---------------------------------------------------------------------------
903
+ // HIGH: Versioning — contexts/brain packs
904
+ // ---------------------------------------------------------------------------
905
+ server.registerTool("contexts.versions", {
906
+ title: "List Context Versions",
907
+ description: "List saved versions of a brain pack context, newest first.",
908
+ inputSchema: {
909
+ name: z.string().min(1).describe("Context name."),
910
+ limit: z.number().int().min(1).max(100).default(50).describe("Maximum versions to return."),
911
+ },
912
+ annotations: { readOnlyHint: true, openWorldHint: true },
913
+ }, async ({ name, limit }) => callTool(async () => jsonResult(await request("GET", `/contexts/${encodeURIComponent(name)}/versions`, undefined, { limit }))));
914
+ server.registerTool("contexts.rollback", {
915
+ title: "Rollback Context",
916
+ description: "Restore a brain pack context to a previous version. Use contexts.versions to find the version_id.",
917
+ inputSchema: {
918
+ name: z.string().min(1).describe("Context name."),
919
+ version_id: z.string().min(1).describe("Version ID to restore."),
920
+ },
921
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
922
+ }, async ({ name, version_id }) => callTool(async () => jsonResult(await request("POST", `/contexts/${encodeURIComponent(name)}/rollback/${encodeURIComponent(version_id)}`), "Context rolled back.")));
923
+ // ---------------------------------------------------------------------------
924
+ // HIGH: Context CRUD
925
+ // ---------------------------------------------------------------------------
926
+ server.registerTool("contexts.create", {
927
+ title: "Create Context",
928
+ description: "Create a new brain pack context folder.",
929
+ inputSchema: {
930
+ name: z.string().min(1).describe("Context name (slug, e.g. 'company-docs')."),
931
+ writeable: z.boolean().default(false).describe("Whether the context is writeable by workers at runtime."),
932
+ sensitive: z.boolean().default(true).describe("Sensitive contexts are excluded from git versioning. Set false to enable versions and rollback."),
933
+ },
934
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
935
+ }, async ({ name, writeable, sensitive }) => callTool(async () => jsonResult(await request("POST", `/contexts/${encodeURIComponent(name)}`, { writeable, sensitive }), "Context created.")));
936
+ server.registerTool("contexts.delete", {
937
+ title: "Delete Context",
938
+ description: "Delete a brain pack context and all its files.",
939
+ inputSchema: {
940
+ name: z.string().min(1).describe("Context name."),
941
+ force: z.boolean().default(false).describe("Force delete even if referenced by workers."),
942
+ },
943
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
944
+ }, async ({ name, force }) => callTool(async () => jsonResult(await request("DELETE", `/contexts/${encodeURIComponent(name)}${force ? "?force=true" : ""}`), "Context deleted.")));
945
+ server.registerTool("contexts.delete_file", {
946
+ title: "Delete Context File",
947
+ description: "Delete a specific file from a brain pack context.",
948
+ inputSchema: {
949
+ name: z.string().min(1).describe("Context name."),
950
+ path: z.string().min(1).describe("File path inside the context."),
951
+ },
952
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
953
+ }, async ({ name, path }) => callTool(async () => jsonResult(await request("DELETE", `/contexts/${encodeURIComponent(name)}/files/${path.split("/").map(encodeURIComponent).join("/")}`), "Context file deleted.")));
954
+ // ---------------------------------------------------------------------------
955
+ // HIGH: Worker alerts
956
+ // ---------------------------------------------------------------------------
957
+ server.registerTool("workers.alerts.list", {
958
+ title: "List Worker Alerts",
959
+ description: "List configured alerts for a worker (email/webhook on failure, success, etc.).",
960
+ inputSchema: workerIdSchema.shape,
961
+ annotations: { readOnlyHint: true, openWorldHint: true },
962
+ }, async ({ id }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}/alerts`))));
963
+ server.registerTool("workers.alerts.create", {
964
+ title: "Create Worker Alert",
965
+ description: "Add an alert to a worker — fires on specified events (failed, completed, approval_required) via webhook or email.",
966
+ inputSchema: {
967
+ id: z.string().min(1).describe("Worker ID."),
968
+ on: z.array(z.string()).min(1).describe("Events to alert on, e.g. ['failed', 'approval_required']."),
969
+ url: z.string().optional().describe("Webhook URL to POST the alert to."),
970
+ email_to: z.array(z.string()).optional().describe("Email addresses to notify."),
971
+ },
972
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
973
+ }, async ({ id, ...body }) => callTool(async () => jsonResult(await request("POST", `/workers/${encodeURIComponent(id)}/alerts`, body), "Alert created.")));
974
+ server.registerTool("workers.alerts.delete", {
975
+ title: "Delete Worker Alert",
976
+ description: "Remove a worker alert by its ID.",
977
+ inputSchema: {
978
+ id: z.string().min(1).describe("Worker ID."),
979
+ alert_id: z.string().min(1).describe("Alert ID to delete."),
980
+ },
981
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
982
+ }, async ({ id, alert_id }) => callTool(async () => jsonResult(await request("DELETE", `/workers/${encodeURIComponent(id)}/alerts/${encodeURIComponent(alert_id)}`), "Alert deleted.")));
983
+ // ---------------------------------------------------------------------------
984
+ // HIGH: Worker lifecycle — archive / restore / stats
985
+ // ---------------------------------------------------------------------------
986
+ server.registerTool("workers.archive", {
987
+ title: "Archive Worker",
988
+ description: "Archive a worker so it no longer appears in the active list or runs on schedule. Reversible with workers.restore.",
989
+ inputSchema: workerIdSchema.shape,
990
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
991
+ }, async ({ id }) => callTool(async () => jsonResult(await request("POST", `/workers/${encodeURIComponent(id)}/archive`), "Worker archived.")));
992
+ server.registerTool("workers.restore", {
993
+ title: "Restore Worker",
994
+ description: "Restore an archived worker back to active status.",
995
+ inputSchema: workerIdSchema.shape,
996
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
997
+ }, async ({ id }) => callTool(async () => jsonResult(await request("POST", `/workers/${encodeURIComponent(id)}/restore`), "Worker restored.")));
998
+ server.registerTool("workers.stats", {
999
+ title: "Get Worker Stats",
1000
+ description: "Get run statistics for a specific worker — success rate, error rate, average duration, run counts for the last 7 days.",
1001
+ inputSchema: workerIdSchema.shape,
1002
+ annotations: { readOnlyHint: true, openWorldHint: true },
1003
+ }, async ({ id }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}/stats`))));
1004
+ // ---------------------------------------------------------------------------
1005
+ // MEDIUM: Workers — sample input, timeseries, reload
1006
+ // ---------------------------------------------------------------------------
1007
+ server.registerTool("workers.sample_input", {
1008
+ title: "Get Worker Sample Input",
1009
+ description: "Get example input values for a worker's input fields. Useful for showing a user what to fill in before running.",
1010
+ inputSchema: workerIdSchema.shape,
1011
+ annotations: { readOnlyHint: true, openWorldHint: true },
1012
+ }, async ({ id }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}/sample-input`))));
1013
+ server.registerTool("workers.timeseries", {
1014
+ title: "Get Worker Run Timeseries",
1015
+ description: "Get daily run counts and success/failure breakdown for a worker over the last N days. Useful for trend reporting.",
1016
+ inputSchema: {
1017
+ id: z.string().min(1).describe("Worker ID."),
1018
+ days: z.number().int().min(1).max(90).default(30).describe("Number of days of history."),
1019
+ },
1020
+ annotations: { readOnlyHint: true, openWorldHint: true },
1021
+ }, async ({ id, days }) => callTool(async () => jsonResult(await request("GET", `/workers/${encodeURIComponent(id)}/runs/timeseries`, undefined, { days }))));
1022
+ server.registerTool("workers.reload", {
1023
+ title: "Reload Workers",
1024
+ description: "Reload all workers from disk. Use after manually editing worker files on an OSS self-hosted deployment.",
1025
+ inputSchema: {},
1026
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1027
+ }, async () => callTool(async () => jsonResult(await request("POST", "/workers/reload"), "Workers reloaded from disk.")));
1028
+ // ---------------------------------------------------------------------------
1029
+ // MEDIUM: Secrets — test
1030
+ // ---------------------------------------------------------------------------
1031
+ server.registerTool("secrets.test", {
1032
+ title: "Test Secret",
1033
+ description: "Verify a secret exists and is reachable. Returns status without revealing the value.",
1034
+ inputSchema: {
1035
+ key: z.string().min(1).describe("Secret name to test."),
1036
+ },
1037
+ annotations: { readOnlyHint: true, openWorldHint: true },
1038
+ }, async ({ key }) => callTool(async () => jsonResult(await request("POST", `/secrets/${encodeURIComponent(key)}/test`))));
1039
+ // ---------------------------------------------------------------------------
1040
+ // MEDIUM: Connections — delete, status, test
1041
+ // ---------------------------------------------------------------------------
1042
+ server.registerTool("connections.delete", {
1043
+ title: "Delete Connection",
1044
+ description: "Remove a configured app connection.",
1045
+ inputSchema: {
1046
+ connection_id: z.string().min(1).describe("Connection ID to delete."),
1047
+ },
1048
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1049
+ }, async ({ connection_id }) => callTool(async () => jsonResult(await request("DELETE", `/connections/${encodeURIComponent(connection_id)}`), "Connection deleted.")));
1050
+ server.registerTool("connections.status", {
1051
+ title: "Get Connection Status",
1052
+ description: "Check the health and auth status of a configured connection.",
1053
+ inputSchema: {
1054
+ connection_id: z.string().min(1).describe("Connection ID."),
1055
+ },
1056
+ annotations: { readOnlyHint: true, openWorldHint: true },
1057
+ }, async ({ connection_id }) => callTool(async () => jsonResult(await request("GET", `/connections/${encodeURIComponent(connection_id)}/status`))));
1058
+ server.registerTool("connections.test", {
1059
+ title: "Test Connection",
1060
+ description: "Run a live connectivity check on a configured connection to verify the auth token is still valid.",
1061
+ inputSchema: {
1062
+ connection_id: z.string().min(1).describe("Connection ID."),
1063
+ },
1064
+ annotations: { readOnlyHint: true, openWorldHint: true },
1065
+ }, async ({ connection_id }) => callTool(async () => jsonResult(await request("POST", `/connections/${encodeURIComponent(connection_id)}/test`))));
1066
+ // ---------------------------------------------------------------------------
1067
+ // MEDIUM: Integrations catalog
1068
+ // ---------------------------------------------------------------------------
1069
+ server.registerTool("integrations.catalog", {
1070
+ title: "List Integrations Catalog",
1071
+ description: "Browse available integrations (apps, triggers, actions) supported by Floom.",
1072
+ inputSchema: {},
1073
+ annotations: { readOnlyHint: true, openWorldHint: true },
1074
+ }, async () => callTool(async () => jsonResult(await request("GET", "/integrations/catalog"))));
1075
+ // ---------------------------------------------------------------------------
1076
+ // MEDIUM: Conversations
1077
+ // ---------------------------------------------------------------------------
1078
+ server.registerTool("conversations.list", {
1079
+ title: "List Conversations",
1080
+ description: "List past workspace agent conversations.",
1081
+ inputSchema: {
1082
+ limit: z.number().int().min(1).max(100).default(20).describe("Maximum conversations to return."),
1083
+ },
1084
+ annotations: { readOnlyHint: true, openWorldHint: true },
1085
+ }, async ({ limit }) => callTool(async () => jsonResult(await request("GET", "/conversations", undefined, { limit }))));
1086
+ server.registerTool("conversations.get", {
1087
+ title: "Get Conversation",
1088
+ description: "Retrieve a full conversation history by ID.",
1089
+ inputSchema: {
1090
+ id: z.string().min(1).describe("Conversation ID."),
1091
+ },
1092
+ annotations: { readOnlyHint: true, openWorldHint: true },
1093
+ }, async ({ id }) => callTool(async () => jsonResult(await request("GET", `/conversations/${encodeURIComponent(id)}`))));
1094
+ // ---------------------------------------------------------------------------
1095
+ // MEDIUM: Workspace instructions
1096
+ // ---------------------------------------------------------------------------
1097
+ server.registerTool("workspace.instructions.get", {
1098
+ title: "Get Workspace Instructions",
1099
+ description: "Read the current workspace agent instructions (the system prompt that governs the workspace agent's behaviour).",
1100
+ inputSchema: {},
1101
+ annotations: { readOnlyHint: true, openWorldHint: true },
1102
+ }, async () => callTool(async () => jsonResult(await request("GET", "/workspace"))));
1103
+ server.registerTool("workspace.instructions.set", {
1104
+ title: "Set Workspace Instructions",
1105
+ description: "Update the workspace agent instructions. Overwrites the current content — read first with workspace.instructions.get if you want to append.",
1106
+ inputSchema: {
1107
+ content: z.string().describe("New workspace instructions markdown content."),
1108
+ },
1109
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1110
+ }, async ({ content }) => callTool(async () => {
1111
+ await request("PUT", "/workspace", { content });
1112
+ return jsonResult({ ok: true }, "Workspace instructions updated.");
1113
+ }));
1114
+ server.registerTool("workspace.versions", {
1115
+ title: "List Workspace Instruction Versions",
1116
+ description: "List saved versions of the workspace agent instructions.",
1117
+ inputSchema: {
1118
+ limit: z.number().int().min(1).max(100).default(20).describe("Maximum versions to return."),
1119
+ },
1120
+ annotations: { readOnlyHint: true, openWorldHint: true },
1121
+ }, async ({ limit }) => callTool(async () => jsonResult(await request("GET", "/workspace/versions", undefined, { limit }))));
1122
+ server.registerTool("workspace.rollback", {
1123
+ title: "Rollback Workspace Instructions",
1124
+ description: "Restore workspace agent instructions to a previous version. Use workspace.versions to find the version_id.",
1125
+ inputSchema: {
1126
+ version_id: z.string().min(1).describe("Version ID to restore."),
1127
+ },
1128
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1129
+ }, async ({ version_id }) => callTool(async () => jsonResult(await request("POST", `/workspace/rollback/${encodeURIComponent(version_id)}`), "Workspace instructions rolled back.")));
1130
+ // ---------------------------------------------------------------------------
1131
+ // MEDIUM: System info and alerts
1132
+ // ---------------------------------------------------------------------------
1133
+ server.registerTool("system.info", {
1134
+ title: "System Info",
1135
+ description: "Get platform version, deployment mode, and configuration flags.",
1136
+ inputSchema: {},
1137
+ annotations: { readOnlyHint: true, openWorldHint: true },
1138
+ }, async () => callTool(async () => jsonResult(await request("GET", "/system/info"))));
1139
+ server.registerTool("system.alerts", {
1140
+ title: "System Alerts",
1141
+ description: "Get system-wide active alerts — worker failures, scheduler issues, connection errors.",
1142
+ inputSchema: {},
1143
+ annotations: { readOnlyHint: true, openWorldHint: true },
1144
+ }, async () => callTool(async () => jsonResult(await request("GET", "/system/alerts"))));
1145
+ return server;
1146
+ }
1147
+ export async function main() {
1148
+ // #1455: resolve the active workspace once (env or creds file) so authHeader()
1149
+ // can attach x-workeros-workspace to every request, matching the CLI.
1150
+ try {
1151
+ const creds = await readCredentials();
1152
+ if (creds?.workspace_id) {
1153
+ resolvedWorkspaceId = creds.workspace_id;
1154
+ }
1155
+ }
1156
+ catch {
1157
+ // Non-fatal: fall back to the WORKEROS_WORKSPACE_ID env read in authHeader().
1158
+ }
1159
+ const server = createServer();
1160
+ const transport = new StdioServerTransport();
1161
+ await server.connect(transport);
1162
+ }
1163
+ const executedPath = process.argv[1] ? resolve(process.argv[1]) : "";
1164
+ if (executedPath && fileURLToPath(import.meta.url) === executedPath) {
1165
+ main().catch((error) => {
1166
+ const message = error instanceof Error ? error.message : String(error);
1167
+ console.error(`floom-mcp failed: ${message}`);
1168
+ process.exit(1);
1169
+ });
1170
+ }
1171
+ //# sourceMappingURL=server.js.map