kiku-agent 1.0.5 → 1.0.7

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.
@@ -1,364 +1,436 @@
1
- import { promises as fs } from 'node:fs';
2
- import path from 'node:path';
3
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
7
- import { KIKU_DATA_DIR } from './appPaths.js';
8
-
9
- export class McpClientManager {
10
- constructor(options = {}) {
11
- this.clientInfo = options.clientInfo || { name: 'kiku-agent', version: '1.0.5' };
12
- this.autoLoad = options.autoLoad !== false;
13
- this.servers = new Map();
14
- this.toolRegistry = new Map();
15
- }
16
-
17
- async connect(serverName, serverConfig = {}, { persist = true } = {}) {
18
- const name = String(serverName || '').trim();
19
- if (!name) throw new Error('Server name is required to connect to MCP.');
20
-
21
- if (this.servers.has(name)) {
22
- await this.disconnect(name, { persist: false });
23
- }
24
-
25
- let transport;
26
- let cmd = serverConfig.command;
27
- let args = serverConfig.args || [];
28
- if (Array.isArray(cmd)) {
29
- args = [...cmd.slice(1), ...args];
30
- cmd = cmd[0];
31
- }
32
- const isStdio = Boolean(cmd || serverConfig.type === 'stdio' || serverConfig.type === 'local');
33
- const isSse = serverConfig.type === 'sse';
34
-
35
- if (isStdio) {
36
- transport = new StdioClientTransport({
37
- command: cmd,
38
- args: args,
39
- env: { ...process.env, ...(serverConfig.env || serverConfig.environment || {}) },
40
- cwd: serverConfig.cwd || process.cwd(),
41
- stderr: 'pipe',
42
- });
43
- } else if (isSse) {
44
- const url = new URL(serverConfig.url);
45
- transport = new SSEClientTransport(url, {
46
- eventSourceInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
47
- requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
48
- });
49
- } else {
50
- const targetUrl = serverConfig.url || serverConfig.serverUrl || serverConfig.httpUrl;
51
- const url = new URL(targetUrl);
52
- const transportOpts = {};
53
- if (serverConfig.headers) transportOpts.headers = serverConfig.headers;
54
- transport = new StreamableHTTPClientTransport(url, transportOpts);
55
- }
56
-
57
- const client = new Client(this.clientInfo, {
58
- capabilities: {
59
- tools: {},
60
- resources: {},
61
- prompts: {},
62
- },
63
- });
64
-
65
- try {
66
- await client.connect(transport);
67
-
68
- let tools = [];
69
- let resources = [];
70
- let prompts = [];
71
-
72
- try {
73
- const toolsResult = await client.listTools();
74
- tools = toolsResult.tools || [];
75
- } catch (err) {
76
- console.warn(`[MCP] Failed to list tools from ${name}: ${err.message}`);
77
- }
78
-
79
- try {
80
- const resResult = await client.listResources();
81
- resources = resResult.resources || [];
82
- } catch {}
83
-
84
- try {
85
- const promptResult = await client.listPrompts();
86
- prompts = promptResult.prompts || [];
87
- } catch {}
88
-
89
- const serverEntry = {
90
- name,
91
- config: serverConfig,
92
- client,
93
- transport,
94
- tools,
95
- resources,
96
- prompts,
97
- connectedAt: new Date().toISOString(),
98
- };
99
-
100
- this.servers.set(name, serverEntry);
101
- this._rebuildToolRegistry();
102
-
103
- if (persist) {
104
- await this.savePersistedConfig().catch(() => {});
105
- }
106
-
107
- return {
108
- success: true,
109
- serverName: name,
110
- toolsCount: tools.length,
111
- resourcesCount: resources.length,
112
- promptsCount: prompts.length,
113
- tools,
114
- };
115
- } catch (err) {
116
- await transport.close().catch(() => {});
117
- throw new Error(`Failed connecting to MCP server "${name}": ${err.message}`);
118
- }
119
- }
120
-
121
- async disconnect(serverName, { persist = true } = {}) {
122
- const entry = this.servers.get(serverName);
123
- if (!entry) return false;
124
-
125
- try {
126
- await entry.client.close().catch(() => {});
127
- await entry.transport.close().catch(() => {});
128
- if (entry.transport?._process) {
129
- try {
130
- entry.transport._process.kill('SIGKILL');
131
- entry.transport._process.stdout?.destroy?.();
132
- entry.transport._process.stderr?.destroy?.();
133
- entry.transport._process.stdin?.destroy?.();
134
- entry.transport._process.unref?.();
135
- } catch {}
136
- }
137
- } catch {}
138
-
139
- this.servers.delete(serverName);
140
- this._rebuildToolRegistry();
141
-
142
- if (persist) {
143
- await this.savePersistedConfig().catch(() => {});
144
- }
145
- return true;
146
- }
147
-
148
- async disconnectAll() {
149
- const names = [...this.servers.keys()];
150
- for (const name of names) {
151
- await this.disconnect(name, { persist: false });
152
- }
153
- }
154
-
155
- _rebuildToolRegistry() {
156
- this.toolRegistry.clear();
157
-
158
- for (const [serverName, serverEntry] of this.servers.entries()) {
159
- for (const tool of serverEntry.tools) {
160
- const directName = `mcp_${serverName}_${tool.name}`;
161
- this.toolRegistry.set(directName, {
162
- serverName,
163
- originalName: tool.name,
164
- schema: tool,
165
- });
166
-
167
- if (!this.toolRegistry.has(tool.name)) {
168
- this.toolRegistry.set(tool.name, {
169
- serverName,
170
- originalName: tool.name,
171
- schema: tool,
172
- });
173
- }
174
- }
175
- }
176
- }
177
-
178
- isMcpTool(toolName) {
179
- return this.toolRegistry.has(toolName);
180
- }
181
-
182
- getToolsSchema() {
183
- const schemas = [];
184
- const seenNames = new Set();
185
-
186
- for (const [serverName, serverEntry] of this.servers.entries()) {
187
- for (const tool of serverEntry.tools) {
188
- const toolName = `mcp_${serverName}_${tool.name}`;
189
- if (seenNames.has(toolName)) continue;
190
- seenNames.add(toolName);
191
-
192
- schemas.push({
193
- type: 'function',
194
- function: {
195
- name: toolName,
196
- description: `[MCP Server: ${serverName}] ${tool.description || `Tool from ${serverName}`}`,
197
- parameters: tool.inputSchema || { type: 'object', properties: {} },
198
- },
199
- _mcp: {
200
- serverName,
201
- originalName: tool.name,
202
- },
203
- });
204
- }
205
- }
206
-
207
- return schemas;
208
- }
209
-
210
- async callTool(toolName, args = {}) {
211
- const registration = this.toolRegistry.get(toolName);
212
- if (!registration) {
213
- return {
214
- status: 'error',
215
- message: `MCP tool "${toolName}" is not registered on any active MCP server.`,
216
- };
217
- }
218
-
219
- const serverEntry = this.servers.get(registration.serverName);
220
- if (!serverEntry) {
221
- return {
222
- status: 'error',
223
- message: `MCP server "${registration.serverName}" is disconnected.`,
224
- };
225
- }
226
-
227
- try {
228
- const response = await serverEntry.client.callTool({
229
- name: registration.originalName,
230
- arguments: args,
231
- });
232
-
233
- if (response.isError) {
234
- const errText = response.content?.map(c => c.text).filter(Boolean).join('\n') || 'Unknown MCP tool error';
235
- return {
236
- status: 'error',
237
- server: registration.serverName,
238
- tool: registration.originalName,
239
- message: errText,
240
- };
241
- }
242
-
243
- let parsedPayload = null;
244
- let textContent = '';
245
- if (response.content && Array.isArray(response.content)) {
246
- for (const item of response.content) {
247
- if (item.type === 'text' && item.text) {
248
- textContent += item.text + '\n';
249
- try {
250
- if (!parsedPayload && item.text.trim().startsWith('{') && item.text.trim().endsWith('}')) {
251
- parsedPayload = JSON.parse(item.text);
252
- }
253
- } catch {}
254
- }
255
- }
256
- }
257
-
258
- return {
259
- status: 'success',
260
- server: registration.serverName,
261
- tool: registration.originalName,
262
- ...(parsedPayload && typeof parsedPayload === 'object' ? parsedPayload : {}),
263
- content: textContent.trim(),
264
- _rawMcp: response.content,
265
- };
266
- } catch (err) {
267
- return {
268
- status: 'error',
269
- server: registration.serverName,
270
- tool: registration.originalName,
271
- message: `Failed executing MCP tool "${registration.originalName}": ${err.message}`,
272
- };
273
- }
274
- }
275
-
276
- getStatus() {
277
- const list = [];
278
- for (const [name, server] of this.servers.entries()) {
279
- list.push({
280
- name,
281
- type: server.config.type || (server.config.command ? 'stdio' : 'streamable-http'),
282
- toolsCount: server.tools.length,
283
- resourcesCount: server.resources.length,
284
- promptsCount: server.prompts.length,
285
- connectedAt: server.connectedAt,
286
- });
287
- }
288
- return {
289
- activeServersCount: this.servers.size,
290
- totalToolsCount: this.getToolsSchema().length,
291
- servers: list,
292
- };
293
- }
294
-
295
- async savePersistedConfig() {
296
- try {
297
- const dir = KIKU_DATA_DIR;
298
- await fs.mkdir(dir, { recursive: true });
299
- const filePath = path.join(dir, 'mcp_servers.json');
300
-
301
- const serversObj = {};
302
- for (const [name, entry] of this.servers.entries()) {
303
- serversObj[name] = entry.config;
304
- }
305
-
306
- await fs.writeFile(filePath, JSON.stringify({ mcpServers: serversObj }, null, 2), 'utf8');
307
- return true;
308
- } catch {
309
- return false;
310
- }
311
- }
312
-
313
- async loadPersistedServers() {
314
- if (this.autoLoad === false) return [];
315
- const results = [];
316
- const candidates = [
317
- path.join(KIKU_DATA_DIR, 'mcp_servers.json'),
318
- path.join(process.cwd(), 'opencode.json'),
319
- path.join(process.cwd(), '.mcp.json'),
320
- path.join(process.cwd(), 'mcp.json'),
321
- ];
322
-
323
- for (const filePath of candidates) {
324
- try {
325
- const content = await fs.readFile(filePath, 'utf8');
326
- const parsed = JSON.parse(content);
327
- const serversObj = parsed.mcpServers || parsed.mcp || {};
328
- for (const [name, cfg] of Object.entries(serversObj)) {
329
- if (cfg.enabled === false || cfg.disabled === true) continue;
330
- if (this.servers.has(name)) continue;
331
- try {
332
- const res = await this.connect(name, cfg, { persist: false });
333
- results.push(res);
334
- } catch (err) {
335
- results.push({ success: false, serverName: name, error: err.message });
336
- }
337
- }
338
- } catch {}
339
- }
340
-
341
- return results;
342
- }
343
-
344
- async loadConfigFile(configPath) {
345
- const resolvedPath = path.resolve(process.cwd(), configPath);
346
- const content = await fs.readFile(resolvedPath, 'utf8');
347
- const parsed = JSON.parse(content);
348
-
349
- const serversObj = parsed.mcpServers || parsed.mcp || {};
350
- const results = [];
351
-
352
- for (const [name, cfg] of Object.entries(serversObj)) {
353
- if (cfg.enabled === false || cfg.disabled === true) continue;
354
- try {
355
- const res = await this.connect(name, cfg);
356
- results.push(res);
357
- } catch (err) {
358
- results.push({ success: false, serverName: name, error: err.message });
359
- }
360
- }
361
-
362
- return results;
363
- }
364
- }
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { PACKAGE_VERSION } from './version.js';
5
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
7
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
8
+ import { KIKU_DATA_DIR } from './appPaths.js';
9
+
10
+ export class McpClientManager {
11
+ constructor(options = {}) {
12
+ this.clientInfo = options.clientInfo || { name: 'kiku-agent', version: PACKAGE_VERSION };
13
+ this.autoLoad = options.autoLoad !== false;
14
+ this.servers = new Map();
15
+ this.persistedConfigs = new Map();
16
+ this.toolRegistry = new Map();
17
+ }
18
+
19
+ async connect(serverName, serverConfig = {}, { persist = true } = {}) {
20
+ const name = String(serverName || '').trim();
21
+ if (!name) throw new Error('Server name is required to connect to MCP.');
22
+
23
+ if (this.servers.has(name)) {
24
+ await this.disconnect(name, { persist: false });
25
+ }
26
+
27
+ let transport;
28
+ let cmd = serverConfig.command;
29
+ let args = serverConfig.args || [];
30
+ if (Array.isArray(cmd)) {
31
+ args = [...cmd.slice(1), ...args];
32
+ cmd = cmd[0];
33
+ }
34
+ const isStdio = Boolean(cmd || serverConfig.type === 'stdio' || serverConfig.type === 'local');
35
+ const isSse = serverConfig.type === 'sse';
36
+
37
+ if (isStdio) {
38
+ transport = new StdioClientTransport({
39
+ command: cmd,
40
+ args: args,
41
+ env: { ...process.env, ...(serverConfig.env || serverConfig.environment || {}) },
42
+ cwd: serverConfig.cwd || process.cwd(),
43
+ stderr: 'pipe',
44
+ });
45
+ } else if (isSse) {
46
+ const url = new URL(serverConfig.url);
47
+ transport = new SSEClientTransport(url, {
48
+ eventSourceInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
49
+ requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
50
+ });
51
+ } else {
52
+ const targetUrl = serverConfig.url || serverConfig.serverUrl || serverConfig.httpUrl;
53
+ const url = new URL(targetUrl);
54
+ const transportOpts = {};
55
+ if (serverConfig.headers) transportOpts.headers = serverConfig.headers;
56
+ transport = new StreamableHTTPClientTransport(url, transportOpts);
57
+ }
58
+
59
+ const client = new Client(this.clientInfo, {
60
+ capabilities: {
61
+ tools: {},
62
+ resources: {},
63
+ prompts: {},
64
+ },
65
+ });
66
+
67
+ try {
68
+ await client.connect(transport);
69
+
70
+ let tools = [];
71
+ let resources = [];
72
+ let prompts = [];
73
+
74
+ try {
75
+ const toolsResult = await client.listTools();
76
+ tools = toolsResult.tools || [];
77
+ } catch (err) {
78
+ console.warn(`[MCP] Failed to list tools from ${name}: ${err.message}`);
79
+ }
80
+
81
+ try {
82
+ const resResult = await client.listResources();
83
+ resources = resResult.resources || [];
84
+ } catch {}
85
+
86
+ try {
87
+ const promptResult = await client.listPrompts();
88
+ prompts = promptResult.prompts || [];
89
+ } catch {}
90
+
91
+ const cleanConfig = { ...serverConfig, enabled: true };
92
+ delete cleanConfig.lastError;
93
+
94
+ const serverEntry = {
95
+ name,
96
+ config: cleanConfig,
97
+ client,
98
+ transport,
99
+ tools,
100
+ resources,
101
+ prompts,
102
+ connectedAt: new Date().toISOString(),
103
+ };
104
+
105
+ this.servers.set(name, serverEntry);
106
+ this.persistedConfigs.set(name, cleanConfig);
107
+ this._rebuildToolRegistry();
108
+
109
+ if (persist) {
110
+ await this.savePersistedConfig().catch(() => {});
111
+ }
112
+
113
+ return {
114
+ success: true,
115
+ serverName: name,
116
+ toolsCount: tools.length,
117
+ resourcesCount: resources.length,
118
+ promptsCount: prompts.length,
119
+ tools,
120
+ };
121
+ } catch (err) {
122
+ await transport.close().catch(() => {});
123
+ this.persistedConfigs.set(name, { ...serverConfig, lastError: err.message });
124
+ throw new Error(`Failed connecting to MCP server "${name}": ${err.message}`);
125
+ }
126
+ }
127
+
128
+ async disconnect(serverName, { persist = false } = {}) {
129
+ const entry = this.servers.get(serverName);
130
+ if (!entry) return false;
131
+
132
+ try {
133
+ await entry.client.close().catch(() => {});
134
+ await entry.transport.close().catch(() => {});
135
+ if (entry.transport?._process) {
136
+ try {
137
+ entry.transport._process.kill('SIGKILL');
138
+ entry.transport._process.stdout?.destroy?.();
139
+ entry.transport._process.stderr?.destroy?.();
140
+ entry.transport._process.stdin?.destroy?.();
141
+ entry.transport._process.unref?.();
142
+ } catch {}
143
+ }
144
+ } catch {}
145
+
146
+ this.servers.delete(serverName);
147
+ this._rebuildToolRegistry();
148
+
149
+ if (persist) {
150
+ await this.savePersistedConfig().catch(() => {});
151
+ }
152
+ return true;
153
+ }
154
+
155
+ async remove(serverName, { persist = true } = {}) {
156
+ await this.disconnect(serverName, { persist: false });
157
+ const hadPersisted = this.persistedConfigs.delete(serverName);
158
+ if (persist) {
159
+ await this.savePersistedConfig().catch(() => {});
160
+ }
161
+ return hadPersisted;
162
+ }
163
+
164
+ async disconnectAll() {
165
+ const names = [...this.servers.keys()];
166
+ for (const name of names) {
167
+ await this.disconnect(name, { persist: false });
168
+ }
169
+ }
170
+
171
+ async reconnect(serverName) {
172
+ const cfg = this.persistedConfigs.get(serverName);
173
+ if (!cfg) {
174
+ throw new Error(`MCP server "${serverName}" is not configured.`);
175
+ }
176
+ return this.connect(serverName, cfg, { persist: true });
177
+ }
178
+
179
+ async reconnectAll() {
180
+ const results = [];
181
+ for (const [name, cfg] of this.persistedConfigs.entries()) {
182
+ if (cfg.enabled === false || cfg.disabled === true) continue;
183
+ try {
184
+ const res = await this.connect(name, cfg, { persist: false });
185
+ results.push(res);
186
+ } catch (err) {
187
+ results.push({ success: false, serverName: name, error: err.message });
188
+ }
189
+ }
190
+ return results;
191
+ }
192
+
193
+ _rebuildToolRegistry() {
194
+ this.toolRegistry.clear();
195
+
196
+ for (const [serverName, serverEntry] of this.servers.entries()) {
197
+ for (const tool of serverEntry.tools) {
198
+ const directName = `mcp_${serverName}_${tool.name}`;
199
+ this.toolRegistry.set(directName, {
200
+ serverName,
201
+ originalName: tool.name,
202
+ schema: tool,
203
+ });
204
+
205
+ if (!this.toolRegistry.has(tool.name)) {
206
+ this.toolRegistry.set(tool.name, {
207
+ serverName,
208
+ originalName: tool.name,
209
+ schema: tool,
210
+ });
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ isMcpTool(toolName) {
217
+ return this.toolRegistry.has(toolName);
218
+ }
219
+
220
+ getToolsSchema() {
221
+ const schemas = [];
222
+ const seenNames = new Set();
223
+
224
+ for (const [serverName, serverEntry] of this.servers.entries()) {
225
+ for (const tool of serverEntry.tools) {
226
+ const toolName = `mcp_${serverName}_${tool.name}`;
227
+ if (seenNames.has(toolName)) continue;
228
+ seenNames.add(toolName);
229
+
230
+ schemas.push({
231
+ type: 'function',
232
+ function: {
233
+ name: toolName,
234
+ description: `[MCP Server: ${serverName}] ${tool.description || `Tool from ${serverName}`}`,
235
+ parameters: tool.inputSchema || { type: 'object', properties: {} },
236
+ },
237
+ _mcp: {
238
+ serverName,
239
+ originalName: tool.name,
240
+ },
241
+ });
242
+ }
243
+ }
244
+
245
+ return schemas;
246
+ }
247
+
248
+ async callTool(toolName, args = {}) {
249
+ const registration = this.toolRegistry.get(toolName);
250
+ if (!registration) {
251
+ return {
252
+ status: 'error',
253
+ message: `MCP tool "${toolName}" is not registered on any active MCP server.`,
254
+ };
255
+ }
256
+
257
+ const serverEntry = this.servers.get(registration.serverName);
258
+ if (!serverEntry) {
259
+ return {
260
+ status: 'error',
261
+ message: `MCP server "${registration.serverName}" is disconnected.`,
262
+ };
263
+ }
264
+
265
+ try {
266
+ const response = await serverEntry.client.callTool({
267
+ name: registration.originalName,
268
+ arguments: args,
269
+ });
270
+
271
+ if (response.isError) {
272
+ const errText = response.content?.map(c => c.text).filter(Boolean).join('\n') || 'Unknown MCP tool error';
273
+ return {
274
+ status: 'error',
275
+ server: registration.serverName,
276
+ tool: registration.originalName,
277
+ message: errText,
278
+ };
279
+ }
280
+
281
+ let parsedPayload = null;
282
+ let textContent = '';
283
+ if (response.content && Array.isArray(response.content)) {
284
+ for (const item of response.content) {
285
+ if (item.type === 'text' && item.text) {
286
+ textContent += item.text + '\n';
287
+ try {
288
+ if (!parsedPayload && item.text.trim().startsWith('{') && item.text.trim().endsWith('}')) {
289
+ parsedPayload = JSON.parse(item.text);
290
+ }
291
+ } catch {}
292
+ }
293
+ }
294
+ }
295
+
296
+ return {
297
+ status: 'success',
298
+ server: registration.serverName,
299
+ tool: registration.originalName,
300
+ ...(parsedPayload && typeof parsedPayload === 'object' ? parsedPayload : {}),
301
+ content: textContent.trim(),
302
+ _rawMcp: response.content,
303
+ };
304
+ } catch (err) {
305
+ return {
306
+ status: 'error',
307
+ server: registration.serverName,
308
+ tool: registration.originalName,
309
+ message: `Failed executing MCP tool "${registration.originalName}": ${err.message}`,
310
+ };
311
+ }
312
+ }
313
+
314
+ getStatus() {
315
+ const list = [];
316
+ const seen = new Set();
317
+
318
+ for (const [name, server] of this.servers.entries()) {
319
+ seen.add(name);
320
+ list.push({
321
+ name,
322
+ status: 'connected',
323
+ type: server.config.type || (server.config.command ? 'stdio' : 'streamable-http'),
324
+ toolsCount: server.tools.length,
325
+ resourcesCount: server.resources.length,
326
+ promptsCount: server.prompts.length,
327
+ connectedAt: server.connectedAt,
328
+ config: server.config,
329
+ });
330
+ }
331
+
332
+ for (const [name, cfg] of this.persistedConfigs.entries()) {
333
+ if (seen.has(name)) continue;
334
+ list.push({
335
+ name,
336
+ status: cfg.lastError ? 'error' : (cfg.enabled === false ? 'disabled' : 'saved_offline'),
337
+ type: cfg.type || (cfg.command ? 'stdio' : 'streamable-http'),
338
+ toolsCount: 0,
339
+ resourcesCount: 0,
340
+ promptsCount: 0,
341
+ error: cfg.lastError || null,
342
+ config: cfg,
343
+ });
344
+ }
345
+
346
+ return {
347
+ activeServersCount: this.servers.size,
348
+ totalConfiguredCount: this.persistedConfigs.size,
349
+ totalToolsCount: this.getToolsSchema().length,
350
+ servers: list,
351
+ };
352
+ }
353
+
354
+ async savePersistedConfig(customPath) {
355
+ try {
356
+ const filePath = customPath || path.join(KIKU_DATA_DIR, 'mcp_servers.json');
357
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
358
+
359
+ const serversObj = {};
360
+ for (const [name, cfg] of this.persistedConfigs.entries()) {
361
+ const clean = { ...cfg };
362
+ delete clean.lastError;
363
+ serversObj[name] = clean;
364
+ }
365
+ for (const [name, entry] of this.servers.entries()) {
366
+ if (!serversObj[name]) {
367
+ serversObj[name] = entry.config;
368
+ }
369
+ }
370
+
371
+ await fs.writeFile(filePath, JSON.stringify({ mcpServers: serversObj }, null, 2), 'utf8');
372
+ return { success: true, filePath, count: Object.keys(serversObj).length };
373
+ } catch (err) {
374
+ return { success: false, error: err.message };
375
+ }
376
+ }
377
+
378
+ async saveToWorkspace(filename = '.mcp.json') {
379
+ const workspacePath = path.resolve(process.cwd(), filename);
380
+ return this.savePersistedConfig(workspacePath);
381
+ }
382
+
383
+ async loadPersistedServers() {
384
+ if (this.autoLoad === false) return [];
385
+ const results = [];
386
+ const candidates = [
387
+ path.join(KIKU_DATA_DIR, 'mcp_servers.json'),
388
+ path.join(process.cwd(), 'opencode.json'),
389
+ path.join(process.cwd(), '.mcp.json'),
390
+ path.join(process.cwd(), 'mcp.json'),
391
+ ];
392
+
393
+ for (const filePath of candidates) {
394
+ try {
395
+ const content = await fs.readFile(filePath, 'utf8');
396
+ const parsed = JSON.parse(content);
397
+ const serversObj = parsed.mcpServers || parsed.mcp || {};
398
+ for (const [name, cfg] of Object.entries(serversObj)) {
399
+ this.persistedConfigs.set(name, cfg);
400
+ if (cfg.enabled === false || cfg.disabled === true) continue;
401
+ if (this.servers.has(name)) continue;
402
+ try {
403
+ const res = await this.connect(name, cfg, { persist: false });
404
+ results.push(res);
405
+ } catch (err) {
406
+ results.push({ success: false, serverName: name, error: err.message });
407
+ }
408
+ }
409
+ } catch {}
410
+ }
411
+
412
+ return results;
413
+ }
414
+
415
+ async loadConfigFile(configPath) {
416
+ const resolvedPath = path.resolve(process.cwd(), configPath);
417
+ const content = await fs.readFile(resolvedPath, 'utf8');
418
+ const parsed = JSON.parse(content);
419
+
420
+ const serversObj = parsed.mcpServers || parsed.mcp || {};
421
+ const results = [];
422
+
423
+ for (const [name, cfg] of Object.entries(serversObj)) {
424
+ this.persistedConfigs.set(name, cfg);
425
+ if (cfg.enabled === false || cfg.disabled === true) continue;
426
+ try {
427
+ const res = await this.connect(name, cfg);
428
+ results.push(res);
429
+ } catch (err) {
430
+ results.push({ success: false, serverName: name, error: err.message });
431
+ }
432
+ }
433
+
434
+ return results;
435
+ }
436
+ }