apteva 0.2.3 → 0.2.6

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 (38) hide show
  1. package/dist/App.0mzj9cz9.js +213 -0
  2. package/dist/index.html +1 -1
  3. package/dist/styles.css +1 -1
  4. package/package.json +6 -6
  5. package/src/binary.ts +271 -1
  6. package/src/crypto.ts +53 -0
  7. package/src/db.ts +492 -3
  8. package/src/mcp-client.ts +599 -0
  9. package/src/providers.ts +31 -0
  10. package/src/routes/api.ts +832 -64
  11. package/src/server.ts +169 -5
  12. package/src/web/App.tsx +44 -2
  13. package/src/web/components/agents/AgentCard.tsx +53 -9
  14. package/src/web/components/agents/AgentPanel.tsx +381 -0
  15. package/src/web/components/agents/AgentsView.tsx +27 -10
  16. package/src/web/components/agents/CreateAgentModal.tsx +7 -7
  17. package/src/web/components/agents/index.ts +1 -1
  18. package/src/web/components/common/Icons.tsx +8 -0
  19. package/src/web/components/common/Modal.tsx +2 -2
  20. package/src/web/components/common/Select.tsx +1 -1
  21. package/src/web/components/common/index.ts +1 -0
  22. package/src/web/components/dashboard/Dashboard.tsx +74 -25
  23. package/src/web/components/index.ts +5 -2
  24. package/src/web/components/layout/Sidebar.tsx +22 -2
  25. package/src/web/components/mcp/McpPage.tsx +1144 -0
  26. package/src/web/components/mcp/index.ts +1 -0
  27. package/src/web/components/onboarding/OnboardingWizard.tsx +5 -1
  28. package/src/web/components/settings/SettingsPage.tsx +312 -82
  29. package/src/web/components/tasks/TasksPage.tsx +129 -0
  30. package/src/web/components/tasks/index.ts +1 -0
  31. package/src/web/components/telemetry/TelemetryPage.tsx +359 -0
  32. package/src/web/context/TelemetryContext.tsx +202 -0
  33. package/src/web/context/index.ts +2 -0
  34. package/src/web/hooks/useAgents.ts +23 -0
  35. package/src/web/styles.css +18 -0
  36. package/src/web/types.ts +75 -1
  37. package/dist/App.wfhmfhx7.js +0 -213
  38. package/src/web/components/agents/ChatPanel.tsx +0 -63
package/src/routes/api.ts CHANGED
@@ -2,10 +2,26 @@ import { spawn } from "bun";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import { mkdirSync, existsSync } from "fs";
5
- import { agentProcesses, BINARY_PATH, getNextPort, getBinaryStatus, BIN_DIR } from "../server";
6
- import { AgentDB, generateId, type Agent } from "../db";
5
+ import { agentProcesses, BINARY_PATH, getNextPort, getBinaryStatus, BIN_DIR, telemetryBroadcaster, type TelemetryEvent } from "../server";
6
+ import { AgentDB, McpServerDB, TelemetryDB, generateId, type Agent, type AgentFeatures, type McpServer } from "../db";
7
7
  import { ProviderKeys, Onboarding, getProvidersWithStatus, PROVIDERS, type ProviderId } from "../providers";
8
- import { binaryExists } from "../binary";
8
+ import {
9
+ binaryExists,
10
+ checkForUpdates,
11
+ getInstalledVersion,
12
+ getAptevaVersion,
13
+ downloadLatestBinary,
14
+ installViaNpm,
15
+ } from "../binary";
16
+ import {
17
+ startMcpProcess,
18
+ stopMcpProcess,
19
+ initializeMcpServer,
20
+ listMcpTools,
21
+ callMcpTool,
22
+ getMcpProcess,
23
+ getMcpProxyUrl,
24
+ } from "../mcp-client";
9
25
 
10
26
  // Data directory for agent instances (in ~/.apteva/agents/)
11
27
  const AGENTS_DATA_DIR = process.env.DATA_DIR
@@ -19,8 +35,279 @@ function json(data: unknown, status = 200): Response {
19
35
  });
20
36
  }
21
37
 
