@desplega.ai/agent-swarm 1.52.1 → 1.53.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 (43) hide show
  1. package/openapi.json +1517 -488
  2. package/package.json +5 -2
  3. package/src/be/db.ts +530 -0
  4. package/src/be/events.ts +322 -0
  5. package/src/be/migrations/021_events.sql +24 -0
  6. package/src/be/migrations/022_context_usage.sql +34 -0
  7. package/src/be/migrations/023_mcp_servers.sql +44 -0
  8. package/src/commands/runner.ts +348 -1
  9. package/src/http/context.ts +118 -0
  10. package/src/http/events.ts +188 -0
  11. package/src/http/index.ts +6 -0
  12. package/src/http/mcp-servers.ts +364 -0
  13. package/src/http/tasks.ts +33 -0
  14. package/src/linear/outbound.ts +8 -1
  15. package/src/linear/sync.ts +3 -0
  16. package/src/oauth/ensure-token.ts +50 -0
  17. package/src/prompts/base-prompt.ts +7 -0
  18. package/src/providers/claude-adapter.ts +156 -15
  19. package/src/providers/pi-mono-adapter.ts +68 -0
  20. package/src/providers/pi-mono-extension.ts +56 -2
  21. package/src/providers/pi-mono-mcp-client.ts +10 -1
  22. package/src/providers/types.ts +14 -1
  23. package/src/server.ts +19 -0
  24. package/src/tests/context-window.test.ts +66 -0
  25. package/src/tests/ensure-token.test.ts +170 -0
  26. package/src/tests/events-db.test.ts +314 -0
  27. package/src/tests/events-http.test.ts +267 -0
  28. package/src/tests/prompt-template-remaining.test.ts +5 -5
  29. package/src/tests/tool-annotations.test.ts +2 -2
  30. package/src/tests/vcs-tracking.test.ts +176 -0
  31. package/src/tests/workflow-executors.test.ts +8 -1
  32. package/src/tools/mcp-servers/index.ts +7 -0
  33. package/src/tools/mcp-servers/mcp-server-create.ts +138 -0
  34. package/src/tools/mcp-servers/mcp-server-delete.ts +72 -0
  35. package/src/tools/mcp-servers/mcp-server-get.ts +80 -0
  36. package/src/tools/mcp-servers/mcp-server-install.ts +110 -0
  37. package/src/tools/mcp-servers/mcp-server-list.ts +67 -0
  38. package/src/tools/mcp-servers/mcp-server-uninstall.ts +71 -0
  39. package/src/tools/mcp-servers/mcp-server-update.ts +120 -0
  40. package/src/tools/tool-config.ts +9 -0
  41. package/src/types.ts +153 -0
  42. package/src/utils/context-window.ts +41 -0
  43. package/src/workflows/executors/base.ts +9 -1
