apteva 0.2.8 → 0.2.9

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.
package/src/routes/api.ts CHANGED
@@ -2,7 +2,7 @@ import { spawn } from "bun";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import { mkdirSync, existsSync, rmSync } from "fs";
5
- import { agentProcesses, BINARY_PATH, getNextPort, getBinaryStatus, BIN_DIR, telemetryBroadcaster, type TelemetryEvent } from "../server";
5
+ import { agentProcesses, agentsStarting, BINARY_PATH, getNextPort, getBinaryStatus, BIN_DIR, telemetryBroadcaster, type TelemetryEvent } from "../server";
6
6
  import { AgentDB, McpServerDB, TelemetryDB, UserDB, ProjectDB, generateId, type Agent, type AgentFeatures, type McpServer, type Project } from "../db";
7
7
  import { ProviderKeys, Onboarding, getProvidersWithStatus, PROVIDERS, type ProviderId } from "../providers";
8
8
  import { createUser, hashPassword, validatePassword } from "../auth";
@@ -23,7 +23,14 @@ import {
23
23
  callMcpTool,
24
24
  getMcpProcess,
25
25
  getMcpProxyUrl,
26
+ getHttpMcpClient,
26
27
  } from "../mcp-client";
28
+ import { openApiSpec } from "../openapi";
29
+ import { getProvider, getProviderIds, registerProvider } from "../integrations";
30
+ import { ComposioProvider } from "../integrations/composio";
31
+
32
+ // Register integration providers
33
+ registerProvider(ComposioProvider);
27
34
 
28
35
  // Data directory for agent instances (in ~/.apteva/agents/)
29
36
  const AGENTS_DATA_DIR = process.env.DATA_DIR
@@ -43,6 +50,7 @@ function debug(...args: unknown[]) {
43
50
  }
44
51
 
45
52
  // Wait for agent to be healthy (with timeout)