38
+ // Wait for agent to be healthy (with timeout)
39
+ async function waitForAgentHealth(port: number, maxAttempts = 30, delayMs = 200): Promise<boolean> {
40
+ for (let i = 0; i < maxAttempts; i++) {
41
+ try {
42
+ const res = await fetch(`http://localhost:${port}/health`, {
43
+ signal: AbortSignal.timeout(1000),
44
+ });
45
+ if (res.ok) return true;
46
+ } catch {
47
+ // Not ready yet
48
+ }
49
+ await new Promise(r => setTimeout(r, delayMs));
50
+ }
51
+ return false;
52
+ }
53
+
54
+ // Build agent config from apteva agent data
55
+ // Note: POST /config expects flat structure WITHOUT "agent" wrapper
56
+ function buildAgentConfig(agent: Agent, providerKey: string) {
57
+ const features = agent.features;
58
+
59
+ // Get MCP server details for the agent's selected servers
60
+ // All MCP servers are accessed via HTTP proxy (apteva manages the stdio processes)
61
+ const mcpServers = (agent.mcp_servers || [])
62
+ .map(id => McpServerDB.findById(id))
63
+ .filter((s): s is NonNullable<typeof s> => s !== null && s.status === "running" && s.port)
64
+ .map(s => ({
65
+ name: s.name,
66
+ type: "http" as const,
67
+ url: `http://localhost:${s.port}/mcp`,
68
+ headers: {},
69
+ enabled: true,
70
+ }));
71
+
72
+ return {
73
+ id: agent.id,
74
+ name: agent.name,
75
+ description: agent.system_prompt,
76
+ llm: {
77
+ provider: agent.provider,
78
+ model: agent.model,
79
+ max_tokens: 4000,
80
+ temperature: 0.7,
81
+ system_prompt: agent.system_prompt,
82
+ vision: {
83
+ enabled: features.vision,
84
+ max_images: 20,
85
+ max_image_size: 5242880,
86
+ allowed_types: ["jpeg", "png", "gif", "webp"],
87
+ resize_images: true,
88
+ max_dimension: 1568,
89
+ pdf: {
90
+ enabled: features.vision,
91
+ max_file_size: 33554432,
92
+ max_pages: 100,
93
+ allow_urls: true,
94
+ },
95
+ },
96
+ parallel_tools: {
97
+ enabled: true,
98
+ max_concurrent: 10,
99
+ },
100
+ tools: [], // Clear any old tool whitelist - agent uses all registered tools
101
+ },
102
+ tasks: {
103
+ enabled: features.tasks,
104
+ allow_scheduling: true,
105
+ allow_recurring: true,
106
+ max_tasks: 100,
107
+ auto_execute: false,
108
+ },
109
+ scheduler: {
110
+ enabled: features.tasks,
111
+ interval: "1m",
112
+ max_tasks: 100,
113
+ },
114
+ memory: {
115
+ enabled: features.memory,
116
+ embedding_model: "text-embedding-3-small",
117
+ decision_model: "gpt-4o-mini",
118
+ max_memories_per_query: 20,
119
+ min_importance: 0.3,
120
+ min_similarity: 0.3,
121
+ auto_prune: true,
122
+ max_memories: 10000,
123
+ embedding_provider: "openai",
124
+ auto_extract_memories: features.memory ? true : null,
125
+ auto_ingest_files: true,
126
+ },
127
+ operator: {
128
+ enabled: features.operator,
129
+ virtual_browser: "http://localhost:8098",
130
+ display_width: 1024,
131
+ display_height: 768,
132
+ max_actions_per_turn: 5,
133
+ },
134
+ mcp: {
135
+ enabled: features.mcp,
136
+ base_url: "http://localhost:3000/mcp",
137
+ timeout: "30s",
138
+ retry_count: 3,
139
+ cache_ttl: "15m",
140
+ servers: mcpServers,
141
+ },
142
+ realtime: {
143
+ enabled: features.realtime,
144
+ provider: "openai",
145
+ model: "gpt-4o-realtime-preview",
146
+ voice: "alloy",
147
+ },
148
+ context: {
149
+ max_messages: 30,
150
+ max_tokens: 0,
151
+ keep_images: 5,
152
+ },
153
+ filesystem: {
154
+ enabled: true,
155
+ max_file_size: 10485760,
156
+ max_total_size: 104857600,
157
+ auto_extract: true,
158
+ auto_cleanup: true,
159
+ retention_days: 7,
160
+ },
161
+ telemetry: {
162
+ enabled: true,
163
+ endpoint: `http://localhost:${process.env.PORT || 4280}/api/telemetry`,
164
+ batch_size: 10,
165
+ flush_interval: 30,
166
+ categories: [], // Empty = all categories
167
+ },
168
+ };
169
+ }
170
+
171
+ // Push config to running agent
172
+ async function pushConfigToAgent(port: number, config: any): Promise<{ success: boolean; error?: string }> {
173
+ try {
174
+ const res = await fetch(`http://localhost:${port}/config`, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify(config),
178
+ signal: AbortSignal.timeout(5000),
179
+ });
180
+ if (res.ok) {
181
+ return { success: true };
182
+ }
183
+ const data = await res.json().catch(() => ({}));
184
+ return { success: false, error: data.error || `HTTP ${res.status}` };
185
+ } catch (err) {
186
+ return { success: false, error: String(err) };
187
+ }
188
+ }
189
+
190
+ // Exported helper to start an agent process (used by API route and auto-restart)
191
+ export async function startAgentProcess(
192
+ agent: Agent,
193
+ options: { silent?: boolean } = {}
194
+ ): Promise<{ success: boolean; port?: number; error?: string }> {
195
+ const { silent = false } = options;
196
+
197
+ // Check if binary exists
198
+ if (!binaryExists(BIN_DIR)) {
199
+ return { success: false, error: "Agent binary not available" };
200
+ }
201
+
202
+ // Check if already running
203
+ if (agentProcesses.has(agent.id)) {
204
+ return { success: false, error: "Agent already running" };
205
+ }
206
+
207
+ // Get the API key for the agent's provider
208
+ const providerKey = ProviderKeys.getDecrypted(agent.provider);
209
+ if (!providerKey) {
210
+ return { success: false, error: `No API key for provider: ${agent.provider}` };
211
+ }
212
+
213
+ // Get provider config for env var name
214
+ const providerConfig = PROVIDERS[agent.provider as ProviderId];
215
+ if (!providerConfig) {
216
+ return { success: false, error: `Unknown provider: ${agent.provider}` };
217
+ }
218
+
219
+ // Assign port
220
+ const port = getNextPort();
221
+
222
+ try {
223
+ // Create data directory for this agent
224
+ const agentDataDir = join(AGENTS_DATA_DIR, agent.id);
225
+ if (!existsSync(agentDataDir)) {
226
+ mkdirSync(agentDataDir, { recursive: true });
227
+ }
228
+
229
+ if (!silent) {
230
+ console.log(`Starting agent ${agent.name} on port ${port}...`);
231
+ console.log(` Provider: ${agent.provider}`);
232
+ console.log(` Data dir: ${agentDataDir}`);
233
+ }
234
+
235
+ // Build environment with provider key
236
+ const env: Record<string, string> = {
237
+ ...process.env as Record<string, string>,
238
+ PORT: String(port),
239
+ DATA_DIR: agentDataDir,
240
+ [providerConfig.envVar]: providerKey,
241
+ };
242
+
243
+ const proc = spawn({
244
+ cmd: [BINARY_PATH],
245
+ env,
246
+ stdout: "ignore",
247
+ stderr: "ignore",
248
+ });
249
+
250
+ agentProcesses.set(agent.id, proc);
251
+
252
+ // Wait for agent to be healthy
253
+ if (!silent) {
254
+ console.log(` Waiting for agent to be ready...`);
255
+ }
256
+ const isHealthy = await waitForAgentHealth(port);
257
+ if (!isHealthy) {
258
+ if (!silent) {
259
+ console.error(` Agent failed to start (health check timeout)`);
260
+ }
261
+ proc.kill();
262
+ agentProcesses.delete(agent.id);
263
+ return { success: false, error: "Health check timeout" };
264
+ }
265
+
266
+ // Push configuration to the agent
267
+ if (!silent) {
268
+ console.log(` Pushing configuration...`);
269
+ }
270
+ const config = buildAgentConfig(agent, providerKey);
271
+ const configResult = await pushConfigToAgent(port, config);
272
+ if (!configResult.success) {
273
+ if (!silent) {
274
+ console.error(` Failed to configure agent: ${configResult.error}`);
275
+ }
276
+ // Agent is running but not configured - still usable but log warning
277
+ } else if (!silent) {
278
+ console.log(` Configuration applied successfully`);
279
+ }
280
+
281
+ // Update status in database
282
+ AgentDB.setStatus(agent.id, "running", port);
283
+
284
+ if (!silent) {
285
+ console.log(`Agent ${agent.name} started on port ${port} (pid: ${proc.pid})`);
286
+ }
287
+
288
+ return { success: true, port };
289
+ } catch (err) {
290
+ if (!silent) {
291
+ console.error(`Failed to start agent: ${err}`);
292
+ }
293
+ return { success: false, error: String(err) };
294
+ }
295
+ }
296
+
22
297
  // Transform DB agent to API response format (camelCase for frontend compatibility)