@@ -0,0 +1,188 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ createEvent,
5
+ createEventsBatch,
6
+ getEventCountsFiltered,
7
+ getEventsFiltered,
8
+ } from "../be/events";
9
+ import {
10
+ EventCategorySchema,
11
+ EventNameSchema,
12
+ EventSourceSchema,
13
+ EventStatusSchema,
14
+ } from "../types";
15
+ import { route } from "./route-def";
16
+ import { json, jsonError } from "./utils";
17
+
18
+ // ─── Route Definitions ───────────────────────────────────────────────────────
19
+
20
+ const eventBodySchema = z.object({
21
+ category: EventCategorySchema,
22
+ event: EventNameSchema,
23
+ status: EventStatusSchema.optional(),
24
+ source: EventSourceSchema,
25
+ agentId: z.string().optional(),
26
+ taskId: z.string().optional(),
27
+ sessionId: z.string().optional(),
28
+ parentEventId: z.string().optional(),
29
+ numericValue: z.number().optional(),
30
+ durationMs: z.number().int().optional(),
31
+ data: z.record(z.string(), z.unknown()).optional(),
32
+ });
33
+
34
+ const createEventRoute = route({
35
+ method: "post",
36
+ path: "/api/events",
37
+ pattern: ["api", "events"],
38
+ summary: "Store a single event",
39
+ tags: ["Events"],
40
+ body: eventBodySchema,
41
+ responses: {
42
+ 201: { description: "Event stored" },
43
+ 400: { description: "Validation error" },
44
+ },
45
+ auth: { apiKey: true },
46
+ });
47
+
48
+ const createEventsBatchRoute = route({
49
+ method: "post",
50
+ path: "/api/events/batch",
51
+ pattern: ["api", "events", "batch"],
52
+ summary: "Store multiple events in a batch",
53
+ tags: ["Events"],
54
+ body: z.object({
55
+ events: z.array(eventBodySchema).min(1).max(500),
56
+ }),
57
+ responses: {
58
+ 201: { description: "Events stored" },
59
+ 400: { description: "Validation error" },
60
+ },
61
+ auth: { apiKey: true },
62
+ });
63
+
64
+ const getEventsRoute = route({
65
+ method: "get",
66
+ path: "/api/events",
67
+ pattern: ["api", "events"],
68
+ summary: "Query events with filters",
69
+ tags: ["Events"],
70
+ query: z.object({
71
+ category: EventCategorySchema.optional(),
72
+ event: EventNameSchema.optional(),
73
+ status: EventStatusSchema.optional(),
74
+ source: EventSourceSchema.optional(),
75
+ agentId: z.string().optional(),
76
+ taskId: z.string().optional(),
77
+ sessionId: z.string().optional(),
78
+ since: z.string().optional(),
79
+ until: z.string().optional(),
80
+ limit: z.coerce.number().int().min(1).max(1000).optional(),
81
+ }),
82
+ responses: {
83
+ 200: { description: "List of events" },
84
+ },
85
+ auth: { apiKey: true },
86
+ });
87
+
88
+ const getEventCountsRoute = route({
89
+ method: "get",
90
+ path: "/api/events/counts",
91
+ pattern: ["api", "events", "counts"],
92
+ summary: "Get event counts grouped by event name",
93
+ tags: ["Events"],
94
+ query: z.object({
95
+ category: EventCategorySchema.optional(),
96
+ source: EventSourceSchema.optional(),
97
+ agentId: z.string().optional(),
98
+ taskId: z.string().optional(),
99
+ sessionId: z.string().optional(),
100
+ since: z.string().optional(),
101
+ until: z.string().optional(),
102
+ }),
103
+ responses: {
104
+ 200: { description: "Event counts" },
105
+ },
106
+ auth: { apiKey: true },
107
+ });
108
+
109
+ // ─── Handler ─────────────────────────────────────────────────────────────────
110
+
111
+ export async function handleEvents(
112
+ req: IncomingMessage,
113
+ res: ServerResponse,
114
+ pathSegments: string[],
115
+ queryParams: URLSearchParams,
116
+ _myAgentId: string | undefined,
117
+ ): Promise<boolean> {
118
+ // Match batch BEFORE generic /api/events (POST)
119
+ if (createEventsBatchRoute.match(req.method, pathSegments)) {
120
+ const parsed = await createEventsBatchRoute.parse(req, res, pathSegments, queryParams);
121
+ if (!parsed) return true;
122
+
123
+ try {
124
+ const count = createEventsBatch(parsed.body.events);
125
+ json(res, { success: true, count }, 201);
126
+ } catch (error) {
127
+ console.error("[HTTP] Failed to create events batch:", error);
128
+ jsonError(res, "Failed to store events batch", 500);
129
+ }
130
+ return true;
131
+ }
132
+
133
+ // Match counts BEFORE generic /api/events (GET)
134
+ if (getEventCountsRoute.match(req.method, pathSegments)) {
135
+ const parsed = await getEventCountsRoute.parse(req, res, pathSegments, queryParams);
136
+ if (!parsed) return true;
137
+
138
+ const counts = getEventCountsFiltered({
139
+ category: parsed.query.category || undefined,
140
+ source: parsed.query.source || undefined,
141
+ agentId: parsed.query.agentId || undefined,
142
+ taskId: parsed.query.taskId || undefined,
143
+ sessionId: parsed.query.sessionId || undefined,
144
+ since: parsed.query.since || undefined,
145
+ until: parsed.query.until || undefined,
146
+ });
147
+ json(res, { counts });
148
+ return true;
149
+ }
150
+
151
+ // POST /api/events — single event
152
+ if (createEventRoute.match(req.method, pathSegments)) {
153
+ const parsed = await createEventRoute.parse(req, res, pathSegments, queryParams);
154
+ if (!parsed) return true;
155
+
156
+ try {
157
+ const event = createEvent(parsed.body);
158
+ json(res, { success: true, event }, 201);
159
+ } catch (error) {
160
+ console.error("[HTTP] Failed to create event:", error);
161
+ jsonError(res, "Failed to store event", 500);
162
+ }
163
+ return true;
164
+ }
165
+
166
+ // GET /api/events — filtered query
167
+ if (getEventsRoute.match(req.method, pathSegments)) {
168
+ const parsed = await getEventsRoute.parse(req, res, pathSegments, queryParams);
169
+ if (!parsed) return true;
170
+
171
+ const events = getEventsFiltered({
172
+ category: parsed.query.category || undefined,
173
+ event: parsed.query.event || undefined,
174
+ status: parsed.query.status || undefined,
175
+ source: parsed.query.source || undefined,
176
+ agentId: parsed.query.agentId || undefined,
177
+ taskId: parsed.query.taskId || undefined,
178
+ sessionId: parsed.query.sessionId || undefined,
179
+ since: parsed.query.since || undefined,
180
+ until: parsed.query.until || undefined,
181
+ limit: parsed.query.limit ?? 100,
182
+ });
183
+ json(res, { events });
184
+ return true;
185
+ }
186
+
187
+ return false;
188
+ }
package/src/http/index.ts CHANGED
@@ -19,11 +19,14 @@ import { handleActiveSessions } from "./active-sessions";
19
19
  import { handleAgentRegister, handleAgentsRest } from "./agents";
