@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,50 @@
1
+ import { getOAuthApp, getOAuthTokens, isTokenExpiringSoon } from "../be/db-queries/oauth";
2
+ import { type OAuthProviderConfig, refreshAccessToken } from "./wrapper";
3
+
4
+ /**
5
+ * Build an OAuthProviderConfig from the oauth_apps table for any provider.
6
+ */
7
+ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
8
+ const app = getOAuthApp(provider);
9
+ if (!app) return null;
10
+
11
+ const metadata = JSON.parse(app.metadata || "{}");
12
+ return {
13
+ provider,
14
+ clientId: app.clientId,
15
+ clientSecret: app.clientSecret,
16
+ authorizeUrl: app.authorizeUrl,
17
+ tokenUrl: app.tokenUrl,
18
+ redirectUri: app.redirectUri,
19
+ scopes: app.scopes.split(","),
20
+ extraParams: metadata.extraParams ?? (metadata.actor ? { actor: metadata.actor } : undefined),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Ensure a valid OAuth token exists for the given provider.
26
+ * If the token is expiring soon, attempt to refresh it.
27
+ * Call this before any API interaction with an OAuth-protected service.
28
+ */
29
+ export async function ensureToken(provider: string): Promise<void> {
30
+ if (!isTokenExpiringSoon(provider)) return;
31
+
32
+ const config = getOAuthConfig(provider);
33
+ const tokens = getOAuthTokens(provider);
34
+ if (!config || !tokens?.refreshToken) {
35
+ console.warn(
36
+ `[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
37
+ );
38
+ return;
39
+ }
40
+
41
+ try {
42
+ await refreshAccessToken(config, tokens.refreshToken);
43
+ console.log(`[OAuth] ${provider} token refreshed successfully`);
44
+ } catch (err) {
45
+ console.error(
46
+ `[OAuth] Failed to refresh ${provider} token:`,
47
+ err instanceof Error ? err.message : err,
48
+ );
49
+ }
50
+ }
@@ -40,6 +40,8 @@ export type BasePromptArgs = {
40
40
  };
41
41
  /** Pre-fetched skill summaries for the installed skills section */
42
42
  skillsSummary?: { name: string; description: string }[];
43
+ /** Pre-fetched MCP server summaries for the installed MCP servers section */
44
+ mcpServersSummary?: string;
43
45
  };
44
46
 
45
47
  export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
@@ -76,6 +78,11 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
76
78
  prompt += `\n\n## Installed Skills\n\nThe following skills are available. Use the Skill tool to invoke them by name.\n\n${summaries}\n`;
77
79
  }
78
80
 
81
+ // Installed MCP servers section
82
+ if (args.mcpServersSummary) {
83
+ prompt += `\n\n## Installed MCP Servers\n\nThe following MCP servers are configured for your use:\n${args.mcpServersSummary}\n`;
84
+ }
85
+
79
86
  // Repo context (protected, never truncated)
80
87
  if (args.repoContext) {
81
88
  prompt += "\n\n## Repository Context\n\n";
@@ -1,5 +1,6 @@
1
1
  import { unlink, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
+ import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
3
4
  import { validateClaudeCredentials } from "../utils/credentials";
4
5
  import {
5
6
  parseStderrForErrors,
@@ -40,14 +41,83 @@ async function cleanupTaskFile(pid: number): Promise<void> {
40
41
  }
41
42
  }
42
43
 
44
+ /** Fetch installed MCP servers from the API and return them as .mcp.json-compatible entries */
45
+ async function fetchInstalledMcpServers(
46
+ apiUrl: string,
47
+ apiKey: string,
48
+ agentId: string,
49
+ ): Promise<Record<string, Record<string, unknown>> | null> {
50
+ try {
51
+ const res = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers?resolveSecrets=true`, {
52
+ headers: {
53
+ Authorization: `Bearer ${apiKey}`,
54
+ "X-Agent-ID": agentId,
55
+ },
56
+ });
57
+ if (!res.ok) return null;
58
+
59
+ const data = (await res.json()) as {
60
+ servers: Array<{
61
+ name: string;
62
+ transport: string;
63
+ isActive: boolean;
64
+ isEnabled: boolean;
65
+ command?: string;
66
+ args?: string;
67
+ url?: string;
68
+ headers?: string;
69
+ resolvedEnv?: Record<string, string>;
70
+ resolvedHeaders?: Record<string, string>;
71
+ }>;
72
+ };
73
+
74
+ const entries: Record<string, Record<string, unknown>> = {};
75
+ for (const srv of data.servers.filter((s) => s.isActive && s.isEnabled)) {
76
+ if (srv.transport === "stdio" && srv.command) {
77
+ let args: string[] = [];
78
+ try {
79
+ args = srv.args ? JSON.parse(srv.args) : [];
80
+ } catch {
81
+ // invalid JSON — use empty args
82
+ }
83
+ entries[srv.name] = {
84
+ command: srv.command,
85
+ args,
86
+ env: srv.resolvedEnv || {},
87
+ };
88
+ } else if ((srv.transport === "http" || srv.transport === "sse") && srv.url) {
89
+ let parsedHeaders: Record<string, string> = {};
90
+ try {
91
+ parsedHeaders = srv.headers ? JSON.parse(srv.headers) : {};
92
+ } catch {
93
+ // invalid JSON — use empty headers
94
+ }
95
+ entries[srv.name] = {
96
+ type: srv.transport,
97
+ url: srv.url,
98
+ headers: { ...parsedHeaders, ...(srv.resolvedHeaders || {}) },
99
+ };
100
+ }
101
+ }
102
+ return Object.keys(entries).length > 0 ? entries : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
43
108
  /**
44
- * Create a per-session MCP config file with X-Source-Task-Id header injected.
109
+ * Create a per-session MCP config file with X-Source-Task-Id header injected
110
+ * and installed MCP servers merged in.
45
111
  * Each session gets its own copy to avoid race conditions when multiple concurrent
46
112
  * Claude sessions share the same cwd. The session-specific config is passed to
47
113
  * Claude CLI via --mcp-config, so the shared .mcp.json is never modified.
48
114
  * Returns the path to the per-session config, or null if no config exists.
49
115
  */
50
- async function createSessionMcpConfig(cwd: string, taskId: string): Promise<string | null> {
116
+ async function createSessionMcpConfig(
117
+ cwd: string,
118
+ taskId: string,
119
+ installedServers?: Record<string, Record<string, unknown>> | null,
120
+ ): Promise<string | null> {
51
121
  // Walk up from cwd to find .mcp.json (mirrors Claude CLI's project-level config discovery).
52
122
  // In Docker, .mcp.json lives at /workspace/.mcp.json but tasks often run with cwd set to
53
123
  // a subdirectory like /workspace/repos/<repo>, so a single-directory check misses it.
@@ -64,24 +134,42 @@ async function createSessionMcpConfig(cwd: string, taskId: string): Promise<stri
64
134
  searchDir = parent;
65
135
  }
66
136
 
67
- if (!mcpJsonPath) return null;
137
+ if (!mcpJsonPath && !installedServers) return null;
68
138
 
69
139
  try {
70
- const file = Bun.file(mcpJsonPath);
71
-
72
- const config = await file.json();
140
+ let config: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
141
+ if (mcpJsonPath) {
142
+ const file = Bun.file(mcpJsonPath);
143
+ config = await file.json();
144
+ }
73
145
  const servers = config?.mcpServers;
74
- if (!servers) return null;
146
+ if (!servers && !installedServers) return null;
147
+
148
+ if (!config.mcpServers) config.mcpServers = {};
75
149
 
76
150
  // Find the agent-swarm server entry (could be named "agent-swarm" or similar)
77
- const serverKey = Object.keys(servers).find(
78
- (k) => k === "agent-swarm" || servers[k]?.headers?.["X-Agent-ID"],
151
+ const serverKey = Object.keys(config.mcpServers).find(
152
+ (k) =>
153
+ k === "agent-swarm" ||
154
+ ((config.mcpServers![k] as Record<string, unknown>)?.headers &&
155
+ ((config.mcpServers![k] as Record<string, Record<string, unknown>>).headers?.[
156
+ "X-Agent-ID"
157
+ ] as unknown)),
79
158
  );
80
- if (!serverKey) return null;
159
+ if (serverKey) {
160
+ const server = config.mcpServers[serverKey] as Record<string, unknown>;
161
+ if (!server.headers) server.headers = {};
162
+ (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
163
+ }
81
164
 
82
- const server = servers[serverKey];
83
- if (!server.headers) server.headers = {};
84
- server.headers["X-Source-Task-Id"] = taskId;
165
+ // Merge installed MCP servers (don't overwrite existing entries)
166
+ if (installedServers) {
167
+ for (const [name, serverConfig] of Object.entries(installedServers)) {
168
+ if (!config.mcpServers[name]) {
169
+ config.mcpServers[name] = serverConfig;
170
+ }
171
+ }
172
+ }
85
173
 
86
174
  // Write per-session config to /tmp — no race, each session has its own file
87
175
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
@@ -102,6 +190,7 @@ class ClaudeSession implements ProviderSession {
102
190
  private completionPromise: Promise<ProviderResult>;
103
191
  private errorTracker = new SessionErrorTracker();
104
192
  private taskFilePid: number;
193
+ private contextWindowSize: number;
105
194
 
106
195
  constructor(
107
196
  private config: ProviderSessionConfig,
@@ -112,6 +201,7 @@ class ClaudeSession implements ProviderSession {
112
201
  private claudeBinary: string = "claude",
113
202
  ) {
114
203
  this.taskFilePid = taskFilePid;
204
+ this.contextWindowSize = getContextWindowSize(model);
115
205
  const cmd = this.buildCommand();
116
206
 
117
207
  console.log(
@@ -279,6 +369,19 @@ class ClaudeSession implements ProviderSession {
279
369
  if (json.type === "system" && json.subtype === "init" && json.session_id) {
280
370
  this._sessionId = json.session_id;
281
371
  this.emit({ type: "session_init", sessionId: json.session_id });
372
+ if (json.model) {
373
+ this.contextWindowSize = getContextWindowSize(json.model);
374
+ }
375
+ }
376
+
377
+ // Compaction detection
378
+ if (json.type === "system" && json.subtype === "compact_boundary" && json.compact_metadata) {
379
+ this.emit({
380
+ type: "compaction",
381
+ preCompactTokens: json.compact_metadata.pre_tokens ?? 0,
382
+ compactTrigger: json.compact_metadata.trigger ?? "auto",
383
+ contextTotalTokens: this.contextWindowSize,
384
+ });
282
385
  }
283
386
 
284
387
  // Cost data from result
@@ -312,6 +415,14 @@ class ClaudeSession implements ProviderSession {
312
415
  cost,
313
416
  isError: json.is_error || false,
314
417
  });
418
+
419
+ // Update context window size from modelUsage if available
420
+ if (json.modelUsage) {
421
+ const modelKey = Object.keys(json.modelUsage)[0];
422
+ if (modelKey && json.modelUsage[modelKey]?.contextWindow) {
423
+ this.contextWindowSize = json.modelUsage[modelKey].contextWindow;
424
+ }
425
+ }
315
426
  }
316
427
 
317
428
  // Tool use from assistant messages — emit tool_start for auto-progress
@@ -331,6 +442,21 @@ class ClaudeSession implements ProviderSession {
331
442
  }
332
443
  }
333
444
  }
445
+
446
+ // Context usage extraction from assistant message usage
447
+ if (json.message.usage) {
448
+ const usage = json.message.usage;
449
+ const contextUsed = computeContextUsed(usage);
450
+ const contextTotal = this.contextWindowSize;
451
+
452
+ this.emit({
453
+ type: "context_usage",
454
+ contextUsedTokens: contextUsed,
455
+ contextTotalTokens: contextTotal,
456
+ contextPercent: contextTotal > 0 ? (contextUsed / contextTotal) * 100 : 0,
457
+ outputTokens: usage.output_tokens ?? 0,
458
+ });
459
+ }
334
460
  }
335
461
 
336
462
  trackErrorFromJson(json, this.errorTracker);
@@ -435,8 +561,23 @@ export class ClaudeAdapter implements ProviderAdapter {
435
561
 
436
562
  console.log(`\x1b[2m[${config.role}]\x1b[0m Task file written: ${taskFilePath}`);
437
563
 
438
- // Create per-session MCP config with X-Source-Task-Id header (no shared-file race condition)
439
- const sessionMcpConfig = await createSessionMcpConfig(config.cwd, config.taskId);
564
+ // Fetch installed MCP servers from API for this agent
565
+ const installedServers =
566
+ config.apiUrl && config.apiKey && config.agentId
567
+ ? await fetchInstalledMcpServers(config.apiUrl, config.apiKey, config.agentId)
568
+ : null;
569
+ if (installedServers) {
570
+ console.log(
571
+ `\x1b[2m[${config.role}]\x1b[0m Merging ${Object.keys(installedServers).length} installed MCP server(s) into session config`,
572
+ );
573
+ }
574
+
575
+ // Create per-session MCP config with X-Source-Task-Id header + installed servers (no shared-file race condition)
576
+ const sessionMcpConfig = await createSessionMcpConfig(
577
+ config.cwd,
578
+ config.taskId,
579
+ installedServers,
580
+ );
440
581
 
441
582
  return new ClaudeSession(
442
583
  config,
@@ -389,6 +389,74 @@ export class PiMonoAdapter implements ProviderAdapter {
389
389
  } catch (err) {
390
390
  console.warn(`\x1b[33m[${config.role}] Failed to discover MCP tools: ${err}\x1b[0m`);
391
391
  }
392
+
393
+ // 2b. Discover tools from installed MCP servers (HTTP/SSE transport only)
394
+ try {
395
+ const mcpServersRes = await fetch(
396
+ `${config.apiUrl}/api/agents/${config.agentId}/mcp-servers?resolveSecrets=true`,
397
+ {
398
+ headers: {
399
+ Authorization: `Bearer ${config.apiKey}`,
400
+ "X-Agent-ID": config.agentId,
401
+ },
402
+ },
403
+ );
404
+ if (mcpServersRes.ok) {
405
+ const mcpServersData = (await mcpServersRes.json()) as {
406
+ servers: Array<{
407
+ name: string;
408
+ transport: string;
409
+ url?: string;
410
+ headers?: string;
411
+ isActive: boolean;
412
+ isEnabled: boolean;
413
+ resolvedHeaders?: Record<string, string>;
414
+ }>;
415
+ };
416
+ const httpServers = mcpServersData.servers.filter(
417
+ (s) =>
418
+ s.isActive &&
419
+ s.isEnabled &&
420
+ (s.transport === "http" || s.transport === "sse") &&
421
+ s.url,
422
+ );
423
+
424
+ for (const srv of httpServers) {
425
+ try {
426
+ const srvClient = new McpHttpClient(srv.url!, "", "");
427
+ srvClient.useRawUrl = true;
428
+ // Build custom headers from static headers + resolved secret headers
429
+ let parsedHeaders: Record<string, string> = {};
430
+ try {
431
+ parsedHeaders = srv.headers ? JSON.parse(srv.headers) : {};
432
+ } catch {
433
+ // invalid JSON
434
+ }
435
+ srvClient.customHeaders = {
436
+ ...parsedHeaders,
437
+ ...(srv.resolvedHeaders || {}),
438
+ };
439
+ await srvClient.initialize();
440
+ const srvTools = await srvClient.listTools();
441
+ // Prefix tool names with mcp__<server-name>__ to avoid conflicts
442
+ const prefixed = mcpToolsToDefinitions(srvClient, srvTools).map((t) => ({
443
+ ...t,
444
+ name: `mcp__${srv.name}__${t.name}`,
445
+ }));
446
+ customTools.push(...prefixed);
447
+ console.log(
448
+ `\x1b[2m[${config.role}]\x1b[0m Discovered ${srvTools.length} tools from MCP server "${srv.name}"`,
449
+ );
450
+ } catch (srvErr) {
451
+ console.warn(
452
+ `\x1b[33m[${config.role}] Failed to discover tools from MCP server "${srv.name}": ${srvErr}\x1b[0m`,
453
+ );
454
+ }
455
+ }
456
+ }
457
+ } catch {
458
+ // Non-fatal — installed MCP server tool discovery is optional
459
+ }
392
460
  }
393
461
 
394
462
  // 3. Resolve model
@@ -383,6 +383,8 @@ ${transcript}`;
383
383
  */
384
384
  export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFactory {
385
385
  return (pi) => {
386
+ let lastContextPostTime = 0;
387
+
386
388
  // === session_start → SessionStart ===
387
389
  pi.on("session_start", async (_event, _ctx) => {
388
390
  // Ping server
@@ -479,7 +481,7 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
479
481
  });
480
482
 
481
483
  // === tool_result → PostToolUse ===
482
- pi.on("tool_result", async (event, _ctx) => {
484
+ pi.on("tool_result", async (event, ctx) => {
483
485
  // Heartbeat (workers only, fire-and-forget)
484
486
  if (!config.isLead && config.taskId) {
485
487
  fireAndForget(`${config.apiUrl}/api/active-sessions/heartbeat/${config.taskId}`, {
@@ -494,6 +496,26 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
494
496
  headers: apiHeaders(config),
495
497
  });
496
498
 
499
+ // Throttled context usage reporting (every 30s)
500
+ const usage = ctx.getContextUsage?.();
501
+ if (config.taskId && usage?.tokens != null) {
502
+ const now = Date.now();
503
+ if (now - lastContextPostTime >= 30_000) {
504
+ lastContextPostTime = now;
505
+ fireAndForget(`${config.apiUrl}/api/tasks/${config.taskId}/context`, {
506
+ method: "POST",
507
+ headers: apiHeaders(config),
508
+ body: JSON.stringify({
509
+ eventType: "progress",
510
+ sessionId: `pi-${config.taskId}`,
511
+ contextUsedTokens: usage.tokens,
512
+ contextTotalTokens: usage.contextWindow,
513
+ contextPercent: usage.percent,
514
+ }),
515
+ });
516
+ }
517
+ }
518
+
497
519
  // Shared disk write failure detection (Archil only — safety net)
498
520
  if (process.env.ARCHIL_MOUNT_TOKEN) {
499
521
  // Pi-mono uses lowercase tool names
@@ -562,7 +584,7 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
562
584
  // === context → PreCompact ===
563
585
  // The context event allows injecting messages before compaction.
564
586
  // We log the goal reminder to console (it gets captured in context).
565
- pi.on("context", async (_event, _ctx) => {
587
+ pi.on("context", async (_event, ctx) => {
566
588
  if (!config.taskId) return undefined;
567
589
 
568
590
  try {
@@ -583,6 +605,22 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
583
605
  /* don't block compaction */
584
606
  }
585
607
 
608
+ // Report context usage as a compaction event
609
+ const usage = ctx.getContextUsage?.();
610
+ if (usage) {
611
+ fireAndForget(`${config.apiUrl}/api/tasks/${config.taskId}/context`, {
612
+ method: "POST",
613
+ headers: apiHeaders(config),
614
+ body: JSON.stringify({
615
+ eventType: "compaction",
616
+ sessionId: `pi-${config.taskId}`,
617
+ contextUsedTokens: usage.tokens ?? undefined,
618
+ contextTotalTokens: usage.contextWindow,
619
+ contextPercent: usage.percent ?? undefined,
620
+ }),
621
+ });
622
+ }
623
+
586
624
  return undefined;
587
625
  });
588
626
 
@@ -604,6 +642,22 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
604
642
 
605
643
  // === session_shutdown → Stop ===
606
644
  pi.on("session_shutdown", async (_event, ctx) => {
645
+ // Post final context usage before shutdown
646
+ const usage = ctx.getContextUsage?.();
647
+ if (config.taskId && usage) {
648
+ await fetch(`${config.apiUrl}/api/tasks/${config.taskId}/context`, {
649
+ method: "POST",
650
+ headers: apiHeaders(config),
651
+ body: JSON.stringify({
652
+ eventType: "completion",
653
+ sessionId: `pi-${config.taskId}`,
654
+ contextTotalTokens: usage.contextWindow,
655
+ contextPercent: usage.percent ?? undefined,
656
+ contextUsedTokens: usage.tokens ?? undefined,
657
+ }),
658
+ }).catch(() => {});
659
+ }
660
+
607
661
  // Sync identity files and setup script
608
662
  await syncIdentityFilesToServer(config);
609
663
  await syncSetupScriptToServer(config);
@@ -20,6 +20,13 @@ interface McpToolCallResult {
20
20
  export class McpHttpClient {
21
21
  private sessionId: string | null = null;
22
22
  private nextId = 1;
23
+ /** Additional headers merged into every request (e.g. for installed MCP servers) */
24
+ public customHeaders: Record<string, string> = {};
25
+ /**
26
+ * When true, baseUrl is used as-is for requests (external MCP servers).
27
+ * When false (default), /mcp is appended (swarm convention).
28
+ */
29
+ public useRawUrl = false;
23
30
 
24
31
  constructor(
25
32
  private baseUrl: string,
@@ -34,6 +41,7 @@ export class McpHttpClient {
34
41
  Accept: "application/json, text/event-stream",
35
42
  Authorization: `Bearer ${this.apiKey}`,
36
43
  "X-Agent-ID": this.agentId,
44
+ ...this.customHeaders,
37
45
  };
38
46
  if (this.taskId) {
39
47
  headers["X-Source-Task-Id"] = this.taskId;
@@ -42,7 +50,8 @@ export class McpHttpClient {
42
50
  headers["mcp-session-id"] = this.sessionId;
43
51
  }
44
52
 
45
- const res = await fetch(`${this.baseUrl}/mcp`, {
53
+ const url = this.useRawUrl ? this.baseUrl : `${this.baseUrl}/mcp`;
54
+ const res = await fetch(url, {
46
55
  method: "POST",
47
56
  headers,
48
57
  body: JSON.stringify(body),
@@ -24,7 +24,20 @@ export type ProviderEvent =
24
24
  | { type: "error"; message: string; category?: string }
25
25
  | { type: "raw_log"; content: string }
26
26
  | { type: "raw_stderr"; content: string }
27
- | { type: "custom"; name: string; data: unknown };
27
+ | { type: "custom"; name: string; data: unknown }
28
+ | {
29
+ type: "context_usage";
30
+ contextUsedTokens: number;
31
+ contextTotalTokens: number;
32
+ contextPercent: number;
33
+ outputTokens: number;
34
+ }
35
+ | {
36
+ type: "compaction";
37
+ preCompactTokens: number;
38
+ compactTrigger: "auto" | "manual";
39
+ contextTotalTokens: number;
40
+ };
28
41
 
29
42
  /** Configuration passed to a provider adapter to create a session. */
30
43
  export interface ProviderSessionConfig {
package/src/server.ts CHANGED
@@ -25,6 +25,16 @@ import { registerJoinSwarmTool } from "./tools/join-swarm";
25
25
  // Messaging capability
26
26
  import { registerListChannelsTool } from "./tools/list-channels";
27
27
  import { registerListServicesTool } from "./tools/list-services";
28
+ // MCP Servers capability
29
+ import {
30
+ registerMcpServerCreateTool,
31
+ registerMcpServerDeleteTool,
32
+ registerMcpServerGetTool,
33
+ registerMcpServerInstallTool,
34
+ registerMcpServerListTool,
35
+ registerMcpServerUninstallTool,
36
+ registerMcpServerUpdateTool,
37
+ } from "./tools/mcp-servers";
28
38
  // Memory capability
29
39
  import { registerMemoryGetTool } from "./tools/memory-get";
30
40
  import { registerMemorySearchTool } from "./tools/memory-search";
@@ -272,5 +282,14 @@ export function createServer() {
272
282
  registerSkillSyncRemoteTool(server);
273
283
  registerSkillPublishTool(server);
274
284
 
285
+ // MCP Servers - always registered
286
+ registerMcpServerCreateTool(server);
287
+ registerMcpServerUpdateTool(server);
288
+ registerMcpServerDeleteTool(server);
289
+ registerMcpServerGetTool(server);
290
+ registerMcpServerListTool(server);
291
+ registerMcpServerInstallTool(server);
292
+ registerMcpServerUninstallTool(server);
293
+
275
294
  return server;
276
295
  }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
3
+
4
+ describe("getContextWindowSize", () => {
5
+ test("returns 1M for opus models", () => {
6
+ expect(getContextWindowSize("claude-opus-4-6")).toBe(1_000_000);
7
+ expect(getContextWindowSize("opus")).toBe(1_000_000);
8
+ });
9
+
10
+ test("returns 1M for sonnet models", () => {
11
+ expect(getContextWindowSize("claude-sonnet-4-6")).toBe(1_000_000);
12
+ expect(getContextWindowSize("sonnet")).toBe(1_000_000);
13
+ });
14
+
15
+ test("returns 200K for haiku models", () => {
16
+ expect(getContextWindowSize("claude-haiku-4-5")).toBe(200_000);
17
+ expect(getContextWindowSize("haiku")).toBe(200_000);
18
+ });
19
+
20
+ test("returns 200K default for unknown models", () => {
21
+ expect(getContextWindowSize("gpt-5")).toBe(200_000);
22
+ expect(getContextWindowSize("unknown-model")).toBe(200_000);
23
+ expect(getContextWindowSize("")).toBe(200_000);
24
+ });
25
+
26
+ test("returns default entry value", () => {
27
+ expect(getContextWindowSize("default")).toBe(200_000);
28
+ });
29
+ });
30
+
31
+ describe("computeContextUsed", () => {
32
+ test("sums all token fields", () => {
33
+ expect(
34
+ computeContextUsed({
35
+ input_tokens: 1000,
36
+ cache_creation_input_tokens: 500,
37
+ cache_read_input_tokens: 200,
38
+ }),
39
+ ).toBe(1700);
40
+ });
41
+
42
+ test("handles missing fields as zero", () => {
43
+ expect(computeContextUsed({})).toBe(0);
44
+ expect(computeContextUsed({ input_tokens: 100 })).toBe(100);
45
+ expect(computeContextUsed({ cache_read_input_tokens: 50 })).toBe(50);
46
+ });
47
+
48
+ test("handles null fields as zero", () => {
49
+ expect(
50
+ computeContextUsed({
51
+ input_tokens: null,
52
+ cache_creation_input_tokens: null,
53
+ cache_read_input_tokens: null,
54
+ }),
55
+ ).toBe(0);
56
+ });
57
+
58
+ test("handles mix of values, nulls, and missing", () => {
59
+ expect(
60
+ computeContextUsed({
61
+ input_tokens: 5000,
62
+ cache_creation_input_tokens: null,
63
+ }),
64
+ ).toBe(5000);
65
+ });
66
+ });