23
298
  function toApiAgent(agent: Agent) {
299
+ // Look up MCP server details
300
+ const mcpServerDetails = (agent.mcp_servers || [])
301
+ .map(id => McpServerDB.findById(id))
302
+ .filter((s): s is NonNullable<typeof s> => s !== null)
303
+ .map(s => ({
304
+ id: s.id,
305
+ name: s.name,
306
+ type: s.type,
307
+ status: s.status,
308
+ port: s.port,
309
+ }));
310
+
24
311
  return {
25
312
  id: agent.id,
26
313
  name: agent.name,
@@ -30,6 +317,8 @@ function toApiAgent(agent: Agent) {
30
317
  status: agent.status,
31
318
  port: agent.port,
32
319
  features: agent.features,
320
+ mcpServers: agent.mcp_servers, // Keep IDs for backwards compatibility
321
+ mcpServerDetails, // Include full details
33
322
  createdAt: agent.created_at,
34
323
  updatedAt: agent.updated_at,
35
324
  };
@@ -64,6 +353,7 @@ export async function handleApiRequest(req: Request, path: string): Promise<Resp
64
353
  provider: provider || "anthropic",
65
354
  system_prompt: systemPrompt || "You are a helpful assistant.",
66
355
  features: features || DEFAULT_FEATURES,
356
+ mcp_servers: body.mcpServers || [],
67
357
  });
68
358
 
69
359
  return json({ agent: toApiAgent(agent) }, 201);
@@ -98,8 +388,23 @@ export async function handleApiRequest(req: Request, path: string): Promise<Resp
98
388
  if (body.model !== undefined) updates.model = body.model;
99
389
  if (body.provider !== undefined) updates.provider = body.provider;
100
390
  if (body.systemPrompt !== undefined) updates.system_prompt = body.systemPrompt;
391
+ if (body.features !== undefined) updates.features = body.features;
392
+ if (body.mcpServers !== undefined) updates.mcp_servers = body.mcpServers;
101
393
 