20
20
  import { handleApprovalRequests } from "./approval-requests";
21
21
  import { handleConfig } from "./config";
22
+ import { handleContext } from "./context";
22
23
  import { handleCore, loadGlobalConfigsIntoEnv } from "./core";
23
24
  import { handleDbQuery } from "./db-query";
24
25
  import { handleEcosystem } from "./ecosystem";
25
26
  import { handleEpics } from "./epics";
27
+ import { handleEvents } from "./events";
26
28
  import { handleMcp } from "./mcp";
29
+ import { handleMcpServers } from "./mcp-servers";
27
30
  import { handleMemory } from "./memory";
28
31
  import { handlePoll } from "./poll";
29
32
  import { handlePromptTemplates } from "./prompt-templates";
@@ -102,6 +105,7 @@ const httpServer = createHttpServer(async (req, res) => {
102
105
  () => handleTrackers(req, res, pathSegments),
103
106
  () => handleWebhooks(req, res, pathSegments),
104
107
  () => handleAgentsRest(req, res, pathSegments, queryParams, myAgentId),
108
+ () => handleContext(req, res, pathSegments, queryParams, myAgentId),
105
109
  () => handleTasks(req, res, pathSegments, queryParams, myAgentId),
106
110
  () => handleStats(req, res, pathSegments, queryParams),
107
111
  () => handleActiveSessions(req, res, pathSegments, queryParams, myAgentId),
@@ -114,7 +118,9 @@ const httpServer = createHttpServer(async (req, res) => {
114
118
  () => handleDbQuery(req, res, pathSegments, queryParams),
115
119
  () => handleRepos(req, res, pathSegments, queryParams),
116
120
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
121
+ () => handleMcpServers(req, res, pathSegments, queryParams),
117
122
  () => handleMemory(req, res, pathSegments, myAgentId),
123
+ () => handleEvents(req, res, pathSegments, queryParams, myAgentId),
118
124
  () => handleMcp(req, res, transports),
119
125
  ];
120
126
 
@@ -0,0 +1,364 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ createMcpServer,
5
+ deleteMcpServer,
6
+ getAgentMcpServers,
7
+ getMcpServerById,
8
+ getResolvedConfig,
9
+ installMcpServer,
10
+ listMcpServers,
11
+ uninstallMcpServer,
12
+ updateMcpServer,
13
+ } from "../be/db";
14
+ import { route } from "./route-def";
15
+ import { json, jsonError } from "./utils";
16
+
17
+ // ─── Route Definitions ───────────────────────────────────────────────────────
18
+
19
+ const listMcpServersRoute = route({
20
+ method: "get",
21
+ path: "/api/mcp-servers",
22
+ pattern: ["api", "mcp-servers"],
23
+ summary: "List MCP servers with optional filters",
24
+ tags: ["MCP Servers"],
25
+ auth: { apiKey: true },
26
+ query: z.object({
27
+ scope: z.string().optional(),
28
+ transport: z.string().optional(),
29
+ ownerAgentId: z.string().optional(),
30
+ enabled: z.string().optional(),
31
+ search: z.string().optional(),
32
+ }),
33
+ responses: {
34
+ 200: { description: "MCP server list" },
35
+ },
36
+ });
37
+
38
+ const getMcpServerRoute = route({
39
+ method: "get",
40
+ path: "/api/mcp-servers/{id}",
41
+ pattern: ["api", "mcp-servers", null],
42
+ summary: "Get MCP server by ID",
43
+ tags: ["MCP Servers"],
44
+ auth: { apiKey: true },
45
+ params: z.object({ id: z.string() }),
46
+ responses: {
47
+ 200: { description: "MCP server details" },
48
+ 404: { description: "MCP server not found" },
49
+ },
50
+ });
51
+
52
+ const createMcpServerRoute = route({
53
+ method: "post",
54
+ path: "/api/mcp-servers",
55
+ pattern: ["api", "mcp-servers"],
56
+ summary: "Create a new MCP server",
57
+ tags: ["MCP Servers"],
58
+ auth: { apiKey: true },
59
+ body: z.object({
60
+ name: z.string().min(1),
61
+ transport: z.enum(["stdio", "http", "sse"]),
62
+ description: z.string().optional(),
63
+ scope: z.string().optional(),
64
+ ownerAgentId: z.string().optional(),
65
+ command: z.string().optional(),
66
+ args: z.string().optional(),
67
+ url: z.string().optional(),
68
+ headers: z.string().optional(),
69
+ envConfigKeys: z.string().optional(),
70
+ headerConfigKeys: z.string().optional(),
71
+ }),
72
+ responses: {
73
+ 201: { description: "MCP server created" },
74
+ 400: { description: "Validation error" },
75
+ },
76
+ });
77
+
78
+ const updateMcpServerRoute = route({
79
+ method: "put",
80
+ path: "/api/mcp-servers/{id}",
81
+ pattern: ["api", "mcp-servers", null],
82
+ summary: "Update an MCP server",
83
+ tags: ["MCP Servers"],
84
+ auth: { apiKey: true },
85
+ params: z.object({ id: z.string() }),
86
+ body: z.record(z.string(), z.unknown()),
87
+ responses: {
88
+ 200: { description: "MCP server updated" },
89
+ 404: { description: "MCP server not found" },
90
+ },
91
+ });
92
+
93
+ const deleteMcpServerRoute = route({
94
+ method: "delete",
95
+ path: "/api/mcp-servers/{id}",
96
+ pattern: ["api", "mcp-servers", null],
97
+ summary: "Delete an MCP server",
98
+ tags: ["MCP Servers"],
99
+ auth: { apiKey: true },
100
+ params: z.object({ id: z.string() }),
101
+ responses: {
102
+ 200: { description: "MCP server deleted" },
103
+ 404: { description: "MCP server not found" },
104
+ },
105
+ });
106
+
107
+ const installMcpServerRoute = route({
108
+ method: "post",
109
+ path: "/api/mcp-servers/{id}/install",
110
+ pattern: ["api", "mcp-servers", null, "install"],
111
+ summary: "Install MCP server for an agent",
112
+ tags: ["MCP Servers"],
113
+ auth: { apiKey: true },
114
+ params: z.object({ id: z.string() }),
115
+ body: z.object({
116
+ agentId: z.string(),
117
+ }),
118
+ responses: {
119
+ 200: { description: "MCP server installed" },
120
+ 404: { description: "MCP server not found" },
121
+ },
122
+ });
123
+
124
+ const uninstallMcpServerRoute = route({
125
+ method: "delete",
126
+ path: "/api/mcp-servers/{id}/install/{agentId}",
127
+ pattern: ["api", "mcp-servers", null, "install", null],
128
+ summary: "Uninstall MCP server for an agent",
129
+ tags: ["MCP Servers"],
130
+ auth: { apiKey: true },
131
+ params: z.object({ id: z.string(), agentId: z.string() }),
132
+ responses: {
133
+ 200: { description: "MCP server uninstalled" },
134
+ },
135
+ });
136
+
137
+ const getAgentMcpServersRoute = route({
138
+ method: "get",
139
+ path: "/api/agents/{id}/mcp-servers",
140
+ pattern: ["api", "agents", null, "mcp-servers"],
141
+ summary: "Get all MCP servers installed for an agent",
142
+ tags: ["MCP Servers"],
143
+ auth: { apiKey: true },
144
+ params: z.object({ id: z.string() }),
145
+ query: z.object({
146
+ resolveSecrets: z.string().optional(),
147
+ }),
148
+ responses: {
149
+ 200: { description: "Agent MCP servers list" },
150
+ },
151
+ });
152
+
153
+ // ─── Handler ─────────────────────────────────────────────────────────────────
154
+
155
+ export async function handleMcpServers(
156
+ req: IncomingMessage,
157
+ res: ServerResponse,
158
+ pathSegments: string[],
159
+ queryParams: URLSearchParams,
160
+ ): Promise<boolean> {
161
+ // GET /api/agents/:id/mcp-servers (must be before /api/mcp-servers routes)
162
+ if (getAgentMcpServersRoute.match(req.method, pathSegments)) {
163
+ const parsed = await getAgentMcpServersRoute.parse(req, res, pathSegments, queryParams);
164
+ if (!parsed) return true;
165
+
166
+ const servers = getAgentMcpServers(parsed.params.id);
167
+ const resolveSecrets = parsed.query.resolveSecrets === "true";
168
+
169
+ if (resolveSecrets) {
170
+ const configs = getResolvedConfig(parsed.params.id);
171
+ const configMap = new Map(configs.map((c) => [c.key, c.value]));
172
+
173
+ const serversWithSecrets = servers.map((server) => {
174
+ const resolvedEnv: Record<string, string> = {};
175
+ const resolvedHeaders: Record<string, string> = {};
176
+
177
+ // Resolve env config keys (JSON object: {"ENV_VAR": "config-key-name"})
178
+ if (server.envConfigKeys) {
179
+ try {
180
+ const mapping = JSON.parse(server.envConfigKeys) as Record<string, string>;
181
+ for (const [envVar, configKey] of Object.entries(mapping)) {
182
+ const value = configMap.get(configKey);
183
+ if (value !== undefined) {
184
+ resolvedEnv[envVar] = value;
185
+ }
186
+ }
187
+ } catch {
188
+ // Invalid JSON — skip resolution
189
+ }
190
+ }
191
+
192
+ // Resolve header config keys (JSON object: {"Header-Name": "config-key-name"})
193
+ if (server.headerConfigKeys) {
194
+ try {
195
+ const mapping = JSON.parse(server.headerConfigKeys) as Record<string, string>;
196
+ for (const [headerName, configKey] of Object.entries(mapping)) {
197
+ const value = configMap.get(configKey);
198
+ if (value !== undefined) {
199
+ resolvedHeaders[headerName] = value;
200
+ }
201
+ }
202
+ } catch {
203
+ // Invalid JSON — skip resolution
204
+ }
205
+ }
206
+
207
+ return { ...server, resolvedEnv, resolvedHeaders };
208
+ });
209
+
210
+ json(res, { servers: serversWithSecrets, total: serversWithSecrets.length });
211
+ } else {
212
+ json(res, { servers, total: servers.length });
213
+ }
214
+ return true;
215
+ }
216
+
217
+ // POST /api/mcp-servers/:id/install
218
+ if (installMcpServerRoute.match(req.method, pathSegments)) {
219
+ const parsed = await installMcpServerRoute.parse(req, res, pathSegments, queryParams);
220
+ if (!parsed) return true;
221
+
222
+ const server = getMcpServerById(parsed.params.id);
223
+ if (!server) {
224
+ jsonError(res, "MCP server not found", 404);
225
+ return true;
226
+ }
227
+
228
+ try {
229
+ const agentMcpServer = installMcpServer(parsed.body.agentId, parsed.params.id);
230
+ json(res, { agentMcpServer });
231
+ } catch (err) {
232
+ jsonError(res, err instanceof Error ? err.message : "Install failed", 400);
233
+ }
234
+ return true;
235
+ }
236
+
237
+ // DELETE /api/mcp-servers/:id/install/:agentId
238
+ if (uninstallMcpServerRoute.match(req.method, pathSegments)) {
239
+ const parsed = await uninstallMcpServerRoute.parse(req, res, pathSegments, queryParams);
240
+ if (!parsed) return true;
241
+
242
+ const removed = uninstallMcpServer(parsed.params.agentId, parsed.params.id);
243
+ json(res, { success: removed });
244
+ return true;
245
+ }
246
+
247
+ // GET /api/mcp-servers
248
+ if (listMcpServersRoute.match(req.method, pathSegments)) {
249
+ const parsed = await listMcpServersRoute.parse(req, res, pathSegments, queryParams);
250
+ if (!parsed) return true;
251
+
252
+ const servers = listMcpServers({
253
+ scope: parsed.query.scope as "global" | "swarm" | "agent" | undefined,
254
+ transport: parsed.query.transport as "stdio" | "http" | "sse" | undefined,
255
+ ownerAgentId: parsed.query.ownerAgentId,
256
+ isEnabled: parsed.query.enabled !== undefined ? parsed.query.enabled === "true" : undefined,
257
+ search: parsed.query.search,
258
+ });
259
+
260
+ json(res, { servers, total: servers.length });
261
+ return true;
262
+ }
263
+
264
+ // GET /api/mcp-servers/:id
265
+ if (getMcpServerRoute.match(req.method, pathSegments)) {
266
+ const parsed = await getMcpServerRoute.parse(req, res, pathSegments, queryParams);
267
+ if (!parsed) return true;
268
+
269
+ const server = getMcpServerById(parsed.params.id);
270
+ if (!server) {
271
+ jsonError(res, "MCP server not found", 404);
272
+ return true;
273
+ }
274
+ json(res, server);
275
+ return true;
276
+ }
277
+
278
+ // POST /api/mcp-servers
279
+ if (createMcpServerRoute.match(req.method, pathSegments)) {
280
+ const parsed = await createMcpServerRoute.parse(req, res, pathSegments, queryParams);
281
+ if (!parsed) return true;
282
+
283
+ // Transport-specific validation
284
+ if (parsed.body.transport === "stdio" && !parsed.body.command) {
285
+ jsonError(res, "command is required for stdio transport", 400);
286
+ return true;
287
+ }
288
+ if ((parsed.body.transport === "http" || parsed.body.transport === "sse") && !parsed.body.url) {
289
+ jsonError(res, "url is required for http/sse transport", 400);
290
+ return true;
291
+ }
292
+
293
+ try {
294
+ const server = createMcpServer({
295
+ name: parsed.body.name,
296
+ transport: parsed.body.transport,
297
+ description: parsed.body.description,
298
+ scope: parsed.body.scope as "global" | "swarm" | "agent" | undefined,
299
+ ownerAgentId: parsed.body.ownerAgentId,
300
+ command: parsed.body.command,
301
+ args: parsed.body.args,
302
+ url: parsed.body.url,
303
+ headers: parsed.body.headers,
304
+ envConfigKeys: parsed.body.envConfigKeys,
305
+ headerConfigKeys: parsed.body.headerConfigKeys,
306
+ });
307
+ json(res, { server }, 201);
308
+ } catch (err) {
309
+ jsonError(res, err instanceof Error ? err.message : "Create failed", 400);
310
+ }
311
+ return true;
312
+ }
313
+
314
+ // PUT /api/mcp-servers/:id
315
+ if (updateMcpServerRoute.match(req.method, pathSegments)) {
316
+ const parsed = await updateMcpServerRoute.parse(req, res, pathSegments, queryParams);
317
+ if (!parsed) return true;
318
+
319
+ // Transport-specific validation on update (only if transport is being set)
320
+ const transport = parsed.body.transport as string | undefined;
321
+ if (transport === "stdio" && parsed.body.command === undefined) {
322
+ // Check if existing server already has a command
323
+ const existing = getMcpServerById(parsed.params.id);
324
+ if (existing && !existing.command && !parsed.body.command) {
325
+ jsonError(res, "command is required for stdio transport", 400);
326
+ return true;
327
+ }
328
+ }
329
+ if ((transport === "http" || transport === "sse") && parsed.body.url === undefined) {
330
+ const existing = getMcpServerById(parsed.params.id);
331
+ if (existing && !existing.url && !parsed.body.url) {
332
+ jsonError(res, "url is required for http/sse transport", 400);
333
+ return true;
334
+ }
335
+ }
336
+
337
+ const server = updateMcpServer(
338
+ parsed.params.id,
339
+ parsed.body as Parameters<typeof updateMcpServer>[1],
340
+ );
341
+ if (!server) {
342
+ jsonError(res, "MCP server not found", 404);
343
+ return true;
344
+ }
345
+ json(res, { server });
346
+ return true;
347
+ }
348
+
349
+ // DELETE /api/mcp-servers/:id
350
+ if (deleteMcpServerRoute.match(req.method, pathSegments)) {
351
+ const parsed = await deleteMcpServerRoute.parse(req, res, pathSegments, queryParams);
352
+ if (!parsed) return true;
353
+
354
+ const deleted = deleteMcpServer(parsed.params.id);
355
+ if (!deleted) {
356
+ jsonError(res, "MCP server not found", 404);
357
+ return true;
358
+ }
359
+ json(res, { success: true });
360
+ return true;
361
+ }
362
+
363
+ return false;
364
+ }
package/src/http/tasks.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  updateAgentStatusFromCapacity,
18
18
  updateTaskClaudeSessionId,
19
19
  updateTaskProgress,
20
+ updateTaskVcs,
20
21
  } from "../be/db";
21
22
  import { route } from "./route-def";
22
23
  import { json, jsonError } from "./utils";
@@ -187,6 +188,26 @@ const resumeTaskRoute = route({
187
188
  },
188
189
  });