53
+ // Note: /health endpoint is whitelisted in agent, no auth needed
46
54
  async function waitForAgentHealth(port: number, maxAttempts = 30, delayMs = 200): Promise<boolean> {
47
55
  for (let i = 0; i < maxAttempts; i++) {
48
56
  try {
@@ -58,41 +66,56 @@ async function waitForAgentHealth(port: number, maxAttempts = 30, delayMs = 200)
58
66
  return false;
59
67
  }
60
68
 
69
+ // Make authenticated request to agent
70
+ async function agentFetch(
71
+ agentId: string,
72
+ port: number,
73
+ endpoint: string,
74
+ options: RequestInit = {}
75
+ ): Promise<Response> {
76
+ const apiKey = AgentDB.getApiKey(agentId);
77
+ const headers: Record<string, string> = {
78
+ ...(options.headers as Record<string, string> || {}),
79
+ };
80
+ if (apiKey) {
81
+ headers["X-API-Key"] = apiKey;
82
+ }
83
+ return fetch(`http://localhost:${port}${endpoint}`, {
84
+ ...options,
85
+ headers,
86
+ });
87
+ }
88
+
61
89
  // Build agent config from apteva agent data
62
90
  // Note: POST /config expects flat structure WITHOUT "agent" wrapper
63
91
  function buildAgentConfig(agent: Agent, providerKey: string) {
64
92
  const features = agent.features;
65
93
 
66
94
  // Get MCP server details for the agent's selected servers
67
- // Supports both local servers and Composio configs (prefixed with "composio:")
68
95
  const mcpServers: Array<{ name: string; type: "http"; url: string; headers: Record<string, string>; enabled: boolean }> = [];
69
96
 
70
97
  for (const id of agent.mcp_servers || []) {
71
- // Check if this is a Composio config
72
- if (id.startsWith("composio:")) {
73
- const configId = id.slice(9); // Remove "composio:" prefix
74
- const composioKey = ProviderKeys.getDecrypted("composio");
75
- if (composioKey) {
76
- mcpServers.push({
77
- name: `composio-${configId.slice(0, 8)}`,
78
- type: "http",
79
- url: `https://backend.composio.dev/v3/mcp/${configId}`,
80
- headers: { "x-api-key": composioKey },
81
- enabled: true,
82
- });
83
- }
84
- } else {
85
- // Local MCP server
86
- const server = McpServerDB.findById(id);
87
- if (server && server.status === "running" && server.port) {
88
- mcpServers.push({
89
- name: server.name,
90
- type: "http",
91
- url: `http://localhost:${server.port}/mcp`,
92
- headers: {},
93
- enabled: true,
94
- });
95
- }
98
+ const server = McpServerDB.findById(id);
99
+ if (!server) continue;
100
+
101
+ if (server.type === "http" && server.url) {
102
+ // Remote HTTP server (Composio, Smithery, or custom)
103
+ mcpServers.push({
104
+ name: server.name,
105
+ type: "http",
106
+ url: server.url,
107
+ headers: server.headers || {},
108
+ enabled: true,
109
+ });
110
+ } else if (server.status === "running" && server.port) {
111
+ // Local MCP server (npm, github, custom)
112
+ mcpServers.push({
113
+ name: server.name,
114
+ type: "http",
115
+ url: `http://localhost:${server.port}/mcp`,
116
+ headers: {},
117
+ enabled: true,
118
+ });
96
119
  }
97
120
  }
98
121
 
@@ -196,9 +219,10 @@ function buildAgentConfig(agent: Agent, providerKey: string) {
196
219
  }
197
220
 
198
221
  // Push config to running agent
199
- async function pushConfigToAgent(port: number, config: any): Promise<{ success: boolean; error?: string }> {
222
+ // Push config to running agent (with authentication)
223
+ async function pushConfigToAgent(agentId: string, port: number, config: any): Promise<{ success: boolean; error?: string }> {
200
224
  try {
201
- const res = await fetch(`http://localhost:${port}/config`, {
225
+ const res = await agentFetch(agentId, port, "/config", {
202
226
  method: "POST",
203
227
  headers: { "Content-Type": "application/json" },
204
228
  body: JSON.stringify(config),
@@ -217,38 +241,85 @@ async function pushConfigToAgent(port: number, config: any): Promise<{ success:
217
241
  // Exported helper to start an agent process (used by API route and auto-restart)
218
242
  export async function startAgentProcess(
219
243
  agent: Agent,
220
- options: { silent?: boolean } = {}
244
+ options: { silent?: boolean; cleanData?: boolean } = {}
221
245
  ): Promise<{ success: boolean; port?: number; error?: string }> {
222
- const { silent = false } = options;
246
+ const { silent = false, cleanData = false } = options;
223
247
 
224
248
  // Check if binary exists
225
249
  if (!binaryExists(BIN_DIR)) {
226
250
  return { success: false, error: "Agent binary not available" };
227
251
  }
228
252
 
229
- // Check if already running
253
+ // Check if already running (process map)
230
254
  if (agentProcesses.has(agent.id)) {
231
255
  return { success: false, error: "Agent already running" };
232
256
  }
233
257
 
258
+ // Check if already being started (race condition prevention)
259
+ if (agentsStarting.has(agent.id)) {
260
+ return { success: false, error: "Agent is already starting" };
261
+ }
262
+
263
+ // Mark as starting
264
+ agentsStarting.add(agent.id);
265
+
234
266
  // Get the API key for the agent's provider
235
267
  const providerKey = ProviderKeys.getDecrypted(agent.provider);
236
268
  if (!providerKey) {
269
+ agentsStarting.delete(agent.id);
237
270
  return { success: false, error: `No API key for provider: ${agent.provider}` };
238
271
  }
239
272
 
240
273
  // Get provider config for env var name
241
274
  const providerConfig = PROVIDERS[agent.provider as ProviderId];
242
275
  if (!providerConfig) {
276
+ agentsStarting.delete(agent.id);
243
277
  return { success: false, error: `Unknown provider: ${agent.provider}` };
244
278
  }
245
279
 
246
- // Assign port
247
- const port = getNextPort();
280
+ // Use agent's permanently assigned port
281
+ const port = agent.port;
282
+ if (!port) {
283
+ agentsStarting.delete(agent.id);
284
+ return { success: false, error: "Agent has no assigned port" };
285
+ }
286
+
287
+ // Get or create API key for the agent
288
+ const agentApiKey = AgentDB.ensureApiKey(agent.id);
289
+ if (!agentApiKey) {
290
+ agentsStarting.delete(agent.id);
291
+ return { success: false, error: "Failed to get/create agent API key" };
292
+ }
248
293
 
249
294
  try {
250
- // Create data directory for this agent
295
+ // Check if something is already running on this port (orphaned process)
296
+ try {
297
+ const res = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(500) });
298
+ if (res.ok) {
299
+ // Something is running - try to shut it down
300
+ if (!silent) {
301
+ console.log(` Port ${port} in use, stopping orphaned process...`);
302
+ }
303
+ try {
304
+ await fetch(`http://localhost:${port}/shutdown`, { method: "POST", signal: AbortSignal.timeout(1000) });
305
+ await new Promise(r => setTimeout(r, 500)); // Wait for shutdown
306
+ } catch {
307
+ // Shutdown failed - process might not support it
308
+ }
309
+ }
310
+ } catch {
311
+ // Port is free - good
312
+ }
313
+
314
+ // Handle data directory
251
315
  const agentDataDir = join(AGENTS_DATA_DIR, agent.id);
316
+ if (cleanData && existsSync(agentDataDir)) {
317
+ // Clean old data if requested
318
+ rmSync(agentDataDir, { recursive: true, force: true });
319
+ if (!silent) {
320
+ console.log(` Cleaned old data directory`);
321
+ }
322
+ }
252
323
  if (!existsSync(agentDataDir)) {
253
324
  mkdirSync(agentDataDir, { recursive: true });
254
325
  }
@@ -259,11 +330,12 @@ export async function startAgentProcess(
259
330
  console.log(` Data dir: ${agentDataDir}`);
260
331
  }
261
332
 
262
- // Build environment with provider key
333
+ // Build environment with provider key and agent API key
263
334
  const env: Record<string, string> = {
264
335
  ...process.env as Record<string, string>,
265
336
  PORT: String(port),
266
337
  DATA_DIR: agentDataDir,
338
+ AGENT_API_KEY: agentApiKey,
267
339
  [providerConfig.envVar]: providerKey,
268
340
  };
269
341
 
@@ -274,7 +346,8 @@ export async function startAgentProcess(
274
346
  stderr: "ignore",
275
347
  });
276
348
 
277
- agentProcesses.set(agent.id, proc);
349
+ // Store process with port for tracking
350
+ agentProcesses.set(agent.id, { proc, port });
278
351
 
279
352
  // Wait for agent to be healthy
280
353
  if (!silent) {
@@ -287,6 +360,7 @@ export async function startAgentProcess(
287
360
  }
288
361
  proc.kill();
289
362
  agentProcesses.delete(agent.id);
363
+ agentsStarting.delete(agent.id);
290
364
  return { success: false, error: "Health check timeout" };
291
365
  }
292
366
 
@@ -295,7 +369,7 @@ export async function startAgentProcess(
295
369
  console.log(` Pushing configuration...`);
296
370
  }
297
371
  const config = buildAgentConfig(agent, providerKey);
298
- const configResult = await pushConfigToAgent(port, config);
372
+ const configResult = await pushConfigToAgent(agent.id, port, config);
299
373
  if (!configResult.success) {
300
374
  if (!silent) {
301
375
  console.error(` Failed to configure agent: ${configResult.error}`);
@@ -305,15 +379,17 @@ export async function startAgentProcess(
305
379
  console.log(` Configuration applied successfully`);
306
380
  }
307
381
 
308
- // Update status in database
309
- AgentDB.setStatus(agent.id, "running", port);
382
+ // Update status in database (port is already set, just update status)
383
+ AgentDB.setStatus(agent.id, "running");
310
384
 
311
385
  if (!silent) {
312
386
  console.log(`Agent ${agent.name} started on port ${port} (pid: ${proc.pid})`);
313
387
  }
314
388
 
389
+ agentsStarting.delete(agent.id);
315
390
  return { success: true, port };
316
391
  } catch (err) {
392
+ agentsStarting.delete(agent.id);
317
393
  if (!silent) {
318
394
  console.error(`Failed to start agent: ${err}`);
319
395
  }
@@ -379,6 +455,11 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
379
455
  });
380
456
  }
381
457
 
458
+ // GET /api/openapi - OpenAPI spec (no auth required)
459
+ if (path === "/api/openapi" && method === "GET") {
460
+ return json(openApiSpec);
461
+ }
462
+
382
463
  // GET /api/agents - List all agents
383
464
  if (path === "/api/agents" && method === "GET") {
384
465
  const agents = AgentDB.findAll();
@@ -452,7 +533,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
452
533
  const providerKey = ProviderKeys.getDecrypted(updated.provider);
453
534
  if (providerKey) {
454
535
  const config = buildAgentConfig(updated, providerKey);
455
- const configResult = await pushConfigToAgent(updated.port, config);
536
+ const configResult = await pushConfigToAgent(updated.id, updated.port, config);
456
537
  if (!configResult.success) {
457
538
  console.error(`Failed to push config to running agent: ${configResult.error}`);
458
539
  }
@@ -474,9 +555,9 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
474
555
  }
475
556
 
476
557
  // Stop the agent if running
477
- const proc = agentProcesses.get(agentId);
478
- if (proc) {
479
- proc.kill();
558
+ const agentProc = agentProcesses.get(agentId);
559
+ if (agentProc) {
560
+ agentProc.proc.kill();
480
561
  agentProcesses.delete(agentId);
481
562
  }
482
563
 
@@ -498,6 +579,46 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
498
579
  return json({ success: true });
499
580
  }
500
581
 
582
+ // GET /api/agents/:id/api-key - Get the agent's API key (masked)
583
+ const apiKeyGetMatch = path.match(/^\/api\/agents\/([^/]+)\/api-key$/);
584
+ if (apiKeyGetMatch && method === "GET") {
585
+ const agent = AgentDB.findById(apiKeyGetMatch[1]);
586
+ if (!agent) {
587
+ return json({ error: "Agent not found" }, 404);
588
+ }
589
+
590
+ const apiKey = AgentDB.getApiKey(agent.id);
591
+ if (!apiKey) {
592
+ return json({ error: "No API key found for this agent" }, 404);
593
+ }
594
+
595
+ // Return masked key (show only first 8 chars)
596
+ const masked = apiKey.substring(0, 8) + "..." + apiKey.substring(apiKey.length - 4);
597
+ return json({
598
+ apiKey: masked,
599
+ hasKey: true,
600
+ });
601
+ }
602
+
603
+ // POST /api/agents/:id/api-key - Regenerate the agent's API key
604
+ if (apiKeyGetMatch && method === "POST") {
605
+ const agent = AgentDB.findById(apiKeyGetMatch[1]);
606
+ if (!agent) {
607
+ return json({ error: "Agent not found" }, 404);
608
+ }
609
+
610
+ const newKey = AgentDB.regenerateApiKey(agent.id);
611
+ if (!newKey) {
612
+ return json({ error: "Failed to regenerate API key" }, 500);
613
+ }
614
+
615
+ // Return the full new key (only time it's fully visible)
616
+ return json({
617
+ apiKey: newKey,
618
+ message: "API key regenerated. This is the only time the full key will be shown.",
619
+ });
620
+ }
621
+
501
622
  // POST /api/agents/:id/start - Start an agent
502
623
  const startMatch = path.match(/^\/api\/agents\/([^/]+)\/start$/);
503
624
  if (startMatch && method === "POST") {
@@ -523,10 +644,10 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
523
644
  return json({ error: "Agent not found" }, 404);
524
645
  }
525
646
 
526
- const proc = agentProcesses.get(agent.id);
527
- if (proc) {
528
- console.log(`Stopping agent ${agent.name} (pid: ${proc.pid})...`);
529
- proc.kill();
647
+ const agentProc = agentProcesses.get(agent.id);
648
+ if (agentProc) {
649
+ console.log(`Stopping agent ${agent.name} (pid: ${agentProc.proc.pid})...`);
650
+ agentProc.proc.kill();
530
651
  agentProcesses.delete(agent.id);
531
652
  }
532
653
 
@@ -549,13 +670,10 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
549
670
  try {
550
671
  const body = await req.json();
551
672
 
552
- // Proxy to the agent's /chat endpoint
553
- const agentUrl = `http://localhost:${agent.port}/chat`;
554
- const response = await fetch(agentUrl, {
673
+ // Proxy to the agent's /chat endpoint with authentication
674
+ const response = await agentFetch(agent.id, agent.port, "/chat", {
555
675
  method: "POST",
556
- headers: {
557
- "Content-Type": "application/json",
558
- },
676
+ headers: { "Content-Type": "application/json" },
559
677
  body: JSON.stringify(body),
560
678
  });
561
679
 
@@ -595,8 +713,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
595
713
  }
596
714
 
597
715
  try {
598
- const agentUrl = `http://localhost:${agent.port}/threads`;
599
- const response = await fetch(agentUrl, {
716
+ const response = await agentFetch(agent.id, agent.port, "/threads", {
600
717
  method: "GET",
601
718
  headers: { "Accept": "application/json" },
602
719
  });
@@ -627,8 +744,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
627
744
 
628
745
  try {
629
746
  const body = await req.json().catch(() => ({}));
630
- const agentUrl = `http://localhost:${agent.port}/threads`;
631
- const response = await fetch(agentUrl, {
747
+ const response = await agentFetch(agent.id, agent.port, "/threads", {
632
748
  method: "POST",
633
749
  headers: { "Content-Type": "application/json" },
634
750
  body: JSON.stringify(body),
@@ -661,8 +777,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
661
777
 
662
778
  try {
663
779
  const threadId = threadDetailMatch[2];
664
- const agentUrl = `http://localhost:${agent.port}/threads/${threadId}`;
665
- const response = await fetch(agentUrl, {
780
+ const response = await agentFetch(agent.id, agent.port, `/threads/${threadId}`, {
666
781
  method: "GET",
667
782
  headers: { "Accept": "application/json" },
668
783
  });
@@ -693,8 +808,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
693
808
 
694
809
  try {
695
810
  const threadId = threadDetailMatch[2];
696
- const agentUrl = `http://localhost:${agent.port}/threads/${threadId}`;
697
- const response = await fetch(agentUrl, {
811
+ const response = await agentFetch(agent.id, agent.port, `/threads/${threadId}`, {
698
812
  method: "DELETE",
699
813
  });
700
814
 
@@ -724,8 +838,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
724
838
 
725
839
  try {
726
840
  const threadId = threadMessagesMatch[2];
727
- const agentUrl = `http://localhost:${agent.port}/threads/${threadId}/messages`;
728
- const response = await fetch(agentUrl, {
841
+ const response = await agentFetch(agent.id, agent.port, `/threads/${threadId}/messages`, {
729
842
  method: "GET",
730
843
  headers: { "Accept": "application/json" },
731
844
  });
@@ -760,8 +873,8 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
760
873
  try {
761
874
  const url = new URL(req.url);
762
875
  const threadId = url.searchParams.get("thread_id") || "";
763
- const agentUrl = `http://localhost:${agent.port}/memories${threadId ? `?thread_id=${threadId}` : ""}`;
764
- const response = await fetch(agentUrl, {
876
+ const endpoint = `/memories${threadId ? `?thread_id=${threadId}` : ""}`;
877
+ const response = await agentFetch(agent.id, agent.port, endpoint, {
765
878
  method: "GET",
766
879
  headers: { "Accept": "application/json" },
767
880
  });
@@ -791,8 +904,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
791
904
  }
792
905
 
793
906
  try {
794
- const agentUrl = `http://localhost:${agent.port}/memories`;
795
- const response = await fetch(agentUrl, { method: "DELETE" });
907
+ const response = await agentFetch(agent.id, agent.port, "/memories", { method: "DELETE" });
796
908
 
797
909
  if (!response.ok) {
798
910
  const errorText = await response.text();
@@ -820,8 +932,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
820
932
 
821
933
  try {
822
934
  const memoryId = memoryDeleteMatch[2];
823
- const agentUrl = `http://localhost:${agent.port}/memories/${memoryId}`;
824
- const response = await fetch(agentUrl, { method: "DELETE" });
935
+ const response = await agentFetch(agent.id, agent.port, `/memories/${memoryId}`, { method: "DELETE" });
825
936
 
826
937
  if (!response.ok) {
827
938
  const errorText = await response.text();
@@ -855,8 +966,8 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
855
966
  if (url.searchParams.get("thread_id")) params.set("thread_id", url.searchParams.get("thread_id")!);
856
967
  if (url.searchParams.get("limit")) params.set("limit", url.searchParams.get("limit")!);
857
968
 
858
- const agentUrl = `http://localhost:${agent.port}/files${params.toString() ? `?${params}` : ""}`;
859
- const response = await fetch(agentUrl, {
969
+ const endpoint = `/files${params.toString() ? `?${params}` : ""}`;
970
+ const response = await agentFetch(agent.id, agent.port, endpoint, {
860
971
  method: "GET",
861
972
  headers: { "Accept": "application/json" },
862
973
  });
@@ -888,8 +999,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
888
999
 
889
1000
  try {
890
1001
  const fileId = fileGetMatch[2];
891
- const agentUrl = `http://localhost:${agent.port}/files/${fileId}`;
892
- const response = await fetch(agentUrl, {
1002
+ const response = await agentFetch(agent.id, agent.port, `/files/${fileId}`, {
893
1003
  method: "GET",
894
1004
  headers: { "Accept": "application/json" },
895
1005
  });
@@ -920,8 +1030,9 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
920
1030
 
921
1031
  try {
922
1032
  const fileId = fileGetMatch[2];
923
- const agentUrl = `http://localhost:${agent.port}/files/${fileId}`;
924
- const response = await fetch(agentUrl, { method: "DELETE" });
1033
+ const response = await agentFetch(agent.id, agent.port, `/files/${fileId}`, {
1034
+ method: "DELETE",
1035
+ });
925
1036
 
926
1037
  if (!response.ok) {
927
1038
  const errorText = await response.text();
@@ -949,8 +1060,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
949
1060
 
950
1061
  try {
951
1062
  const fileId = fileDownloadMatch[2];
952
- const agentUrl = `http://localhost:${agent.port}/files/${fileId}/download`;
953
- const response = await fetch(agentUrl);
1063
+ const response = await agentFetch(agent.id, agent.port, `/files/${fileId}/download`);
954
1064
 
955
1065
  if (!response.ok) {
956
1066
  const errorText = await response.text();
@@ -987,8 +1097,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
987
1097
  }
988
1098
 
989
1099
  try {
990
- const agentUrl = `http://localhost:${agent.port}/discovery/agents`;
991
- const response = await fetch(agentUrl, {
1100
+ const response = await agentFetch(agent.id, agent.port, "/discovery/agents", {
992
1101
  method: "GET",
993
1102
  headers: { "Accept": "application/json" },
994
1103
  });
@@ -1385,13 +1494,19 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1385
1494
 
1386
1495
  // POST /api/version/update - Download/install latest agent binary
1387
1496
  if (path === "/api/version/update" && method === "POST") {
1388
- // Check if any agents are running
1497
+ // Get all running agents to restart later
1389
1498
  const runningAgents = AgentDB.findAll().filter(a => a.status === "running");
1390
- if (runningAgents.length > 0) {
1391
- return json(
1392
- { success: false, error: "Cannot update while agents are running. Stop all agents first." },
1393
- { status: 400 }
1394
- );
1499
+ const agentsToRestart = runningAgents.map(a => a.id);
1500
+
1501
+ // Stop all running agents
1502
+ for (const agent of runningAgents) {
1503
+ const agentProc = agentProcesses.get(agent.id);
1504
+ if (agentProc) {
1505
+ console.log(`Stopping agent ${agent.name} for update...`);
1506
+ agentProc.proc.kill();
1507
+ agentProcesses.delete(agent.id);
1508
+ }
1509
+ AgentDB.setStatus(agent.id, "stopped");
1395
1510
  }
1396
1511
 
1397
1512
  // Try npm install first, fall back to direct download
@@ -1401,10 +1516,31 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1401
1516
  result = await downloadLatestBinary(BIN_DIR);
1402
1517
  }
1403
1518
 
1404
- if (result.success) {
1405
- return json({ success: true, version: result.version });
1519
+ if (!result.success) {
1520
+ return json({ success: false, error: result.error }, 500);
1521
+ }
1522
+
1523
+ // Restart agents that were running
1524
+ const restartResults: { id: string; name: string; success: boolean; error?: string }[] = [];
1525
+ for (const agentId of agentsToRestart) {
1526
+ const agent = AgentDB.findById(agentId);
1527
+ if (agent) {
1528
+ console.log(`Restarting agent ${agent.name} after update...`);
1529
+ const startResult = await startAgentProcess(agent);
1530
+ restartResults.push({
1531
+ id: agent.id,
1532
+ name: agent.name,
1533
+ success: startResult.success,
1534
+ error: startResult.error,
1535
+ });
1536
+ }
1406
1537
  }
1407
- return json({ success: false, error: result.error }, { status: 500 });
1538
+
1539
+ return json({
1540
+ success: true,
1541
+ version: result.version,
1542
+ restarted: restartResults,
1543
+ });
1408
1544
  }
1409
1545
 
1410
1546
  // GET /api/health - Health check
@@ -1429,10 +1565,10 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1429
1565
 
1430
1566
  // ==================== TASKS ====================
1431
1567
 
1432
- // Helper to fetch from a running agent
1433
- async function fetchFromAgent(port: number, endpoint: string): Promise<any> {
1568
+ // Helper to fetch from a running agent (with authentication)
1569
+ async function fetchFromAgent(agentId: string, port: number, endpoint: string): Promise<any> {
1434
1570
  try {
1435
- const response = await fetch(`http://localhost:${port}${endpoint}`, {
1571
+ const response = await agentFetch(agentId, port, endpoint, {
1436
1572
  headers: { "Accept": "application/json" },
1437
1573
  });
1438
1574
  if (response.ok) {
@@ -1453,7 +1589,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1453
1589
  const allTasks: any[] = [];
1454
1590
 
1455
1591
  for (const agent of runningAgents) {
1456
- const data = await fetchFromAgent(agent.port!, `/tasks?status=${status}`);
1592
+ const data = await fetchFromAgent(agent.id, agent.port!, `/tasks?status=${status}`);
1457
1593
  if (data?.tasks) {
1458
1594
  // Add agent info to each task
1459
1595
  for (const task of data.tasks) {
@@ -1489,7 +1625,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1489
1625
  const url = new URL(req.url);
1490
1626
  const status = url.searchParams.get("status") || "all";
1491
1627
 
1492
- const data = await fetchFromAgent(agent.port, `/tasks?status=${status}`);
1628
+ const data = await fetchFromAgent(agent.id, agent.port, `/tasks?status=${status}`);
1493
1629
  if (!data) {
1494
1630
  return json({ error: "Failed to fetch tasks from agent" }, 500);
1495
1631
  }
@@ -1508,7 +1644,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1508
1644
  let runningTasks = 0;
1509
1645
 
1510
1646
  for (const agent of runningAgents) {
1511
- const data = await fetchFromAgent(agent.port!, "/tasks?status=all");
1647
+ const data = await fetchFromAgent(agent.id, agent.port!, "/tasks?status=all");
1512
1648
  if (data?.tasks) {
1513
1649
  totalTasks += data.tasks.length;
1514
1650
  for (const task of data.tasks) {
@@ -1633,7 +1769,161 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1633
1769
  }
1634
1770
  }
1635
1771
 
1636
- // ============ Composio Integration ============
1772
+ // ============ Generic Integration Providers ============
1773
+ // These endpoints work with any registered provider (composio, smithery, etc.)
1774
+
1775
+ // GET /api/integrations/providers - List available integration providers
1776
+ if (path === "/api/integrations/providers" && method === "GET") {
1777
+ const providerIds = getProviderIds();
1778
+ const providers = providerIds.map(id => {
1779
+ const provider = getProvider(id);
1780
+ const hasKey = !!ProviderKeys.getDecrypted(id);
1781
+ return {
1782
+ id,
1783
+ name: provider?.name || id,
1784
+ connected: hasKey,
1785
+ };
1786
+ });
1787
+ return json({ providers });
1788
+ }
1789
+
1790
+ // GET /api/integrations/:provider/apps - List available apps from a provider
1791
+ const appsMatch = path.match(/^\/api\/integrations\/([^/]+)\/apps$/);
1792
+ if (appsMatch && method === "GET") {
1793
+ const providerId = appsMatch[1];
1794
+ const provider = getProvider(providerId);
1795
+ if (!provider) {
1796
+ return json({ error: `Unknown provider: ${providerId}` }, 404);
1797
+ }
1798
+
1799
+ const apiKey = ProviderKeys.getDecrypted(providerId);
1800
+ if (!apiKey) {
1801
+ return json({ error: `${provider.name} API key not configured`, apps: [] }, 200);
1802
+ }
1803
+
1804
+ try {
1805
+ const apps = await provider.listApps(apiKey);
1806
+ return json({ apps });
1807
+ } catch (e) {
1808
+ console.error(`Failed to list apps from ${providerId}:`, e);
1809
+ return json({ error: "Failed to fetch apps" }, 500);
1810
+ }
1811
+ }
1812
+
1813
+ // GET /api/integrations/:provider/connected - List user's connected accounts
1814
+ const connectedMatch = path.match(/^\/api\/integrations\/([^/]+)\/connected$/);
1815
+ if (connectedMatch && method === "GET") {
1816
+ const providerId = connectedMatch[1];
1817
+ const provider = getProvider(providerId);
1818
+ if (!provider) {
1819
+ return json({ error: `Unknown provider: ${providerId}` }, 404);
1820
+ }
1821
+
1822
+ const apiKey = ProviderKeys.getDecrypted(providerId);
1823
+ if (!apiKey) {
1824
+ return json({ error: `${provider.name} API key not configured`, accounts: [] }, 200);
1825
+ }
1826
+
1827
+ // Use Apteva user ID as the entity ID for the provider
1828
+ const userId = user?.id || "default";
1829
+
1830
+ try {
1831
+ const accounts = await provider.listConnectedAccounts(apiKey, userId);
1832
+ return json({ accounts });
1833
+ } catch (e) {
1834
+ console.error(`Failed to list connected accounts from ${providerId}:`, e);
1835
+ return json({ error: "Failed to fetch connected accounts" }, 500);
1836
+ }
1837
+ }
1838
+
1839
+ // POST /api/integrations/:provider/connect - Initiate connection (OAuth or API Key)
1840
+ const connectMatch = path.match(/^\/api\/integrations\/([^/]+)\/connect$/);
1841
+ if (connectMatch && method === "POST") {
1842
+ const providerId = connectMatch[1];
1843
+ const provider = getProvider(providerId);
1844
+ if (!provider) {
1845
+ return json({ error: `Unknown provider: ${providerId}` }, 404);
1846
+ }
1847
+
1848
+ const apiKey = ProviderKeys.getDecrypted(providerId);
1849
+ if (!apiKey) {
1850
+ return json({ error: `${provider.name} API key not configured` }, 401);
1851
+ }
1852
+
1853
+ try {
1854
+ const body = await req.json();
1855
+ const { appSlug, redirectUrl, credentials } = body;
1856
+
1857
+ if (!appSlug) {
1858
+ return json({ error: "appSlug is required" }, 400);
1859
+ }
1860
+
1861
+ // Use Apteva user ID as the entity ID
1862
+ const userId = user?.id || "default";
1863
+
1864
+ // Default redirect URL back to our integrations page
1865
+ const callbackUrl = redirectUrl || `http://localhost:${process.env.PORT || 4280}/mcp?tab=hosted&connected=${appSlug}`;
1866
+
1867
+ const result = await provider.initiateConnection(apiKey, userId, appSlug, callbackUrl, credentials);
1868
+ return json(result);
1869
+ } catch (e) {
1870
+ console.error(`Failed to initiate connection for ${providerId}:`, e);
1871
+ return json({ error: `Failed to initiate connection: ${e}` }, 500);
1872
+ }
1873
+ }
1874
+
1875
+ // GET /api/integrations/:provider/connection/:id - Check connection status
1876
+ const connectionStatusMatch = path.match(/^\/api\/integrations\/([^/]+)\/connection\/([^/]+)$/);
1877
+ if (connectionStatusMatch && method === "GET") {
1878
+ const providerId = connectionStatusMatch[1];
1879
+ const connectionId = connectionStatusMatch[2];
1880
+ const provider = getProvider(providerId);
1881
+ if (!provider) {
1882
+ return json({ error: `Unknown provider: ${providerId}` }, 404);
1883
+ }
1884
+
1885
+ const apiKey = ProviderKeys.getDecrypted(providerId);
1886
+ if (!apiKey) {
1887
+ return json({ error: `${provider.name} API key not configured` }, 401);
1888
+ }
1889
+
1890
+ try {
1891
+ const connection = await provider.getConnectionStatus(apiKey, connectionId);
1892
+ if (!connection) {
1893
+ return json({ error: "Connection not found" }, 404);
1894
+ }
1895
+ return json({ connection });
1896
+ } catch (e) {
1897
+ console.error(`Failed to get connection status:`, e);
1898
+ return json({ error: "Failed to get connection status" }, 500);
1899
+ }
1900
+ }
1901
+
1902
+ // DELETE /api/integrations/:provider/connection/:id - Disconnect/revoke
1903
+ const disconnectMatch = path.match(/^\/api\/integrations\/([^/]+)\/connection\/([^/]+)$/);
1904
+ if (disconnectMatch && method === "DELETE") {
1905
+ const providerId = disconnectMatch[1];
1906
+ const connectionId = disconnectMatch[2];
1907
+ const provider = getProvider(providerId);
1908
+ if (!provider) {
1909
+ return json({ error: `Unknown provider: ${providerId}` }, 404);
1910
+ }
1911
+
1912
+ const apiKey = ProviderKeys.getDecrypted(providerId);
1913
+ if (!apiKey) {
1914
+ return json({ error: `${provider.name} API key not configured` }, 401);
1915
+ }
1916
+
1917
+ try {
1918
+ const success = await provider.disconnect(apiKey, connectionId);
1919
+ return json({ success });
1920
+ } catch (e) {
1921
+ console.error(`Failed to disconnect:`, e);
1922
+ return json({ error: "Failed to disconnect" }, 500);
1923
+ }
1924
+ }
1925
+
1926
+ // ============ Composio-Specific Routes (MCP Configs) ============
1637
1927
 
1638
1928
  // GET /api/integrations/composio/configs - List Composio MCP configs
1639
1929
  if (path === "/api/integrations/composio/configs" && method === "GET") {
@@ -1658,15 +1948,13 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1658
1948
 
1659
1949
  const data = await res.json();
1660
1950
 
1661
- // Transform to our format
1951
+ // Transform to our format (no user_id in URLs - that's provided when adding)
1662
1952
  const configs = (data.items || data.servers || []).map((item: any) => ({
1663
1953
  id: item.id,
1664
1954
  name: item.name || item.id,
1665
1955
  toolkits: item.toolkits || item.apps || [],
1666
1956
  toolsCount: item.toolsCount || item.tools?.length || 0,
1667
1957
  createdAt: item.createdAt || item.created_at,
1668
- // Build the MCP URL for this config
1669
- mcpUrl: `https://backend.composio.dev/v3/mcp/${item.id}`,
1670
1958
  }));
1671
1959
 
1672
1960
  return json({ configs });
@@ -1704,19 +1992,187 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1704
1992
  name: data.name || data.id,
1705
1993
  toolkits: data.toolkits || data.apps || [],
1706
1994
  tools: data.tools || [],
1707
- mcpUrl: `https://backend.composio.dev/v3/mcp/${data.id}`,
1708
- }
1995
+ },
1709
1996
  });
1710
1997
  } catch (e) {
1711
1998
  return json({ error: "Failed to fetch config" }, 500);
1712
1999
  }
1713
2000
  }
1714
2001
 
2002
+ // POST /api/integrations/composio/configs/:id/add - Add a Composio config as an MCP server
2003
+ // Fetches the mcp_url directly from Composio API
2004
+ const composioAddMatch = path.match(/^\/api\/integrations\/composio\/configs\/([^/]+)\/add$/);
2005
+ if (composioAddMatch && method === "POST") {
2006
+ const configId = composioAddMatch[1];
2007
+ const apiKey = ProviderKeys.getDecrypted("composio");
2008
+ if (!apiKey) {
2009
+ return json({ error: "Composio API key not configured" }, 401);
2010
+ }
2011
+
2012
+ try {
2013
+ // Fetch config details from Composio to get the name and mcp_url
2014
+ const res = await fetch(`https://backend.composio.dev/api/v3/mcp/${configId}`, {
2015
+ headers: {
2016
+ "x-api-key": apiKey,
2017
+ "Content-Type": "application/json",
2018
+ },
2019
+ });
2020
+
2021
+ if (!res.ok) {
2022
+ const errText = await res.text();
2023
+ console.error("Failed to fetch Composio MCP config:", errText);
2024
+ return json({ error: "Failed to fetch MCP config from Composio" }, 400);
2025
+ }
2026
+
2027
+ const data = await res.json();
2028
+ const configName = data.name || `composio-${configId.slice(0, 8)}`;
2029
+ const mcpUrl = data.mcp_url;
2030
+ const authConfigIds = data.auth_config_ids || [];
2031
+ const serverInstanceCount = data.server_instance_count || 0;
2032
+
2033
+ if (!mcpUrl) {
2034
+ return json({ error: "MCP config does not have a URL" }, 400);
2035
+ }
2036
+
2037
+ // Get user_id from connected accounts for this auth config
2038
+ const { createMcpServerInstance, getUserIdForAuthConfig } = await import("../integrations/composio");
2039
+ let userId: string | null = null;
2040
+
2041
+ if (authConfigIds.length > 0) {
2042
+ userId = await getUserIdForAuthConfig(apiKey, authConfigIds[0]);
2043
+
2044
+ // Create server instance if none exists
2045
+ if (serverInstanceCount === 0 && userId) {
2046
+ const instance = await createMcpServerInstance(apiKey, configId, userId);
2047
+ if (instance) {
2048
+ console.log(`Created server instance for user ${userId} on server ${configId}`);
2049
+ }
2050
+ }
2051
+ }
2052
+
2053
+ // Append user_id to mcp_url for authentication
2054
+ const mcpUrlWithUser = userId
2055
+ ? `${mcpUrl}?user_id=${encodeURIComponent(userId)}`
2056
+ : mcpUrl;
2057
+
2058
+ // Check if already exists (match by config ID in URL)
2059
+ const existing = McpServerDB.findAll().find(
2060
+ s => s.source === "composio" && s.url?.includes(configId)
2061
+ );
2062
+ if (existing) {
2063
+ return json({ server: existing, message: "Server already exists" });
2064
+ }
2065
+
2066
+ // Create the MCP server entry with user_id in URL
2067
+ const server = McpServerDB.create({
2068
+ id: generateId(),
2069
+ name: configName,
2070
+ type: "http",
2071
+ package: null,
2072
+ command: null,
2073
+ args: null,
2074
+ env: {},
2075
+ url: mcpUrlWithUser,
2076
+ headers: { "x-api-key": apiKey },
2077
+ source: "composio",
2078
+ });
2079
+
2080
+ return json({ server, message: "Server added successfully" });
2081
+ } catch (e) {
2082
+ console.error("Failed to add Composio config:", e);
2083
+ return json({ error: "Failed to add Composio config" }, 500);
2084
+ }
2085
+ }
2086
+
2087
+ // POST /api/integrations/composio/configs - Create a new MCP config from connected app
2088
+ if (path === "/api/integrations/composio/configs" && method === "POST") {
2089
+ const apiKey = ProviderKeys.getDecrypted("composio");
2090
+ if (!apiKey) {
2091
+ return json({ error: "Composio API key not configured" }, 401);
2092
+ }
2093
+
2094
+ try {
2095
+ const body = await req.json();
2096
+ const { name, toolkitSlug, authConfigId } = body;
2097
+
2098
+ if (!name || !toolkitSlug) {
2099
+ return json({ error: "name and toolkitSlug are required" }, 400);
2100
+ }
2101
+
2102
+ // If authConfigId not provided, find it from the toolkit
2103
+ let configId = authConfigId;
2104
+ if (!configId) {
2105
+ const { getAuthConfigForToolkit } = await import("../integrations/composio");
2106
+ configId = await getAuthConfigForToolkit(apiKey, toolkitSlug);
2107
+ if (!configId) {
2108
+ return json({ error: `No auth config found for ${toolkitSlug}. Make sure you have connected this app first.` }, 400);
2109
+ }
2110
+ }
2111
+
2112
+ // Create MCP server in Composio
2113
+ const { createMcpServer, createMcpServerInstance, getUserIdForAuthConfig } = await import("../integrations/composio");
2114
+ const mcpServer = await createMcpServer(apiKey, name, [configId]);
2115
+
2116
+ if (!mcpServer) {
2117
+ return json({ error: "Failed to create MCP config" }, 500);
2118
+ }
2119
+
2120
+ // Create server instance for the user who has the connected account
2121
+ const userId = await getUserIdForAuthConfig(apiKey, configId);
2122
+ if (userId) {
2123
+ const instance = await createMcpServerInstance(apiKey, mcpServer.id, userId);
2124
+ if (!instance) {
2125
+ console.warn(`Created MCP server but failed to create instance for user ${userId}`);
2126
+ }
2127
+ }
2128
+
2129
+ // Append user_id to mcp_url for authentication
2130
+ const mcpUrlWithUser = userId
2131
+ ? `${mcpServer.mcpUrl}?user_id=${encodeURIComponent(userId)}`
2132
+ : mcpServer.mcpUrl;
2133
+
2134
+ return json({
2135
+ config: {
2136
+ id: mcpServer.id,
2137
+ name: mcpServer.name,
2138
+ toolkits: mcpServer.toolkits,
2139
+ mcpUrl: mcpUrlWithUser,
2140
+ allowedTools: mcpServer.allowedTools,
2141
+ userId,
2142
+ },
2143
+ }, 201);
2144
+ } catch (e: any) {
2145
+ console.error("Failed to create Composio MCP config:", e);
2146
+ return json({ error: e.message || "Failed to create MCP config" }, 500);
2147
+ }
2148
+ }
2149
+
2150
+ // DELETE /api/integrations/composio/configs/:id - Delete a Composio MCP config
2151
+ if (composioConfigMatch && method === "DELETE") {
2152
+ const configId = composioConfigMatch[1];
2153
+ const apiKey = ProviderKeys.getDecrypted("composio");
2154
+ if (!apiKey) {
2155
+ return json({ error: "Composio API key not configured" }, 401);
2156
+ }
2157
+
2158
+ try {
2159
+ const { deleteMcpServer } = await import("../integrations/composio");
2160
+ const success = await deleteMcpServer(apiKey, configId);
2161
+ if (!success) {
2162
+ return json({ error: "Failed to delete MCP config" }, 500);
2163
+ }
2164
+ return json({ success: true });
2165
+ } catch (e) {
2166
+ console.error("Failed to delete Composio config:", e);
2167
+ return json({ error: "Failed to delete MCP config" }, 500);
2168
+ }
2169
+ }
2170
+
1715
2171
  // POST /api/mcp/servers - Create/install a new MCP server
1716
2172
  if (path === "/api/mcp/servers" && method === "POST") {
1717
2173
  try {
1718
2174
  const body = await req.json();
1719
- const { name, type, package: pkg, command, args, env } = body;
2175
+ const { name, type, package: pkg, command, args, env, url, headers, source } = body;
1720
2176
 
1721
2177
  if (!name) {
1722
2178
  return json({ error: "Name is required" }, 400);
@@ -1730,6 +2186,9 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1730
2186
  command: command || null,
1731
2187
  args: args || null,
1732
2188
  env: env || {},
2189
+ url: url || null,
2190
+ headers: headers || {},
2191
+ source: source || null,
1733
2192
  });
1734
2193
 
1735
2194
  return json({ server }, 201);
@@ -1832,7 +2291,7 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1832
2291
  }
1833
2292
 
1834
2293
  // Get a port for the HTTP proxy
1835
- const port = getNextPort();
2294
+ const port = await getNextPort();
1836
2295
 
1837
2296
  console.log(`Starting MCP server ${server.name}...`);
1838
2297
  console.log(` Command: ${cmd.join(" ")}`);
@@ -1879,7 +2338,24 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1879
2338
  return json({ error: "MCP server not found" }, 404);
1880
2339
  }
1881
2340
 
1882
- // Check if process is running
2341
+ // HTTP servers use remote HTTP transport
2342
+ if (server.type === "http" && server.url) {
2343
+ try {
2344
+ const httpClient = getHttpMcpClient(server.url, server.headers || {});
2345
+ const serverInfo = await httpClient.initialize();
2346
+ const tools = await httpClient.listTools();
2347
+
2348
+ return json({
2349
+ serverInfo,
2350
+ tools,
2351
+ });
2352
+ } catch (err) {
2353
+ console.error(`Failed to list HTTP MCP tools: ${err}`);
2354
+ return json({ error: `Failed to communicate with MCP server: ${err}` }, 500);
2355
+ }
2356
+ }
2357
+
2358
+ // Stdio servers require a running process
1883
2359
  const mcpProcess = getMcpProcess(server.id);
1884
2360
  if (!mcpProcess) {
1885
2361
  return json({ error: "MCP server is not running" }, 400);
@@ -1907,14 +2383,30 @@ export async function handleApiRequest(req: Request, path: string, authContext?:
1907
2383
  return json({ error: "MCP server not found" }, 404);
1908
2384
  }
1909
2385
 
1910
- // Check if process is running
2386
+ const toolName = decodeURIComponent(mcpToolCallMatch[2]);
2387
+
2388
+ // HTTP servers use remote HTTP transport
2389
+ if (server.type === "http" && server.url) {
2390
+ try {
2391
+ const body = await req.json();
2392
+ const args = body.arguments || {};
2393
+
2394
+ const httpClient = getHttpMcpClient(server.url, server.headers || {});
2395
+ const result = await httpClient.callTool(toolName, args);
2396
+
2397
+ return json({ result });
2398
+ } catch (err) {
2399
+ console.error(`Failed to call HTTP MCP tool: ${err}`);
2400
+ return json({ error: `Failed to call tool: ${err}` }, 500);
2401
+ }
2402
+ }
2403
+
2404
+ // Stdio servers require a running process
1911
2405
  const mcpProcess = getMcpProcess(server.id);
1912
2406
  if (!mcpProcess) {
1913
2407
  return json({ error: "MCP server is not running" }, 400);
1914
2408
  }
1915
2409
 
1916
- const toolName = decodeURIComponent(mcpToolCallMatch[2]);
1917
-
1918
2410
  try {
1919
2411
  const body = await req.json();
1920
2412
  const args = body.arguments || {};