102
394
  const updated = AgentDB.update(agentMatch[1], updates);
395
+
396
+ // If agent is running, push the new config
397
+ if (updated && updated.status === "running" && updated.port) {
398
+ const providerKey = ProviderKeys.getDecrypted(updated.provider);
399
+ if (providerKey) {
400
+ const config = buildAgentConfig(updated, providerKey);
401
+ const configResult = await pushConfigToAgent(updated.port, config);
402
+ if (!configResult.success) {
403
+ console.error(`Failed to push config to running agent: ${configResult.error}`);
404
+ }
405
+ }
406
+ }
407
+
103
408
  return json({ agent: updated ? toApiAgent(updated) : null });
104
409
  } catch (e) {
105
410
  return json({ error: "Invalid request body" }, 400);
@@ -132,69 +437,13 @@ export async function handleApiRequest(req: Request, path: string): Promise<Resp
132
437
  return json({ error: "Agent not found" }, 404);
133
438
  }
134
439
 
135
- // Check if binary exists
136
- if (!binaryExists(BIN_DIR)) {
137
- return json({ error: "Agent binary not available. The binary will be downloaded automatically when available, or you can set AGENT_BINARY_PATH environment variable." }, 400);
138
- }
139
-
140
- // Check if already running
141
- if (agentProcesses.has(agent.id)) {
142
- return json({ error: "Agent already running" }, 400);
440
+ const result = await startAgentProcess(agent);
441
+ if (!result.success) {
442
+ return json({ error: result.error }, 400);
143
443
  }
144
444
 
145
- // Get the API key for the agent's provider
146
- const providerKey = ProviderKeys.getDecrypted(agent.provider);
147
- if (!providerKey) {
148
- return json({ error: `No API key configured for provider: ${agent.provider}. Please add your API key in Settings.` }, 400);
149
- }
150
-
151
- // Get provider config for env var name
152
- const providerConfig = PROVIDERS[agent.provider as ProviderId];
153
- if (!providerConfig) {
154
- return json({ error: `Unknown provider: ${agent.provider}` }, 400);
155
- }
156
-
157
- // Assign port
158
- const port = getNextPort();
159
-
160
- // Spawn the agent binary
161
- try {
162
- // Create data directory for this agent
163
- const agentDataDir = join(AGENTS_DATA_DIR, agent.id);
164
- if (!existsSync(agentDataDir)) {
165
- mkdirSync(agentDataDir, { recursive: true });
166
- }
167
-
168
- console.log(`Starting agent ${agent.name} on port ${port}...`);
169
- console.log(` Provider: ${agent.provider}`);
170
- console.log(` Data dir: ${agentDataDir}`);
171
-
172
- // Build environment with provider key
173
- const env: Record<string, string> = {
174
- ...process.env as Record<string, string>,
175
- PORT: String(port),
176
- DATA_DIR: agentDataDir,
177
- [providerConfig.envVar]: providerKey,
178
- };
179
-
180
- const proc = spawn({
181
- cmd: [BINARY_PATH],
182
- env,
183
- stdout: "ignore",
184
- stderr: "ignore",
185
- });
186
-
187
- agentProcesses.set(agent.id, proc);
188
-
189
- // Update status in database
190
- const updated = AgentDB.setStatus(agent.id, "running", port);
191
-
192
- console.log(`Agent ${agent.name} started on port ${port} (pid: ${proc.pid})`);
193
- return json({ agent: updated ? toApiAgent(updated) : null, message: `Agent started on port ${port}` });
194
- } catch (err) {
195
- console.error(`Failed to start agent: ${err}`);
196
- return json({ error: `Failed to start agent: ${err}` }, 500);
197
- }
445
+ const updated = AgentDB.findById(agent.id);
446
+ return json({ agent: updated ? toApiAgent(updated) : null, message: `Agent started on port ${result.port}` });
198
447
  }
199
448
 
200
449
  // POST /api/agents/:id/stop - Stop an agent
@@ -365,9 +614,40 @@ export async function handleApiRequest(req: Request, path: string): Promise<Resp
365
614
  return json(getBinaryStatus(BIN_DIR));
366
615
  }
367
616
 
617
+ // GET /api/version - Check agent binary version info
618
+ if (path === "/api/version" && method === "GET") {
619
+ const versionInfo = await checkForUpdates();
620
+ return json(versionInfo);
621
+ }
622
+
623
+ // POST /api/version/update - Download/install latest agent binary
624
+ if (path === "/api/version/update" && method === "POST") {
625
+ // Check if any agents are running
626
+ const runningAgents = AgentDB.findAll().filter(a => a.status === "running");
627
+ if (runningAgents.length > 0) {
628
+ return json(
629
+ { success: false, error: "Cannot update while agents are running. Stop all agents first." },
630
+ { status: 400 }
631
+ );
632
+ }
633
+
634
+ // Try npm install first, fall back to direct download
635
+ let result = await installViaNpm();
636
+ if (!result.success) {
637
+ // Fall back to direct download
638
+ result = await downloadLatestBinary(BIN_DIR);
639
+ }
640
+
641
+ if (result.success) {
642
+ return json({ success: true, version: result.version });
643
+ }
644
+ return json({ success: false, error: result.error }, { status: 500 });
645
+ }
646
+
368
647
  // GET /api/health - Health check