189
190
 
191
+ const updateTaskVcsRoute = route({
192
+ method: "patch",
193
+ path: "/api/tasks/{id}/vcs",
194
+ pattern: ["api", "tasks", null, "vcs"],
195
+ summary: "Update VCS (PR/MR) info for a task",
196
+ tags: ["Tasks"],
197
+ params: z.object({ id: z.string() }),
198
+ body: z.object({
199
+ vcsProvider: z.enum(["github", "gitlab"]),
200
+ vcsRepo: z.string(),
201
+ vcsNumber: z.number().int().positive(),
202
+ vcsUrl: z.string().url(),
203
+ }),
204
+ responses: {
205
+ 200: { description: "VCS info updated" },
206
+ 404: { description: "Task not found" },
207
+ },
208
+ auth: { apiKey: true },
209
+ });
210
+
190
211
  // ─── Handler ─────────────────────────────────────────────────────────────────
191
212
 
192
213
  export async function handleTasks(
@@ -515,6 +536,18 @@ export async function handleTasks(
515
536
  return true;
516
537
  }
517
538
 
539
+ if (updateTaskVcsRoute.match(req.method, pathSegments)) {
540
+ const parsed = await updateTaskVcsRoute.parse(req, res, pathSegments, queryParams);
541
+ if (!parsed) return true;
542
+ const task = updateTaskVcs(parsed.params.id, parsed.body);
543
+ if (!task) {
544
+ jsonError(res, "Task not found", 404);
545
+ return true;
546
+ }
547
+ json(res, task);
548
+ return true;
549
+ }
550
+
518
551
  if (resumeTaskRoute.match(req.method, pathSegments)) {
519
552
  const parsed = await resumeTaskRoute.parse(req, res, pathSegments, queryParams);
520
553
  if (!parsed) return true;
@@ -1,6 +1,7 @@
1
1
  import { getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
2
+ import { ensureToken } from "../oauth/ensure-token";
2
3
  import { workflowEventBus } from "../workflows/event-bus";
3
- import { getLinearClient } from "./client";
4
+ import { getLinearClient, resetLinearClient } from "./client";
4
5
  import { endAgentSession, postAgentSessionAction, taskSessionMap } from "./sync";
5
6
 
6
7
  let subscribed = false;
@@ -85,6 +86,8 @@ async function handleTaskCompleted(data: unknown): Promise<void> {
85
86
  } else {
86
87
  // No session — fall back to issue comment
87
88
  try {
89
+ await ensureToken("linear");
90
+ resetLinearClient(); // Clear cached client so it picks up refreshed token
88
91
  const client = getLinearClient();
89
92
  if (!client) {
90
93
  console.log("[Linear Outbound] No Linear client available, skipping sync for", taskId);
@@ -133,6 +136,8 @@ async function handleTaskFailed(data: unknown): Promise<void> {
133
136
  } else {
134
137
  // No session — fall back to issue comment
135
138
  try {
139
+ await ensureToken("linear");
140
+ resetLinearClient(); // Clear cached client so it picks up refreshed token
136
141
  const client = getLinearClient();
137
142
  if (!client) {
138
143
  console.log("[Linear Outbound] No Linear client available, skipping sync for", taskId);
@@ -180,6 +185,8 @@ async function handleTaskCancelled(data: unknown): Promise<void> {
180
185
  console.log(`[Linear Outbound] Posted cancellation to AgentSession for task ${taskId}`);
181
186
  } else {
182
187
  try {
188
+ await ensureToken("linear");
189
+ resetLinearClient(); // Clear cached client so it picks up refreshed token
183
190
  const client = getLinearClient();
184
191
  if (!client) {
185
192
  console.log("[Linear Outbound] No Linear client available, skipping sync for", taskId);
@@ -6,6 +6,7 @@ import {
6
6
  getTrackerSyncByExternalId,
7
7
  updateTrackerSync,
8
8
  } from "../be/db-queries/tracker";
9
+ import { ensureToken } from "../oauth/ensure-token";
9
10
  import { resolveTemplate } from "../prompts/resolver";
10
11
  // Side-effect import: registers all Linear event templates in the in-memory registry
11
12
  import "./templates";
@@ -46,6 +47,7 @@ async function postAgentActivity(
46
47
  | { type: "thought" | "response" | "error"; body: string }
47
48
  | { type: "action"; action: string; parameter?: string; result?: string },
48
49
  ): Promise<boolean> {
50
+ await ensureToken("linear");
49
51
  const tokens = getOAuthTokens("linear");
50
52
  if (!tokens) {
51
53
  console.log("[Linear Sync] No OAuth tokens, cannot post AgentSession activity");
@@ -96,6 +98,7 @@ async function updateAgentSession(
96
98
  sessionId: string,
97
99
  input: Record<string, unknown>,
98
100
  ): Promise<boolean> {
101
+ await ensureToken("linear");
99
102
  const tokens = getOAuthTokens("linear");
100
103
  if (!tokens) {
101
104
  console.log("[Linear Sync] No OAuth tokens, cannot update AgentSession");