ikie-cli 0.1.32 → 0.1.34
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/README.md +185 -22
- package/dist/agent.d.ts +44 -0
- package/dist/agent.js +386 -128
- package/dist/config.d.ts +8 -0
- package/dist/config.js +4 -0
- package/dist/index.js +36 -1
- package/dist/mcp-manager.d.ts +75 -89
- package/dist/mcp-manager.js +710 -304
- package/dist/repl.js +315 -71
- package/dist/skills.d.ts +16 -0
- package/dist/skills.js +83 -6
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +21 -4
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +177 -166
- package/dist/tree.d.ts +19 -0
- package/dist/tree.js +266 -0
- package/package.json +2 -1
package/dist/mcp-manager.js
CHANGED
|
@@ -1,353 +1,759 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
4
4
|
import { HOME_DIR } from './config.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
import { VERSION } from './theme.js';
|
|
6
|
+
// ─── Config file paths ────────────────────────────────────────────────────────
|
|
7
|
+
const USER_MCP_FILE = join(HOME_DIR, 'mcp.json');
|
|
8
|
+
const OLD_REGISTRY_FILE = join(HOME_DIR, 'mcp-registry.json');
|
|
9
|
+
function projectMcpFile(cwd) {
|
|
10
|
+
return join(cwd, '.mcp.json');
|
|
11
|
+
}
|
|
12
|
+
function localMcpFile(cwd) {
|
|
13
|
+
return join(cwd, '.mcp.local.json');
|
|
14
|
+
}
|
|
15
|
+
function ensureUserDir() {
|
|
16
|
+
if (!existsSync(HOME_DIR))
|
|
17
|
+
mkdirSync(HOME_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
// ─── Pure helpers (exported for tests) ───────────────────────────────────────
|
|
20
|
+
const ENV_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
21
|
+
export function expandEnvVars(value, env) {
|
|
22
|
+
return value.replace(ENV_VAR_RE, (_, name, fallback) => {
|
|
23
|
+
const v = env[name];
|
|
24
|
+
if (v === undefined || v === null || v === '')
|
|
25
|
+
return fallback ?? '';
|
|
26
|
+
return v;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function expandEnvVarsInEntry(entry, env) {
|
|
30
|
+
const expand = (s) => expandEnvVars(s, env);
|
|
31
|
+
const expandRecord = (r) => {
|
|
32
|
+
if (!r)
|
|
33
|
+
return undefined;
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [k, v] of Object.entries(r))
|
|
36
|
+
out[k] = expand(v);
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
...entry,
|
|
41
|
+
command: entry.command ? expand(entry.command) : undefined,
|
|
42
|
+
args: entry.args?.map(expand),
|
|
43
|
+
env: expandRecord(entry.env),
|
|
44
|
+
url: entry.url ? expand(entry.url) : undefined,
|
|
45
|
+
headers: expandRecord(entry.headers),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function inferTransport(entry) {
|
|
49
|
+
if (entry.type) {
|
|
50
|
+
const t = entry.type.toLowerCase();
|
|
51
|
+
if (t === 'http' || t === 'streamable-http' || t === 'sse' || t === 'stdio')
|
|
52
|
+
return t;
|
|
53
|
+
}
|
|
54
|
+
if (entry.command)
|
|
55
|
+
return 'stdio';
|
|
56
|
+
if (entry.url)
|
|
57
|
+
return 'http';
|
|
58
|
+
return 'stdio';
|
|
59
|
+
}
|
|
60
|
+
export function mergeScopes(local, project, user) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const scope of [user, project, local]) {
|
|
63
|
+
if (!scope.mcpServers)
|
|
64
|
+
continue;
|
|
65
|
+
for (const [name, entry] of Object.entries(scope.mcpServers)) {
|
|
66
|
+
out[name] = entry;
|
|
19
67
|
}
|
|
20
68
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
const NAME_SANITIZE_RE = /[^A-Za-z0-9_-]+/g;
|
|
72
|
+
export function sanitizeMcpName(s) {
|
|
73
|
+
return s.replace(NAME_SANITIZE_RE, '_');
|
|
74
|
+
}
|
|
75
|
+
export function mcpToolName(server, tool) {
|
|
76
|
+
return `mcp__${sanitizeMcpName(server)}__${sanitizeMcpName(tool)}`;
|
|
77
|
+
}
|
|
78
|
+
export function buildReverseToolMap(tools) {
|
|
79
|
+
const out = new Map();
|
|
80
|
+
for (const [server, serverTools] of tools) {
|
|
81
|
+
for (const t of serverTools) {
|
|
82
|
+
out.set(mcpToolName(server, t.name), { server, tool: t.name });
|
|
29
83
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
description: 'File system operations MCP',
|
|
42
|
-
enabled: true,
|
|
43
|
-
autoStart: true,
|
|
44
|
-
},
|
|
45
|
-
github: {
|
|
46
|
-
name: 'github',
|
|
47
|
-
command: 'node',
|
|
48
|
-
args: [join(MCP_DIR, 'github-server.js')],
|
|
49
|
-
env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN || '' },
|
|
50
|
-
description: 'GitHub API operations MCP',
|
|
51
|
-
enabled: false,
|
|
52
|
-
autoStart: false,
|
|
53
|
-
},
|
|
54
|
-
database: {
|
|
55
|
-
name: 'database',
|
|
56
|
-
command: 'node',
|
|
57
|
-
args: [join(MCP_DIR, 'database-server.js')],
|
|
58
|
-
description: 'Database operations MCP (SQLite, PostgreSQL)',
|
|
59
|
-
enabled: false,
|
|
60
|
-
autoStart: false,
|
|
61
|
-
},
|
|
62
|
-
puppeteer: {
|
|
63
|
-
name: 'puppeteer',
|
|
64
|
-
command: 'node',
|
|
65
|
-
args: [join(MCP_DIR, 'puppeteer-server.js')],
|
|
66
|
-
description: 'Browser automation MCP',
|
|
67
|
-
enabled: false,
|
|
68
|
-
autoStart: false,
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
for (const [name, server] of Object.entries(builtIn)) {
|
|
72
|
-
if (!this.registry.servers[name]) {
|
|
73
|
-
this.registry.servers[name] = server;
|
|
74
|
-
if (!this.registry.builtIn.includes(name)) {
|
|
75
|
-
this.registry.builtIn.push(name);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
export function parseClaudeMcpAddCommand(str, defaultScope = 'user') {
|
|
88
|
+
const tokens = tokenizeAddCommand(str);
|
|
89
|
+
// Find "mcp add" in the token stream; ignore any preceding prefix (e.g. "claude").
|
|
90
|
+
let idx = 0;
|
|
91
|
+
while (idx < tokens.length - 1) {
|
|
92
|
+
if (tokens[idx].toLowerCase() === 'mcp' && tokens[idx + 1].toLowerCase() === 'add') {
|
|
93
|
+
idx += 2;
|
|
94
|
+
break;
|
|
78
95
|
}
|
|
79
|
-
|
|
96
|
+
idx++;
|
|
80
97
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const { execSync } = await import('child_process');
|
|
97
|
-
const repoName = config.source.split('/').pop()?.replace('.git', '') || config.name;
|
|
98
|
-
const repoPath = join(MCP_DIR, repoName);
|
|
99
|
-
execSync(`git clone ${config.source} ${repoPath}`, { cwd: MCP_DIR });
|
|
100
|
-
execSync('npm install', { cwd: repoPath });
|
|
101
|
-
command = 'node';
|
|
102
|
-
args = [join(repoPath, 'index.js')];
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
command = 'node';
|
|
106
|
-
args = [config.source];
|
|
98
|
+
if (idx >= tokens.length)
|
|
99
|
+
return { error: 'Missing "mcp add" in command' };
|
|
100
|
+
let scope = defaultScope;
|
|
101
|
+
let scopeExplicit = false;
|
|
102
|
+
let transport;
|
|
103
|
+
const env = {};
|
|
104
|
+
const headers = {};
|
|
105
|
+
let i = idx;
|
|
106
|
+
while (i < tokens.length && tokens[i].startsWith('--')) {
|
|
107
|
+
const flag = tokens[i];
|
|
108
|
+
if (flag === '--scope') {
|
|
109
|
+
const v = tokens[++i];
|
|
110
|
+
if (v === 'user' || v === 'project' || v === 'local') {
|
|
111
|
+
scope = v;
|
|
112
|
+
scopeExplicit = true;
|
|
107
113
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
command,
|
|
111
|
-
args,
|
|
112
|
-
env: config.env,
|
|
113
|
-
description: config.description || 'Custom MCP server',
|
|
114
|
-
enabled: true,
|
|
115
|
-
autoStart: config.autoStart ?? false,
|
|
116
|
-
};
|
|
117
|
-
this.registry.servers[config.name] = server;
|
|
118
|
-
this.saveRegistry();
|
|
119
|
-
return { success: true, server };
|
|
120
|
-
}
|
|
121
|
-
catch (error) {
|
|
122
|
-
return {
|
|
123
|
-
success: false,
|
|
124
|
-
error: error instanceof Error ? error.message : String(error),
|
|
125
|
-
};
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
126
116
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
if (flag === '--transport') {
|
|
118
|
+
transport = tokens[++i];
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
131
121
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
autoStart: false,
|
|
140
|
-
};
|
|
141
|
-
this.registry.servers[config.name] = server;
|
|
142
|
-
this.saveRegistry();
|
|
143
|
-
return { success: true, server };
|
|
144
|
-
}
|
|
145
|
-
uninstallMCP(name) {
|
|
146
|
-
if (!this.registry.servers[name]) {
|
|
147
|
-
return { success: false, error: `MCP '${name}' not found` };
|
|
122
|
+
if (flag === '--env') {
|
|
123
|
+
const kv = tokens[++i];
|
|
124
|
+
const eq = kv.indexOf('=');
|
|
125
|
+
if (eq > 0)
|
|
126
|
+
env[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
148
129
|
}
|
|
149
|
-
if (
|
|
150
|
-
|
|
130
|
+
if (flag === '--header') {
|
|
131
|
+
const kv = tokens[++i];
|
|
132
|
+
const colon = kv.indexOf(':');
|
|
133
|
+
if (colon > 0)
|
|
134
|
+
headers[kv.slice(0, colon).trim()] = kv.slice(colon + 1).trim();
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
151
137
|
}
|
|
152
|
-
|
|
153
|
-
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
if (i >= tokens.length)
|
|
141
|
+
return { error: 'Missing MCP server name' };
|
|
142
|
+
const name = tokens[i++];
|
|
143
|
+
if (i >= tokens.length)
|
|
144
|
+
return { error: 'Missing command or URL' };
|
|
145
|
+
const rest = tokens.slice(i);
|
|
146
|
+
const sep = rest.indexOf('--');
|
|
147
|
+
let entry;
|
|
148
|
+
if (sep === -1) {
|
|
149
|
+
// URL form: name url
|
|
150
|
+
entry = { type: transport ?? 'http', url: rest[0], headers, env, enabled: true, autoStart: true };
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// Everything after `--` is the full command string; `--` itself is not a command.
|
|
154
|
+
const command = rest[sep + 1];
|
|
155
|
+
const args = rest.slice(sep + 2);
|
|
156
|
+
entry = { type: 'stdio', command, args, env, enabled: true, autoStart: true };
|
|
157
|
+
}
|
|
158
|
+
return { name, entry, scope, scopeExplicit };
|
|
159
|
+
}
|
|
160
|
+
function tokenizeAddCommand(str) {
|
|
161
|
+
const tokens = [];
|
|
162
|
+
let current = '';
|
|
163
|
+
let inQuote = false;
|
|
164
|
+
for (let i = 0; i < str.length; i++) {
|
|
165
|
+
const ch = str[i];
|
|
166
|
+
if (inQuote) {
|
|
167
|
+
if (ch === inQuote) {
|
|
168
|
+
tokens.push(current);
|
|
169
|
+
current = '';
|
|
170
|
+
inQuote = false;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
current += ch;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
154
176
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
async startMCP(name) {
|
|
160
|
-
const server = this.registry.servers[name];
|
|
161
|
-
if (!server) {
|
|
162
|
-
return { success: false, error: `MCP '${name}' not found` };
|
|
177
|
+
if (ch === '"' || ch === "'") {
|
|
178
|
+
inQuote = ch;
|
|
179
|
+
continue;
|
|
163
180
|
}
|
|
164
|
-
if (
|
|
165
|
-
|
|
181
|
+
if (/\s/.test(ch)) {
|
|
182
|
+
if (current) {
|
|
183
|
+
tokens.push(current);
|
|
184
|
+
current = '';
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
166
187
|
}
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
current += ch;
|
|
189
|
+
}
|
|
190
|
+
if (current)
|
|
191
|
+
tokens.push(current);
|
|
192
|
+
return tokens;
|
|
193
|
+
}
|
|
194
|
+
// ─── LineBuffer (pure, tested) ──────────────────────────────────────────────
|
|
195
|
+
export class LineBuffer {
|
|
196
|
+
tail = '';
|
|
197
|
+
lines = [];
|
|
198
|
+
push(chunk) {
|
|
199
|
+
const data = this.tail + chunk;
|
|
200
|
+
const parts = data.split('\n');
|
|
201
|
+
this.tail = parts.pop() ?? '';
|
|
202
|
+
this.lines.push(...parts);
|
|
203
|
+
const out = this.lines;
|
|
204
|
+
this.lines = [];
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
flush() {
|
|
208
|
+
const out = this.lines;
|
|
209
|
+
this.lines = [];
|
|
210
|
+
if (this.tail) {
|
|
211
|
+
out.push(this.tail);
|
|
212
|
+
this.tail = '';
|
|
169
213
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// ─── JSON-RPC base client ───────────────────────────────────────────────────
|
|
218
|
+
class BaseJsonRpcClient {
|
|
219
|
+
id = 0;
|
|
220
|
+
pending = new Map();
|
|
221
|
+
dead = false;
|
|
222
|
+
serverName;
|
|
223
|
+
constructor(serverName) {
|
|
224
|
+
this.serverName = serverName;
|
|
225
|
+
}
|
|
226
|
+
nextId() {
|
|
227
|
+
return ++this.id;
|
|
228
|
+
}
|
|
229
|
+
request(method, params, timeoutMs = 60000) {
|
|
230
|
+
if (this.dead)
|
|
231
|
+
return Promise.reject(new Error(`MCP server ${this.serverName} is disconnected`));
|
|
232
|
+
const id = this.nextId();
|
|
233
|
+
const msg = { jsonrpc: '2.0', id, method, params };
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const timer = setTimeout(() => {
|
|
236
|
+
this.pending.delete(id);
|
|
237
|
+
reject(new Error(`MCP request ${method} timed out`));
|
|
238
|
+
}, timeoutMs);
|
|
239
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
240
|
+
this.sendMessage(msg).catch((e) => {
|
|
241
|
+
this.pending.delete(id);
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
reject(e);
|
|
191
244
|
});
|
|
192
|
-
|
|
193
|
-
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
notify(method, params) {
|
|
248
|
+
if (this.dead)
|
|
249
|
+
return Promise.resolve();
|
|
250
|
+
return this.sendMessage({ jsonrpc: '2.0', method, params });
|
|
251
|
+
}
|
|
252
|
+
handleResponse(message) {
|
|
253
|
+
const id = message.id;
|
|
254
|
+
if (id == null)
|
|
255
|
+
return;
|
|
256
|
+
const entry = this.pending.get(id);
|
|
257
|
+
if (!entry)
|
|
258
|
+
return;
|
|
259
|
+
this.pending.delete(id);
|
|
260
|
+
clearTimeout(entry.timer);
|
|
261
|
+
if (message.error) {
|
|
262
|
+
entry.reject(new Error(String(message.error?.message ?? message.error)));
|
|
194
263
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
success: false,
|
|
198
|
-
error: error instanceof Error ? error.message : String(error),
|
|
199
|
-
};
|
|
264
|
+
else {
|
|
265
|
+
entry.resolve(message.result);
|
|
200
266
|
}
|
|
201
267
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
268
|
+
rejectAllPending(reason) {
|
|
269
|
+
this.dead = true;
|
|
270
|
+
for (const [id, entry] of this.pending) {
|
|
271
|
+
clearTimeout(entry.timer);
|
|
272
|
+
entry.reject(new Error(reason));
|
|
273
|
+
this.pending.delete(id);
|
|
206
274
|
}
|
|
207
|
-
mcpProcess.process.kill();
|
|
208
|
-
this.processes.delete(name);
|
|
209
|
-
return { success: true };
|
|
210
275
|
}
|
|
211
|
-
async
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
276
|
+
async callTool(name, args, timeout) {
|
|
277
|
+
const result = await this.request('tools/call', { name, arguments: args }, timeout);
|
|
278
|
+
return flattenMcpContent(result);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function flattenMcpContent(result) {
|
|
282
|
+
if (!result || typeof result !== 'object')
|
|
283
|
+
return String(result);
|
|
284
|
+
const content = result.content;
|
|
285
|
+
if (!Array.isArray(content))
|
|
286
|
+
return JSON.stringify(result, null, 2);
|
|
287
|
+
const parts = [];
|
|
288
|
+
for (const block of content) {
|
|
289
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
290
|
+
parts.push(block.text);
|
|
291
|
+
else
|
|
292
|
+
parts.push(JSON.stringify(block));
|
|
293
|
+
}
|
|
294
|
+
return parts.join('\n');
|
|
295
|
+
}
|
|
296
|
+
// ─── StdioClient ──────────────────────────────────────────────────────────────
|
|
297
|
+
class StdioClient extends BaseJsonRpcClient {
|
|
298
|
+
process;
|
|
299
|
+
command;
|
|
300
|
+
args;
|
|
301
|
+
env;
|
|
302
|
+
stderrRing = [];
|
|
303
|
+
constructor(serverName, command, args, env) {
|
|
304
|
+
super(serverName);
|
|
305
|
+
this.command = command;
|
|
306
|
+
this.args = args;
|
|
307
|
+
this.env = env;
|
|
308
|
+
}
|
|
309
|
+
async connect() {
|
|
310
|
+
const child = spawn(this.command, this.args, {
|
|
311
|
+
env: { ...process.env, ...this.env },
|
|
312
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
|
+
});
|
|
314
|
+
this.process = child;
|
|
315
|
+
const lineBuffer = new LineBuffer();
|
|
316
|
+
// Wait for the process to actually start before sending JSON-RPC.
|
|
317
|
+
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
318
|
+
child.once('spawn', resolveSpawn);
|
|
319
|
+
child.once('error', rejectSpawn);
|
|
320
|
+
});
|
|
321
|
+
child.stdout?.on('data', (data) => {
|
|
322
|
+
const lines = lineBuffer.push(data.toString());
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
try {
|
|
325
|
+
const msg = JSON.parse(line);
|
|
326
|
+
this.handleResponse(msg);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// Ignore non-JSON lines
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
child.stderr?.on('data', (data) => {
|
|
334
|
+
const text = data.toString();
|
|
335
|
+
this.stderrRing.push(text);
|
|
336
|
+
if (this.stderrRing.length > 20)
|
|
337
|
+
this.stderrRing.shift();
|
|
338
|
+
});
|
|
339
|
+
child.on('exit', (code) => {
|
|
340
|
+
this.rejectAllPending(`MCP server ${this.serverName} exited (code ${code ?? 'unknown'})`);
|
|
341
|
+
});
|
|
342
|
+
child.on('error', (err) => {
|
|
343
|
+
this.rejectAllPending(`MCP server ${this.serverName} process error: ${err.message}`);
|
|
344
|
+
});
|
|
345
|
+
await this.request('initialize', {
|
|
346
|
+
protocolVersion: '2024-11-05',
|
|
347
|
+
capabilities: {},
|
|
348
|
+
clientInfo: { name: 'ikie', version: VERSION },
|
|
349
|
+
});
|
|
350
|
+
await this.notify('notifications/initialized', {});
|
|
351
|
+
const toolsResult = await this.request('tools/list', {});
|
|
352
|
+
return toolsResult.tools ?? [];
|
|
353
|
+
}
|
|
354
|
+
async sendMessage(msg) {
|
|
355
|
+
if (!this.process || this.process.killed)
|
|
356
|
+
throw new Error('MCP stdio process not running');
|
|
357
|
+
return new Promise((resolve, reject) => {
|
|
358
|
+
this.process.stdin.write(JSON.stringify(msg) + '\n', (err) => {
|
|
359
|
+
if (err)
|
|
360
|
+
reject(err);
|
|
361
|
+
else
|
|
362
|
+
resolve();
|
|
363
|
+
});
|
|
229
364
|
});
|
|
230
|
-
await this.requestMCPTools(name);
|
|
231
365
|
}
|
|
232
|
-
|
|
233
|
-
this.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
366
|
+
close() {
|
|
367
|
+
this.dead = true;
|
|
368
|
+
if (this.process && !this.process.killed) {
|
|
369
|
+
this.process.kill();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// ─── HttpClient ───────────────────────────────────────────────────────────────
|
|
374
|
+
class HttpClient extends BaseJsonRpcClient {
|
|
375
|
+
url;
|
|
376
|
+
headers;
|
|
377
|
+
env;
|
|
378
|
+
sessionId;
|
|
379
|
+
abortController;
|
|
380
|
+
constructor(serverName, url, headers, env) {
|
|
381
|
+
super(serverName);
|
|
382
|
+
this.url = url;
|
|
383
|
+
this.headers = headers;
|
|
384
|
+
this.env = env;
|
|
385
|
+
}
|
|
386
|
+
async connect() {
|
|
387
|
+
this.abortController = new AbortController();
|
|
388
|
+
await this.request('initialize', {
|
|
389
|
+
protocolVersion: '2024-11-05',
|
|
390
|
+
capabilities: {},
|
|
391
|
+
clientInfo: { name: 'ikie', version: VERSION },
|
|
237
392
|
});
|
|
393
|
+
await this.notify('notifications/initialized', {});
|
|
394
|
+
const toolsResult = await this.request('tools/list', {});
|
|
395
|
+
return toolsResult.tools ?? [];
|
|
238
396
|
}
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
397
|
+
async sendMessage(msg) {
|
|
398
|
+
const id = msg.id;
|
|
399
|
+
const isNotification = id == null;
|
|
400
|
+
const headers = { 'Content-Type': 'application/json', ...this.headers };
|
|
401
|
+
if (this.sessionId)
|
|
402
|
+
headers['Mcp-Session-Id'] = this.sessionId;
|
|
403
|
+
const res = await fetch(this.url, {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
headers,
|
|
406
|
+
body: JSON.stringify(msg),
|
|
407
|
+
signal: this.abortController?.signal,
|
|
408
|
+
});
|
|
409
|
+
if (!res.ok)
|
|
410
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
411
|
+
const sessionId = res.headers.get('Mcp-Session-Id');
|
|
412
|
+
if (sessionId)
|
|
413
|
+
this.sessionId = sessionId;
|
|
414
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
415
|
+
if (contentType.includes('text/event-stream')) {
|
|
416
|
+
await this.readSseResponse(res, id);
|
|
242
417
|
return;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
418
|
+
}
|
|
419
|
+
// Some servers return 202 Accepted with no body for notifications.
|
|
420
|
+
if (isNotification && res.status === 202)
|
|
421
|
+
return;
|
|
422
|
+
const body = await res.json();
|
|
423
|
+
if (!isNotification)
|
|
424
|
+
this.handleResponse(body);
|
|
425
|
+
}
|
|
426
|
+
async readSseResponse(res, expectedId) {
|
|
427
|
+
const reader = res.body?.getReader();
|
|
428
|
+
if (!reader)
|
|
429
|
+
return;
|
|
430
|
+
const decoder = new TextDecoder();
|
|
431
|
+
let buffer = '';
|
|
432
|
+
try {
|
|
433
|
+
while (true) {
|
|
434
|
+
const { done, value } = await reader.read();
|
|
435
|
+
if (done)
|
|
436
|
+
break;
|
|
437
|
+
buffer += decoder.decode(value, { stream: true });
|
|
438
|
+
const lines = buffer.split('\n');
|
|
439
|
+
buffer = lines.pop() ?? '';
|
|
440
|
+
let dataLine;
|
|
441
|
+
for (const line of lines) {
|
|
442
|
+
if (line.startsWith('data:'))
|
|
443
|
+
dataLine = line.slice(5).trim();
|
|
257
444
|
}
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
445
|
+
if (dataLine) {
|
|
446
|
+
try {
|
|
447
|
+
const msg = JSON.parse(dataLine);
|
|
448
|
+
if (expectedId != null && msg.id === expectedId) {
|
|
449
|
+
this.handleResponse(msg);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
262
456
|
}
|
|
263
457
|
}
|
|
264
|
-
|
|
265
|
-
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
reader.releaseLock();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
close() {
|
|
464
|
+
this.dead = true;
|
|
465
|
+
this.abortController?.abort();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// ─── SseClient (deprecated, best-effort) ──────────────────────────────────────
|
|
469
|
+
class SseClient extends BaseJsonRpcClient {
|
|
470
|
+
url;
|
|
471
|
+
headers;
|
|
472
|
+
env;
|
|
473
|
+
postUrl;
|
|
474
|
+
abortController;
|
|
475
|
+
constructor(serverName, url, headers, env) {
|
|
476
|
+
super(serverName);
|
|
477
|
+
this.url = url;
|
|
478
|
+
this.headers = headers;
|
|
479
|
+
this.env = env;
|
|
480
|
+
}
|
|
481
|
+
async connect() {
|
|
482
|
+
this.abortController = new AbortController();
|
|
483
|
+
// Open SSE stream to learn POST endpoint.
|
|
484
|
+
const res = await fetch(this.url, {
|
|
485
|
+
method: 'GET',
|
|
486
|
+
headers: this.headers,
|
|
487
|
+
signal: this.abortController.signal,
|
|
488
|
+
});
|
|
489
|
+
if (!res.ok)
|
|
490
|
+
throw new Error(`SSE connect failed: HTTP ${res.status}`);
|
|
491
|
+
if (!res.body)
|
|
492
|
+
throw new Error('SSE response has no body');
|
|
493
|
+
const reader = res.body.getReader();
|
|
494
|
+
const decoder = new TextDecoder();
|
|
495
|
+
let buffer = '';
|
|
496
|
+
let gotEndpoint = false;
|
|
497
|
+
while (!gotEndpoint) {
|
|
498
|
+
const { done, value } = await reader.read();
|
|
499
|
+
if (done)
|
|
500
|
+
break;
|
|
501
|
+
buffer += decoder.decode(value, { stream: true });
|
|
502
|
+
const lines = buffer.split('\n');
|
|
503
|
+
buffer = lines.pop() ?? '';
|
|
504
|
+
let i = 0;
|
|
505
|
+
while (i < lines.length) {
|
|
506
|
+
if (lines[i].startsWith('event: endpoint')) {
|
|
507
|
+
// The data line follows immediately after the event line.
|
|
508
|
+
if (i + 1 < lines.length && lines[i + 1].startsWith('data:')) {
|
|
509
|
+
this.postUrl = resolve(this.url, lines[i + 1].slice(5).trim());
|
|
510
|
+
gotEndpoint = true;
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
i++;
|
|
266
515
|
}
|
|
267
516
|
}
|
|
517
|
+
reader.releaseLock();
|
|
518
|
+
if (!this.postUrl)
|
|
519
|
+
this.postUrl = this.url;
|
|
520
|
+
await this.request('initialize', {
|
|
521
|
+
protocolVersion: '2024-11-05',
|
|
522
|
+
capabilities: {},
|
|
523
|
+
clientInfo: { name: 'ikie', version: VERSION },
|
|
524
|
+
});
|
|
525
|
+
await this.notify('notifications/initialized', {});
|
|
526
|
+
const toolsResult = await this.request('tools/list', {});
|
|
527
|
+
return toolsResult.tools ?? [];
|
|
528
|
+
}
|
|
529
|
+
async sendMessage(msg) {
|
|
530
|
+
if (!this.postUrl)
|
|
531
|
+
throw new Error('SSE endpoint not ready');
|
|
532
|
+
const id = msg.id;
|
|
533
|
+
const isNotification = id == null;
|
|
534
|
+
const res = await fetch(this.postUrl, {
|
|
535
|
+
method: 'POST',
|
|
536
|
+
headers: { 'Content-Type': 'application/json', ...this.headers },
|
|
537
|
+
body: JSON.stringify(msg),
|
|
538
|
+
signal: this.abortController?.signal,
|
|
539
|
+
});
|
|
540
|
+
if (!res.ok)
|
|
541
|
+
throw new Error(`HTTP ${res.status}`);
|
|
542
|
+
// Notifications may return 202 with no body.
|
|
543
|
+
if (isNotification && res.status === 202)
|
|
544
|
+
return;
|
|
545
|
+
const body = await res.json();
|
|
546
|
+
if (!isNotification)
|
|
547
|
+
this.handleResponse(body);
|
|
268
548
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
549
|
+
close() {
|
|
550
|
+
this.dead = true;
|
|
551
|
+
this.abortController?.abort();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// ─── Manager ──────────────────────────────────────────────────────────────────
|
|
555
|
+
export class McpManager {
|
|
556
|
+
clients = new Map();
|
|
557
|
+
tools = new Map();
|
|
558
|
+
statuses = new Map();
|
|
559
|
+
cwd;
|
|
560
|
+
loadScope(path) {
|
|
561
|
+
if (!existsSync(path))
|
|
562
|
+
return {};
|
|
563
|
+
try {
|
|
564
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
273
565
|
}
|
|
274
|
-
|
|
275
|
-
return {
|
|
566
|
+
catch {
|
|
567
|
+
return {};
|
|
276
568
|
}
|
|
277
|
-
return new Promise((resolve) => {
|
|
278
|
-
const id = Date.now();
|
|
279
|
-
const handlerKey = `${serverName}-${id}`;
|
|
280
|
-
const timeout = setTimeout(() => {
|
|
281
|
-
this.messageHandlers.delete(handlerKey);
|
|
282
|
-
resolve({ success: false, error: 'MCP tool call timeout' });
|
|
283
|
-
}, 30000);
|
|
284
|
-
this.messageHandlers.set(handlerKey, (message) => {
|
|
285
|
-
clearTimeout(timeout);
|
|
286
|
-
if (message.error) {
|
|
287
|
-
resolve({ success: false, error: message.error.message });
|
|
288
|
-
}
|
|
289
|
-
else {
|
|
290
|
-
resolve({ success: true, result: message.result });
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
this.sendMCPMessage(serverName, {
|
|
294
|
-
jsonrpc: '2.0',
|
|
295
|
-
id,
|
|
296
|
-
method: 'tools/call',
|
|
297
|
-
params: {
|
|
298
|
-
name: toolName,
|
|
299
|
-
arguments: args,
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
569
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
570
|
+
loadUserConfig() {
|
|
571
|
+
ensureUserDir();
|
|
572
|
+
this.migrateOldRegistry();
|
|
573
|
+
return this.loadScope(USER_MCP_FILE);
|
|
574
|
+
}
|
|
575
|
+
migrateOldRegistry() {
|
|
576
|
+
if (existsSync(USER_MCP_FILE) || !existsSync(OLD_REGISTRY_FILE))
|
|
577
|
+
return;
|
|
578
|
+
try {
|
|
579
|
+
const old = JSON.parse(readFileSync(OLD_REGISTRY_FILE, 'utf-8'));
|
|
580
|
+
if (!old.servers)
|
|
581
|
+
return;
|
|
582
|
+
const migrated = {};
|
|
583
|
+
for (const [name, s] of Object.entries(old.servers)) {
|
|
584
|
+
// Skip fake built-ins that point at non-existent files.
|
|
585
|
+
if (['database', 'puppeteer'].includes(name))
|
|
586
|
+
continue;
|
|
587
|
+
migrated[name] = {
|
|
588
|
+
type: 'stdio',
|
|
589
|
+
command: s.command,
|
|
590
|
+
args: s.args,
|
|
591
|
+
env: s.env,
|
|
592
|
+
description: s.description,
|
|
593
|
+
enabled: s.enabled ?? true,
|
|
594
|
+
autoStart: s.autoStart ?? false,
|
|
595
|
+
};
|
|
311
596
|
}
|
|
597
|
+
writeFileSync(USER_MCP_FILE, JSON.stringify({ mcpServers: migrated }, null, 2));
|
|
312
598
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
599
|
+
catch {
|
|
600
|
+
// ignore
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
resolveEnv(entry) {
|
|
604
|
+
return { ...process.env, ...entry.env };
|
|
605
|
+
}
|
|
606
|
+
createClient(name, entry) {
|
|
607
|
+
const env = this.resolveEnv(entry);
|
|
608
|
+
const transport = inferTransport(entry);
|
|
609
|
+
if (transport === 'stdio') {
|
|
610
|
+
if (!entry.command)
|
|
611
|
+
throw new Error(`stdio MCP server ${name} missing command`);
|
|
612
|
+
return new StdioClient(name, entry.command, entry.args ?? [], env);
|
|
613
|
+
}
|
|
614
|
+
if (transport === 'sse') {
|
|
615
|
+
if (!entry.url)
|
|
616
|
+
throw new Error(`sse MCP server ${name} missing url`);
|
|
617
|
+
return new SseClient(name, entry.url, entry.headers ?? {}, env);
|
|
618
|
+
}
|
|
619
|
+
if (!entry.url)
|
|
620
|
+
throw new Error(`http MCP server ${name} missing url`);
|
|
621
|
+
return new HttpClient(name, entry.url, entry.headers ?? {}, env);
|
|
622
|
+
}
|
|
623
|
+
async connectAll(cwd) {
|
|
624
|
+
this.cwd = cwd;
|
|
625
|
+
const local = this.loadScope(localMcpFile(cwd));
|
|
626
|
+
const project = this.loadScope(projectMcpFile(cwd));
|
|
627
|
+
const user = this.loadUserConfig();
|
|
628
|
+
const merged = mergeScopes(local, project, user);
|
|
629
|
+
await Promise.all(Object.entries(merged).map(async ([name, entry]) => {
|
|
630
|
+
if (entry.enabled === false || entry.autoStart === false) {
|
|
631
|
+
this.statuses.set(name, { name, enabled: entry.enabled !== false, connected: false, tools: [] });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
await this.connectServer(name, entry);
|
|
323
635
|
}));
|
|
324
636
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
637
|
+
async connectServer(name, entry) {
|
|
638
|
+
try {
|
|
639
|
+
const expanded = expandEnvVarsInEntry(entry, this.resolveEnv(entry));
|
|
640
|
+
const client = this.createClient(name, expanded);
|
|
641
|
+
const tools = await this.withTimeout(client.connect(), entry.timeout ?? 60000, `MCP server ${name} connect timed out`);
|
|
642
|
+
this.clients.set(name, client);
|
|
643
|
+
this.tools.set(name, tools);
|
|
644
|
+
this.statuses.set(name, { name, enabled: true, connected: true, tools });
|
|
329
645
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
646
|
+
catch (err) {
|
|
647
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
648
|
+
this.statuses.set(name, { name, enabled: true, connected: false, tools: [], error: message });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async withTimeout(p, ms, reason) {
|
|
652
|
+
let timer;
|
|
653
|
+
const timeout = new Promise((_, reject) => {
|
|
654
|
+
timer = setTimeout(() => reject(new Error(reason)), ms);
|
|
655
|
+
});
|
|
656
|
+
return Promise.race([p.then(v => { clearTimeout(timer); return v; }), timeout]);
|
|
657
|
+
}
|
|
658
|
+
getToolDefsSync() {
|
|
659
|
+
const defs = [];
|
|
660
|
+
for (const [server, tools] of this.tools) {
|
|
661
|
+
for (const t of tools) {
|
|
662
|
+
defs.push({
|
|
663
|
+
type: 'function',
|
|
664
|
+
function: {
|
|
665
|
+
name: mcpToolName(server, t.name),
|
|
666
|
+
description: t.description ?? `MCP tool ${t.name} from ${server}`,
|
|
667
|
+
parameters: (t.inputSchema ?? { type: 'object', properties: {} }),
|
|
668
|
+
},
|
|
669
|
+
});
|
|
338
670
|
}
|
|
339
671
|
}
|
|
672
|
+
return defs;
|
|
340
673
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
674
|
+
async callTool(qualified, args) {
|
|
675
|
+
const map = buildReverseToolMap(this.tools);
|
|
676
|
+
const q = map.get(qualified);
|
|
677
|
+
if (!q)
|
|
678
|
+
return `Error: unknown MCP tool ${qualified}`;
|
|
679
|
+
const client = this.clients.get(q.server);
|
|
680
|
+
if (!client)
|
|
681
|
+
return `Error: MCP server ${q.server} is not connected`;
|
|
682
|
+
const entry = this.findServerEntry(q.server);
|
|
683
|
+
const timeout = entry?.timeout ?? 60000;
|
|
684
|
+
try {
|
|
685
|
+
return await client.callTool(q.tool, args, timeout);
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
return `Error calling MCP tool ${qualified}: ${err instanceof Error ? err.message : String(err)}`;
|
|
344
689
|
}
|
|
345
690
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
691
|
+
findServerEntry(name) {
|
|
692
|
+
const cwd = this.cwd ?? process.cwd();
|
|
693
|
+
const merged = mergeScopes(this.loadScope(localMcpFile(cwd)), this.loadScope(projectMcpFile(cwd)), this.loadUserConfig());
|
|
694
|
+
return merged[name];
|
|
695
|
+
}
|
|
696
|
+
listServers() {
|
|
697
|
+
return [...this.statuses.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
351
698
|
}
|
|
352
|
-
|
|
699
|
+
async addServer(name, entry, scope) {
|
|
700
|
+
const cwd = this.cwd ?? process.cwd();
|
|
701
|
+
const path = scope === 'user' ? USER_MCP_FILE : scope === 'project' ? projectMcpFile(cwd) : localMcpFile(cwd);
|
|
702
|
+
const cfg = this.loadScope(path);
|
|
703
|
+
cfg.mcpServers = cfg.mcpServers ?? {};
|
|
704
|
+
cfg.mcpServers[name] = entry;
|
|
705
|
+
ensureParentDir(path);
|
|
706
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2));
|
|
707
|
+
await this.connectServer(name, entry);
|
|
708
|
+
}
|
|
709
|
+
removeServer(name, scope) {
|
|
710
|
+
const cwd = this.cwd ?? process.cwd();
|
|
711
|
+
const path = scope === 'user' ? USER_MCP_FILE : scope === 'project' ? projectMcpFile(cwd) : localMcpFile(cwd);
|
|
712
|
+
const cfg = this.loadScope(path);
|
|
713
|
+
if (!cfg.mcpServers?.[name])
|
|
714
|
+
return false;
|
|
715
|
+
delete cfg.mcpServers[name];
|
|
716
|
+
ensureParentDir(path);
|
|
717
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2));
|
|
718
|
+
const client = this.clients.get(name);
|
|
719
|
+
if (client) {
|
|
720
|
+
client.close();
|
|
721
|
+
this.clients.delete(name);
|
|
722
|
+
}
|
|
723
|
+
this.tools.delete(name);
|
|
724
|
+
this.statuses.delete(name);
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
async reconnectServer(name) {
|
|
728
|
+
const cwd = this.cwd ?? process.cwd();
|
|
729
|
+
const merged = mergeScopes(this.loadScope(localMcpFile(cwd)), this.loadScope(projectMcpFile(cwd)), this.loadUserConfig());
|
|
730
|
+
const entry = merged[name];
|
|
731
|
+
if (!entry)
|
|
732
|
+
throw new Error(`MCP server ${name} not found in config`);
|
|
733
|
+
const client = this.clients.get(name);
|
|
734
|
+
if (client)
|
|
735
|
+
client.close();
|
|
736
|
+
this.clients.delete(name);
|
|
737
|
+
this.tools.delete(name);
|
|
738
|
+
await this.connectServer(name, entry);
|
|
739
|
+
}
|
|
740
|
+
disconnectAll() {
|
|
741
|
+
for (const client of this.clients.values())
|
|
742
|
+
client.close();
|
|
743
|
+
this.clients.clear();
|
|
744
|
+
this.tools.clear();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function ensureParentDir(path) {
|
|
748
|
+
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : path.slice(0, path.lastIndexOf('\\'));
|
|
749
|
+
if (parent && !existsSync(parent))
|
|
750
|
+
mkdirSync(parent, { recursive: true });
|
|
751
|
+
}
|
|
752
|
+
let manager = null;
|
|
753
|
+
export function getMcpManager() {
|
|
754
|
+
if (!manager)
|
|
755
|
+
manager = new McpManager();
|
|
756
|
+
return manager;
|
|
353
757
|
}
|
|
758
|
+
// Out of scope: WebSocket transport, OAuth/dynamic client registration,
|
|
759
|
+
// MCP resources/prompts, plugin-bundled servers.
|