369
648
  if (path === "/api/health") {
370
649
  const binaryStatus = getBinaryStatus(BIN_DIR);
650
+ const installedVersion = getInstalledVersion();
371
651
  return json({
372
652
  status: "ok",
373
653
  timestamp: new Date().toISOString(),
@@ -379,10 +659,120 @@ export async function handleApiRequest(req: Request, path: string): Promise<Resp
379
659
  available: binaryStatus.exists,
380
660
  platform: binaryStatus.platform,
381
661
  arch: binaryStatus.arch,
662
+ version: installedVersion,
382
663
  }
383
664
  });
384
665
  }
385
666
 
667
+ // ==================== TASKS ====================
668
+
669
+ // Helper to fetch from a running agent
670
+ async function fetchFromAgent(port: number, endpoint: string): Promise<any> {
671
+ try {
672
+ const response = await fetch(`http://localhost:${port}${endpoint}`, {
673
+ headers: { "Accept": "application/json" },
674
+ });
675
+ if (response.ok) {
676
+ return await response.json();
677
+ }
678
+ return null;
679
+ } catch {
680
+ return null;
681
+ }
682
+ }
683
+
684
+ // GET /api/tasks - Get all tasks from all running agents
685
+ if (path === "/api/tasks" && method === "GET") {
686
+ const url = new URL(req.url);
687
+ const status = url.searchParams.get("status") || "all";
688
+
689
+ const runningAgents = AgentDB.findAll().filter(a => a.status === "running" && a.port);
690
+ const allTasks: any[] = [];
691
+
692
+ for (const agent of runningAgents) {
693
+ const data = await fetchFromAgent(agent.port!, `/tasks?status=${status}`);
694
+ if (data?.tasks) {
695
+ // Add agent info to each task
696
+ for (const task of data.tasks) {
697
+ allTasks.push({
698
+ ...task,
699
+ agentId: agent.id,
700
+ agentName: agent.name,
701
+ });
702
+ }
703
+ }
704
+ }
705
+
706
+ // Sort by created_at descending
707
+ allTasks.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
708
+
709
+ return json({ tasks: allTasks, count: allTasks.length });
710
+ }
711
+
712
+ // GET /api/agents/:id/tasks - Get tasks from a specific agent
713
+ const agentTasksMatch = path.match(/^\/api\/agents\/([^/]+)\/tasks$/);
714
+ if (agentTasksMatch && method === "GET") {
715
+ const agentId = agentTasksMatch[1];
716
+ const agent = AgentDB.findById(agentId);
717
+
718
+ if (!agent) {
719
+ return json({ error: "Agent not found" }, 404);
720
+ }
721
+
722
+ if (agent.status !== "running" || !agent.port) {
723
+ return json({ error: "Agent is not running" }, 400);
724
+ }
725
+
726
+ const url = new URL(req.url);
727
+ const status = url.searchParams.get("status") || "all";
728
+
729
+ const data = await fetchFromAgent(agent.port, `/tasks?status=${status}`);
730
+ if (!data) {
731
+ return json({ error: "Failed to fetch tasks from agent" }, 500);
732
+ }
733
+
734
+ return json(data);
735
+ }
736
+
737
+ // GET /api/dashboard - Get dashboard statistics
738
+ if (path === "/api/dashboard" && method === "GET") {
739
+ const agents = AgentDB.findAll();
740
+ const runningAgents = agents.filter(a => a.status === "running" && a.port);
741
+
742
+ let totalTasks = 0;
743
+ let pendingTasks = 0;
744
+ let completedTasks = 0;
745
+ let runningTasks = 0;
746
+
747
+ for (const agent of runningAgents) {
748
+ const data = await fetchFromAgent(agent.port!, "/tasks?status=all");
749
+ if (data?.tasks) {
750
+ totalTasks += data.tasks.length;
751
+ for (const task of data.tasks) {
752
+ if (task.status === "pending") pendingTasks++;
753
+ else if (task.status === "completed") completedTasks++;
754
+ else if (task.status === "running") runningTasks++;
755
+ }
756
+ }
757
+ }
758
+
759
+ return json({
760
+ agents: {
761
+ total: agents.length,
762
+ running: runningAgents.length,
763
+ },
764
+ tasks: {
765
+ total: totalTasks,
766
+ pending: pendingTasks,
767
+ running: runningTasks,
768
+ completed: completedTasks,
769
+ },
770
+ providers: {
771
+ configured: ProviderKeys.getConfiguredProviders().length,
772
+ },
773
+ });
774
+ }
775
+
386
776
  // GET /api/version - Get current and latest version
387
777
  if (path === "/api/version" && method === "GET") {
388
778
  try {
@@ -418,5 +808,383 @@ export async function handleApiRequest(req: Request, path: string): Promise<Resp
418
808
  }
419
809
  }
420
810
 
