@swarmai/local-agent 0.1.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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * MCP Manager for Local Agent
3
+ *
4
+ * Manages MCP (Model Context Protocol) server connections locally.
5
+ * Uses @modelcontextprotocol/sdk to spawn stdio-based MCP server processes,
6
+ * discover their tools, and execute tool calls.
7
+ *
8
+ * Mirrors patterns from server/services/mcp/MCPClientManager.cjs
9
+ * but adapted for client-side use without database access.
10
+ */
11
+
12
+ const os = require('os');
13
+
14
+ const TOOL_CALL_TIMEOUT = 60000; // 60s
15
+
16
+ /**
17
+ * Pre-configured MCP server recipes
18
+ */
19
+ const MCP_RECIPES = {
20
+ playwright: {
21
+ name: 'playwright',
22
+ description: 'Browser automation via Playwright',
23
+ command: 'npx',
24
+ args: ['-y', '@playwright/mcp@latest'],
25
+ },
26
+ filesystem: {
27
+ name: 'filesystem',
28
+ description: 'Local filesystem access',
29
+ command: 'npx',
30
+ args: ['-y', '@modelcontextprotocol/server-filesystem', os.homedir()],
31
+ },
32
+ sqlite: {
33
+ name: 'sqlite',
34
+ description: 'SQLite database access',
35
+ command: 'npx',
36
+ args: ['-y', '@modelcontextprotocol/server-sqlite'],
37
+ },
38
+ git: {
39
+ name: 'git',
40
+ description: 'Git repository operations',
41
+ command: 'npx',
42
+ args: ['-y', '@modelcontextprotocol/server-github'],
43
+ },
44
+ docker: {
45
+ name: 'docker',
46
+ description: 'Docker container management',
47
+ command: 'npx',
48
+ args: ['-y', '@modelcontextprotocol/server-docker'],
49
+ },
50
+ };
51
+
52
+ class MCPManager {
53
+ constructor() {
54
+ /** @type {Map<string, { client: Object, transport: Object, tools: Array, config: Object }>} */
55
+ this._connections = new Map();
56
+ this._log = console.log;
57
+ }
58
+
59
+ /**
60
+ * Set log function (called from connection.js)
61
+ */
62
+ setLogger(logFn) {
63
+ this._log = logFn || console.log;
64
+ }
65
+
66
+ /**
67
+ * Start all configured MCP servers
68
+ * @param {Object} mcpConfig - { serverName: { command, args, env } }
69
+ */
70
+ async startAll(mcpConfig) {
71
+ if (!mcpConfig || typeof mcpConfig !== 'object') return;
72
+
73
+ const entries = Object.entries(mcpConfig);
74
+ if (entries.length === 0) return;
75
+
76
+ for (const [name, config] of entries) {
77
+ try {
78
+ await this.connect(name, config);
79
+ } catch (e) {
80
+ this._log(`MCP: Failed to start "${name}": ${e.message}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Connect to a single MCP server
87
+ * @param {string} name - Server name
88
+ * @param {Object} config - { command, args, env }
89
+ */
90
+ async connect(name, config) {
91
+ if (this._connections.has(name)) {
92
+ this._log(`MCP: "${name}" already connected`);
93
+ return;
94
+ }
95
+
96
+ if (!config.command) {
97
+ throw new Error(`MCP server "${name}" has no command configured`);
98
+ }
99
+
100
+ const { Client } = require('@modelcontextprotocol/sdk/client');
101
+ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
102
+
103
+ const args = Array.isArray(config.args) ? config.args : [];
104
+ const env = config.env && typeof config.env === 'object'
105
+ ? { ...process.env, ...config.env }
106
+ : undefined;
107
+
108
+ // On Windows, npx needs shell resolution
109
+ const isWindows = os.platform() === 'win32';
110
+ const command = isWindows && config.command === 'npx' ? 'npx.cmd' : config.command;
111
+
112
+ this._log(`MCP: Starting "${name}" (${config.command} ${args.join(' ')})`);
113
+
114
+ const transport = new StdioClientTransport({ command, args, env });
115
+
116
+ const client = new Client(
117
+ { name: 'swarmai-local-agent', version: '0.1.0' },
118
+ { capabilities: {} }
119
+ );
120
+
121
+ try {
122
+ await client.connect(transport);
123
+ } catch (err) {
124
+ throw new Error(`Failed to connect MCP server "${name}": ${err.message}`);
125
+ }
126
+
127
+ const entry = { client, transport, tools: [], config };
128
+ this._connections.set(name, entry);
129
+
130
+ // Discover tools
131
+ try {
132
+ const result = await client.listTools();
133
+ entry.tools = (result.tools || []).map(t => ({
134
+ server: name,
135
+ name: t.name,
136
+ description: t.description || '',
137
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
138
+ }));
139
+ this._log(`MCP: "${name}" connected — ${entry.tools.length} tools discovered`);
140
+ } catch (err) {
141
+ this._log(`MCP: "${name}" connected but tool discovery failed: ${err.message}`);
142
+ }
143
+
144
+ // Handle unexpected disconnect
145
+ if (transport.onclose) {
146
+ const originalOnClose = transport.onclose;
147
+ transport.onclose = () => {
148
+ originalOnClose();
149
+ if (this._connections.has(name)) {
150
+ this._log(`MCP: "${name}" disconnected unexpectedly`);
151
+ this._connections.delete(name);
152
+ }
153
+ };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Disconnect a single MCP server
159
+ * @param {string} name
160
+ */
161
+ async disconnect(name) {
162
+ const entry = this._connections.get(name);
163
+ if (!entry) return;
164
+
165
+ try {
166
+ await entry.client.close();
167
+ } catch (err) {
168
+ this._log(`MCP: Error closing "${name}": ${err.message}`);
169
+ }
170
+
171
+ this._connections.delete(name);
172
+ }
173
+
174
+ /**
175
+ * Disconnect all MCP servers
176
+ */
177
+ async disconnectAll() {
178
+ const names = [...this._connections.keys()];
179
+ const promises = names.map(name => this.disconnect(name));
180
+ await Promise.allSettled(promises);
181
+ }
182
+
183
+ /**
184
+ * Call a tool on a specific MCP server
185
+ * @param {string} serverName
186
+ * @param {string} toolName
187
+ * @param {Object} args
188
+ * @returns {Promise<Object>}
189
+ */
190
+ async callTool(serverName, toolName, args = {}) {
191
+ const entry = this._connections.get(serverName);
192
+ if (!entry) {
193
+ throw new Error(`MCP server "${serverName}" not connected. Connected servers: ${this.getConnectedServers().join(', ') || 'none'}`);
194
+ }
195
+
196
+ // Validate tool exists
197
+ const tool = entry.tools.find(t => t.name === toolName);
198
+ if (!tool) {
199
+ const available = entry.tools.map(t => t.name).join(', ');
200
+ throw new Error(`Tool "${toolName}" not found on MCP server "${serverName}". Available: ${available || 'none'}`);
201
+ }
202
+
203
+ // Execute with timeout
204
+ const timeoutPromise = new Promise((_, reject) => {
205
+ setTimeout(() => reject(new Error(`MCP tool call timed out after ${TOOL_CALL_TIMEOUT / 1000}s`)), TOOL_CALL_TIMEOUT);
206
+ });
207
+
208
+ const callPromise = entry.client.callTool({
209
+ name: toolName,
210
+ arguments: args,
211
+ });
212
+
213
+ const result = await Promise.race([callPromise, timeoutPromise]);
214
+ return result.content || result;
215
+ }
216
+
217
+ /**
218
+ * Get all discovered tools from all connected servers (flat array)
219
+ * @returns {Array<{ server: string, name: string, description: string, inputSchema: Object }>}
220
+ */
221
+ getAllTools() {
222
+ const tools = [];
223
+ for (const entry of this._connections.values()) {
224
+ tools.push(...entry.tools);
225
+ }
226
+ return tools;
227
+ }
228
+
229
+ /**
230
+ * Get list of connected server names
231
+ * @returns {string[]}
232
+ */
233
+ getConnectedServers() {
234
+ return [...this._connections.keys()];
235
+ }
236
+
237
+ /**
238
+ * Get a pre-configured recipe
239
+ * @param {string} name
240
+ * @returns {Object|null}
241
+ */
242
+ static getRecipe(name) {
243
+ return MCP_RECIPES[name] || null;
244
+ }
245
+
246
+ /**
247
+ * Get all recipe names
248
+ * @returns {string[]}
249
+ */
250
+ static getRecipeNames() {
251
+ return Object.keys(MCP_RECIPES);
252
+ }
253
+
254
+ /**
255
+ * Get recipe descriptions for display
256
+ * @returns {Array<{name: string, description: string}>}
257
+ */
258
+ static getRecipeList() {
259
+ return Object.values(MCP_RECIPES).map(r => ({
260
+ name: r.name,
261
+ description: r.description,
262
+ }));
263
+ }
264
+ }
265
+
266
+ // Singleton
267
+ let _instance = null;
268
+
269
+ function getMcpManager() {
270
+ if (!_instance) {
271
+ _instance = new MCPManager();
272
+ }
273
+ return _instance;
274
+ }
275
+
276
+ module.exports = { MCPManager, getMcpManager, MCP_RECIPES };
package/src/startup.js ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Cross-OS auto-start at boot management
3
+ * Supports Windows (Registry), macOS (LaunchAgent), Linux (systemd user service)
4
+ */
5
+
6
+ const os = require('os');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { execSync } = require('child_process');
10
+
11
+ const APP_NAME = 'swarmai-agent';
12
+ const APP_LABEL = 'com.swarmai.agent';
13
+
14
+ /**
15
+ * Get the full path to the swarmai-agent executable
16
+ */
17
+ function getExecutablePath() {
18
+ const platform = os.platform();
19
+ try {
20
+ if (platform === 'win32') {
21
+ return execSync('where swarmai-agent', { encoding: 'utf-8' }).trim().split('\n')[0].trim();
22
+ } else {
23
+ return execSync('which swarmai-agent', { encoding: 'utf-8' }).trim();
24
+ }
25
+ } catch {
26
+ // Fallback: use the current process
27
+ return process.argv[1] || 'swarmai-agent';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Enable auto-start at boot
33
+ */
34
+ function enableStartup() {
35
+ const platform = os.platform();
36
+ const execPath = getExecutablePath();
37
+
38
+ switch (platform) {
39
+ case 'win32':
40
+ return enableWindows(execPath);
41
+ case 'darwin':
42
+ return enableMacOS(execPath);
43
+ case 'linux':
44
+ return enableLinux(execPath);
45
+ default:
46
+ throw new Error(`Unsupported platform: ${platform}. Supported: Windows, macOS, Linux`);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Disable auto-start at boot
52
+ */
53
+ function disableStartup() {
54
+ const platform = os.platform();
55
+
56
+ switch (platform) {
57
+ case 'win32':
58
+ return disableWindows();
59
+ case 'darwin':
60
+ return disableMacOS();
61
+ case 'linux':
62
+ return disableLinux();
63
+ default:
64
+ throw new Error(`Unsupported platform: ${platform}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Check if auto-start is enabled
70
+ */
71
+ function isStartupEnabled() {
72
+ const platform = os.platform();
73
+
74
+ switch (platform) {
75
+ case 'win32':
76
+ return isEnabledWindows();
77
+ case 'darwin':
78
+ return isEnabledMacOS();
79
+ case 'linux':
80
+ return isEnabledLinux();
81
+ default:
82
+ return false;
83
+ }
84
+ }
85
+
86
+ // ================
87
+ // Windows: Registry
88
+ // ================
89
+
90
+ function enableWindows(execPath) {
91
+ // Sanitize execPath to prevent command injection via quotes in path
92
+ const safePath = execPath.replace(/"/g, '');
93
+ const cmd = `"${safePath}" start`;
94
+ try {
95
+ execSync(
96
+ `reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${APP_NAME}" /t REG_SZ /d "${cmd}" /f`,
97
+ { encoding: 'utf-8', stdio: 'pipe' }
98
+ );
99
+ return { method: 'registry', path: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' };
100
+ } catch (err) {
101
+ throw new Error(`Failed to set Windows registry: ${err.message}`);
102
+ }
103
+ }
104
+
105
+ function disableWindows() {
106
+ try {
107
+ execSync(
108
+ `reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${APP_NAME}" /f`,
109
+ { encoding: 'utf-8', stdio: 'pipe' }
110
+ );
111
+ return true;
112
+ } catch {
113
+ return false; // Already removed
114
+ }
115
+ }
116
+
117
+ function isEnabledWindows() {
118
+ try {
119
+ const output = execSync(
120
+ `reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${APP_NAME}"`,
121
+ { encoding: 'utf-8', stdio: 'pipe' }
122
+ );
123
+ return output.includes(APP_NAME);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ // ================
130
+ // macOS: LaunchAgent
131
+ // ================
132
+
133
+ function getLaunchAgentPath() {
134
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${APP_LABEL}.plist`);
135
+ }
136
+
137
+ function enableMacOS(execPath) {
138
+ const plistPath = getLaunchAgentPath();
139
+ const plistDir = path.dirname(plistPath);
140
+
141
+ if (!fs.existsSync(plistDir)) {
142
+ fs.mkdirSync(plistDir, { recursive: true });
143
+ }
144
+
145
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
146
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
147
+ <plist version="1.0">
148
+ <dict>
149
+ <key>Label</key>
150
+ <string>${APP_LABEL}</string>
151
+ <key>ProgramArguments</key>
152
+ <array>
153
+ <string>${execPath}</string>
154
+ <string>start</string>
155
+ </array>
156
+ <key>RunAtLoad</key>
157
+ <true/>
158
+ <key>KeepAlive</key>
159
+ <true/>
160
+ <key>StandardOutPath</key>
161
+ <string>${path.join(os.homedir(), '.swarmai', 'agent.log')}</string>
162
+ <key>StandardErrorPath</key>
163
+ <string>${path.join(os.homedir(), '.swarmai', 'agent-error.log')}</string>
164
+ </dict>
165
+ </plist>`;
166
+
167
+ fs.writeFileSync(plistPath, plist, 'utf-8');
168
+
169
+ try {
170
+ execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
171
+ } catch {
172
+ // May already be loaded
173
+ }
174
+
175
+ return { method: 'launchagent', path: plistPath };
176
+ }
177
+
178
+ function disableMacOS() {
179
+ const plistPath = getLaunchAgentPath();
180
+
181
+ try {
182
+ execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' });
183
+ } catch {
184
+ // May not be loaded
185
+ }
186
+
187
+ if (fs.existsSync(plistPath)) {
188
+ fs.unlinkSync(plistPath);
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+
194
+ function isEnabledMacOS() {
195
+ return fs.existsSync(getLaunchAgentPath());
196
+ }
197
+
198
+ // ================
199
+ // Linux: systemd user service
200
+ // ================
201
+
202
+ function getSystemdServicePath() {
203
+ return path.join(os.homedir(), '.config', 'systemd', 'user', `${APP_NAME}.service`);
204
+ }
205
+
206
+ function enableLinux(execPath) {
207
+ const servicePath = getSystemdServicePath();
208
+ const serviceDir = path.dirname(servicePath);
209
+
210
+ if (!fs.existsSync(serviceDir)) {
211
+ fs.mkdirSync(serviceDir, { recursive: true });
212
+ }
213
+
214
+ const service = `[Unit]
215
+ Description=SwarmAI Local Agent
216
+ After=network-online.target
217
+ Wants=network-online.target
218
+
219
+ [Service]
220
+ Type=simple
221
+ ExecStart=${execPath} start
222
+ Restart=on-failure
223
+ RestartSec=10
224
+ Environment=HOME=${os.homedir()}
225
+
226
+ [Install]
227
+ WantedBy=default.target
228
+ `;
229
+
230
+ fs.writeFileSync(servicePath, service, 'utf-8');
231
+
232
+ try {
233
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
234
+ execSync(`systemctl --user enable ${APP_NAME}.service`, { stdio: 'pipe' });
235
+ return { method: 'systemd', path: servicePath };
236
+ } catch (err) {
237
+ // systemd might not be available, try XDG autostart fallback
238
+ return enableLinuxXdg(execPath);
239
+ }
240
+ }
241
+
242
+ function enableLinuxXdg(execPath) {
243
+ const autostartDir = path.join(os.homedir(), '.config', 'autostart');
244
+ const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
245
+
246
+ if (!fs.existsSync(autostartDir)) {
247
+ fs.mkdirSync(autostartDir, { recursive: true });
248
+ }
249
+
250
+ const desktop = `[Desktop Entry]
251
+ Type=Application
252
+ Name=SwarmAI Local Agent
253
+ Exec=${execPath} start
254
+ Hidden=false
255
+ NoDisplay=true
256
+ X-GNOME-Autostart-enabled=true
257
+ `;
258
+
259
+ fs.writeFileSync(desktopPath, desktop, 'utf-8');
260
+ return { method: 'xdg-autostart', path: desktopPath };
261
+ }
262
+
263
+ function disableLinux() {
264
+ // Try systemd first
265
+ const servicePath = getSystemdServicePath();
266
+ if (fs.existsSync(servicePath)) {
267
+ try {
268
+ execSync(`systemctl --user disable ${APP_NAME}.service`, { stdio: 'pipe' });
269
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
270
+ } catch {
271
+ // systemd might not be available
272
+ }
273
+ fs.unlinkSync(servicePath);
274
+ return true;
275
+ }
276
+
277
+ // Try XDG autostart
278
+ const desktopPath = path.join(os.homedir(), '.config', 'autostart', `${APP_NAME}.desktop`);
279
+ if (fs.existsSync(desktopPath)) {
280
+ fs.unlinkSync(desktopPath);
281
+ return true;
282
+ }
283
+
284
+ return false;
285
+ }
286
+
287
+ function isEnabledLinux() {
288
+ if (fs.existsSync(getSystemdServicePath())) return true;
289
+ if (fs.existsSync(path.join(os.homedir(), '.config', 'autostart', `${APP_NAME}.desktop`))) return true;
290
+ return false;
291
+ }
292
+
293
+ module.exports = {
294
+ enableStartup,
295
+ disableStartup,
296
+ isStartupEnabled,
297
+ };