811
+ // ============ MCP Server API ============
812
+
813
+ // GET /api/mcp/servers - List all MCP servers
814
+ if (path === "/api/mcp/servers" && method === "GET") {
815
+ const servers = McpServerDB.findAll();
816
+ return json({ servers });
817
+ }
818
+
819
+ // GET /api/mcp/registry - Search MCP registry for available servers
820
+ if (path === "/api/mcp/registry" && method === "GET") {
821
+ const url = new URL(req.url);
822
+ const search = url.searchParams.get("search") || "";
823
+ const limit = url.searchParams.get("limit") || "20";
824
+
825
+ try {
826
+ const registryUrl = `https://registry.modelcontextprotocol.io/v0/servers?search=${encodeURIComponent(search)}&limit=${limit}`;
827
+ const res = await fetch(registryUrl);
828
+ if (!res.ok) {
829
+ return json({ error: "Failed to fetch registry" }, 500);
830
+ }
831
+ const data = await res.json();
832
+
833
+ // Transform to simpler format
834
+ const servers = (data.servers || []).map((item: any) => {
835
+ const s = item.server;
836
+ const pkg = s.packages?.find((p: any) => p.registryType === "npm");
837
+ return {
838
+ name: s.name,
839
+ description: s.description,
840
+ version: s.version,
841
+ repository: s.repository?.url,
842
+ npmPackage: pkg?.identifier,
843
+ transport: pkg?.transport?.type || "stdio",
844
+ envVars: pkg?.environmentVariables || [],
845
+ };
846
+ }).filter((s: any) => s.npmPackage); // Only show npm packages for now
847
+
848
+ return json({ servers });
849
+ } catch (e) {
850
+ return json({ error: "Failed to search registry" }, 500);
851
+ }
852
+ }
853
+
854
+ // POST /api/mcp/servers - Create/install a new MCP server
855
+ if (path === "/api/mcp/servers" && method === "POST") {
856
+ try {
857
+ const body = await req.json();
858
+ const { name, type, package: pkg, command, args, env } = body;
859
+
860
+ if (!name) {
861
+ return json({ error: "Name is required" }, 400);
862
+ }
863
+
864
+ const server = McpServerDB.create({
865
+ id: generateId(),
866
+ name,
867
+ type: type || "npm",
868
+ package: pkg || null,
869
+ command: command || null,
870
+ args: args || null,
871
+ env: env || {},
872
+ });
873
+
874
+ return json({ server }, 201);
875
+ } catch (e) {
876
+ console.error("Create MCP server error:", e);
877
+ return json({ error: "Invalid request body" }, 400);
878
+ }
879
+ }
880
+
881
+ // GET /api/mcp/servers/:id - Get a specific MCP server
882
+ const mcpServerMatch = path.match(/^\/api\/mcp\/servers\/([^/]+)$/);
883
+ if (mcpServerMatch && method === "GET") {
884
+ const server = McpServerDB.findById(mcpServerMatch[1]);
885
+ if (!server) {
886
+ return json({ error: "MCP server not found" }, 404);
887
+ }
888
+ return json({ server });
889
+ }
890
+
891
+ // PUT /api/mcp/servers/:id - Update an MCP server
892
+ if (mcpServerMatch && method === "PUT") {
893
+ const server = McpServerDB.findById(mcpServerMatch[1]);
894
+ if (!server) {
895
+ return json({ error: "MCP server not found" }, 404);
896
+ }
897
+
898
+ try {
899
+ const body = await req.json();
900
+ const updates: Partial<McpServer> = {};
901
+
902
+ if (body.name !== undefined) updates.name = body.name;
903
+ if (body.type !== undefined) updates.type = body.type;
904
+ if (body.package !== undefined) updates.package = body.package;
905
+ if (body.command !== undefined) updates.command = body.command;
906
+ if (body.args !== undefined) updates.args = body.args;
907
+ if (body.env !== undefined) updates.env = body.env;
908
+
909
+ const updated = McpServerDB.update(mcpServerMatch[1], updates);
910
+ return json({ server: updated });
911
+ } catch (e) {
912
+ return json({ error: "Invalid request body" }, 400);
913
+ }
914
+ }
915
+
916
+ // DELETE /api/mcp/servers/:id - Delete an MCP server
917
+ if (mcpServerMatch && method === "DELETE") {
918
+ const server = McpServerDB.findById(mcpServerMatch[1]);
919
+ if (!server) {
920
+ return json({ error: "MCP server not found" }, 404);
921
+ }
922
+
923
+ // Stop if running
924
+ if (server.status === "running") {
925
+ // TODO: Stop the server process
926
+ }
927
+
928
+ McpServerDB.delete(mcpServerMatch[1]);
929
+ return json({ success: true });
930
+ }
931
+
932
+ // POST /api/mcp/servers/:id/start - Start an MCP server
933
+ const mcpStartMatch = path.match(/^\/api\/mcp\/servers\/([^/]+)\/start$/);
934
+ if (mcpStartMatch && method === "POST") {
935
+ const server = McpServerDB.findById(mcpStartMatch[1]);
936
+ if (!server) {
937
+ return json({ error: "MCP server not found" }, 404);
938
+ }
939
+
940
+ if (server.status === "running") {
941
+ return json({ error: "MCP server already running" }, 400);
942
+ }
943
+
944
+ // Determine command to run
945
+ // Helper to substitute $ENV_VAR references with actual values
946
+ const substituteEnvVars = (str: string, env: Record<string, string>): string => {
947
+ return str.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, varName) => {
948
+ return env[varName] || '';
949
+ });
950
+ };
951
+
952
+ let cmd: string[];
953
+ const serverEnv = server.env || {};
954
+
955
+ if (server.command) {
956
+ // Custom command - substitute env vars in args
957
+ cmd = server.command.split(" ");
958
+ if (server.args) {
959
+ const substitutedArgs = substituteEnvVars(server.args, serverEnv);
960
+ cmd.push(...substitutedArgs.split(" "));
961
+ }
962
+ } else if (server.package) {
963
+ // npm package - use npx
964
+ cmd = ["npx", "-y", server.package];
965
+ if (server.args) {
966
+ const substitutedArgs = substituteEnvVars(server.args, serverEnv);
967
+ cmd.push(...substitutedArgs.split(" "));
968
+ }
969
+ } else {
970
+ return json({ error: "No command or package specified" }, 400);
971
+ }
972
+
973
+ // Get a port for the HTTP proxy
974
+ const port = getNextPort();
975
+
976
+ console.log(`Starting MCP server ${server.name}...`);
977
+ console.log(` Command: ${cmd.join(" ")}`);
978
+ console.log(` HTTP proxy: http://localhost:${port}/mcp`);
979
+
980
+ // Start the MCP process with stdio pipes + HTTP proxy
981
+ const result = await startMcpProcess(server.id, cmd, server.env || {}, port);
982
+
983
+ if (!result.success) {
984
+ console.error(`Failed to start MCP server: ${result.error}`);
985
+ return json({ error: `Failed to start: ${result.error}` }, 500);
986
+ }
987
+
988
+ // Update status with the HTTP proxy port
989
+ const updated = McpServerDB.setStatus(server.id, "running", port);
990
+
991
+ return json({
992
+ server: updated,
993
+ message: "MCP server started",
994
+ proxyUrl: `http://localhost:${port}/mcp`,
995
+ });
996
+ }
997
+
998
+ // POST /api/mcp/servers/:id/stop - Stop an MCP server
999
+ const mcpStopMatch = path.match(/^\/api\/mcp\/servers\/([^/]+)\/stop$/);
1000
+ if (mcpStopMatch && method === "POST") {
1001
+ const server = McpServerDB.findById(mcpStopMatch[1]);
1002
+ if (!server) {
1003
+ return json({ error: "MCP server not found" }, 404);
1004
+ }
1005
+
1006
+ // Stop the MCP process
1007
+ stopMcpProcess(server.id);
1008
+
1009
+ const updated = McpServerDB.setStatus(server.id, "stopped");
1010
+ return json({ server: updated, message: "MCP server stopped" });
1011
+ }
1012
+
1013
+ // GET /api/mcp/servers/:id/tools - List tools from an MCP server
1014
+ const mcpToolsMatch = path.match(/^\/api\/mcp\/servers\/([^/]+)\/tools$/);
1015
+ if (mcpToolsMatch && method === "GET") {
1016
+ const server = McpServerDB.findById(mcpToolsMatch[1]);
1017
+ if (!server) {
1018
+ return json({ error: "MCP server not found" }, 404);
1019
+ }
1020
+
1021
+ // Check if process is running
1022
+ const mcpProcess = getMcpProcess(server.id);
1023
+ if (!mcpProcess) {
1024
+ return json({ error: "MCP server is not running" }, 400);
1025
+ }
1026
+
1027
+ try {
1028
+ const serverInfo = await initializeMcpServer(server.id);
1029
+ const tools = await listMcpTools(server.id);
1030
+
1031
+ return json({
1032
+ serverInfo,
1033
+ tools,
1034
+ });
1035
+ } catch (err) {
1036
+ console.error(`Failed to list MCP tools: ${err}`);
1037
+ return json({ error: `Failed to communicate with MCP server: ${err}` }, 500);
1038
+ }
1039
+ }
1040
+
1041
+ // POST /api/mcp/servers/:id/tools/:toolName/call - Call a tool on an MCP server
1042
+ const mcpToolCallMatch = path.match(/^\/api\/mcp\/servers\/([^/]+)\/tools\/([^/]+)\/call$/);
1043
+ if (mcpToolCallMatch && method === "POST") {
1044
+ const server = McpServerDB.findById(mcpToolCallMatch[1]);
1045
+ if (!server) {
1046
+ return json({ error: "MCP server not found" }, 404);
1047
+ }
1048
+
1049
+ // Check if process is running
1050
+ const mcpProcess = getMcpProcess(server.id);
1051
+ if (!mcpProcess) {
1052
+ return json({ error: "MCP server is not running" }, 400);
1053
+ }
1054
+
1055
+ const toolName = decodeURIComponent(mcpToolCallMatch[2]);
1056
+
1057
+ try {
1058
+ const body = await req.json();
1059
+ const args = body.arguments || {};
1060
+
1061
+ const result = await callMcpTool(server.id, toolName, args);
1062
+
1063
+ return json({ result });
1064
+ } catch (err) {
1065
+ console.error(`Failed to call MCP tool: ${err}`);
1066
+ return json({ error: `Failed to call tool: ${err}` }, 500);
1067
+ }
1068
+ }
1069
+
1070
+ // ============ Telemetry Endpoints ============
1071
+
1072
+ // POST /api/telemetry - Receive telemetry events from agents
1073
+ if (path === "/api/telemetry" && method === "POST") {
1074
+ try {
1075
+ const body = await req.json() as {
1076
+ agent_id: string;
1077
+ sent_at: string;
1078
+ events: Array<{
1079
+ id: string;
1080
+ timestamp: string;
1081
+ category: string;
1082
+ type: string;
1083
+ level: string;
1084
+ trace_id?: string;
1085
+ span_id?: string;
1086
+ thread_id?: string;
1087
+ data?: Record<string, unknown>;
1088
+ metadata?: Record<string, unknown>;
1089
+ duration_ms?: number;
1090
+ error?: string;
1091
+ }>;
1092
+ };
1093
+
1094
+ if (!body.agent_id || !body.events) {
1095
+ return json({ error: "agent_id and events are required" }, 400);
1096
+ }
1097
+
1098
+ // Filter out debug events - too noisy
1099
+ const filteredEvents = body.events.filter(e => e.level !== "debug");
1100
+ const inserted = TelemetryDB.insertBatch(body.agent_id, filteredEvents);
1101
+
1102
+ // Broadcast to SSE clients
1103
+ if (filteredEvents.length > 0) {
1104
+ const broadcastEvents: TelemetryEvent[] = filteredEvents.map(e => ({
1105
+ id: e.id,
1106
+ agent_id: body.agent_id,
1107
+ timestamp: e.timestamp,
1108
+ category: e.category,
1109
+ type: e.type,
1110
+ level: e.level,
1111
+ trace_id: e.trace_id,
1112
+ thread_id: e.thread_id,
1113
+ data: e.data,
1114
+ duration_ms: e.duration_ms,
1115
+ error: e.error,
1116
+ }));
1117
+ telemetryBroadcaster.broadcast(broadcastEvents);
1118
+ }
1119
+
1120
+ return json({ received: body.events.length, inserted });
1121
+ } catch (e) {
1122
+ console.error("Telemetry error:", e);
1123
+ return json({ error: "Invalid telemetry payload" }, 400);
1124
+ }
1125
+ }
1126
+
1127
+ // GET /api/telemetry/stream - SSE stream for real-time telemetry
1128
+ if (path === "/api/telemetry/stream" && method === "GET") {
1129
+ let controller: ReadableStreamDefaultController<string>;
1130
+
1131
+ const stream = new ReadableStream<string>({
1132
+ start(c) {
1133
+ controller = c;
1134
+ telemetryBroadcaster.addClient(controller);
1135
+ // Send initial connection message
1136
+ controller.enqueue("data: {\"connected\":true}\n\n");
1137
+ },
1138
+ cancel() {
1139
+ telemetryBroadcaster.removeClient(controller);
1140
+ },
1141
+ });
1142
+
1143
+ return new Response(stream, {
1144
+ headers: {
1145
+ "Content-Type": "text/event-stream",
1146
+ "Cache-Control": "no-cache",
1147
+ "Connection": "keep-alive",
1148
+ "Access-Control-Allow-Origin": "*",
1149
+ },
1150
+ });
1151
+ }
1152
+
1153
+ // GET /api/telemetry/events - Query telemetry events
1154
+ if (path === "/api/telemetry/events" && method === "GET") {
1155
+ const url = new URL(req.url);
1156
+ const events = TelemetryDB.query({
1157
+ agent_id: url.searchParams.get("agent_id") || undefined,
1158
+ category: url.searchParams.get("category") || undefined,
1159
+ level: url.searchParams.get("level") || undefined,
1160
+ trace_id: url.searchParams.get("trace_id") || undefined,
1161
+ since: url.searchParams.get("since") || undefined,
1162
+ until: url.searchParams.get("until") || undefined,
1163
+ limit: parseInt(url.searchParams.get("limit") || "100"),
1164
+ offset: parseInt(url.searchParams.get("offset") || "0"),
1165
+ });
1166
+ return json({ events });
1167
+ }
1168
+
1169
+ // GET /api/telemetry/usage - Get usage statistics
1170
+ if (path === "/api/telemetry/usage" && method === "GET") {
1171
+ const url = new URL(req.url);
1172
+ const usage = TelemetryDB.getUsage({
1173
+ agent_id: url.searchParams.get("agent_id") || undefined,
1174
+ since: url.searchParams.get("since") || undefined,
1175
+ until: url.searchParams.get("until") || undefined,
1176
+ group_by: (url.searchParams.get("group_by") as "agent" | "day") || undefined,
1177
+ });
1178
+ return json({ usage });
1179
+ }
1180
+
1181
+ // GET /api/telemetry/stats - Get summary statistics
1182
+ if (path === "/api/telemetry/stats" && method === "GET") {
1183
+ const url = new URL(req.url);
1184
+ const agentId = url.searchParams.get("agent_id") || undefined;
1185
+ const stats = TelemetryDB.getStats(agentId);
1186
+ return json({ stats });
1187
+ }
1188
+
421
1189
  return json({ error: "Not found" }, 404);
422
1190
  }