cli-surf 0.11.0 → 0.12.1

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/cliMcp.js ADDED
@@ -0,0 +1,553 @@
1
+ /**
2
+ * MCP-клиент для surf — как в Codex / Claude Code, без внешних зависимостей.
3
+ * Транспорт stdio (npx-команды и локальные серверы): JSON-RPC поверх stdin/stdout
4
+ * дочернего процесса (initialize → tools/list → tools/call).
5
+ *
6
+ * Конфиг:
7
+ * ~/.surf-cli/mcp.json — пользовательский уровень
8
+ * <cwd>/.surf/mcp.json — уровень проекта (перекрывает)
9
+ * Формат: { "mcpServers": { "<name>": { command, args?, env?, cwd?, timeout? } } }
10
+ */
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { spawn } from 'node:child_process';
15
+ export function userMcpPath() {
16
+ return path.join(os.homedir(), '.surf-cli', 'mcp.json');
17
+ }
18
+ export function projectMcpPath(cwd) {
19
+ return path.join(path.resolve(cwd), '.surf', 'mcp.json');
20
+ }
21
+ export function validMcpName(name) {
22
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name);
23
+ }
24
+ export function sanitizeServerConfig(raw) {
25
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
26
+ return null;
27
+ const rec = raw;
28
+ const hasCommand = typeof rec.command === 'string' && rec.command.trim().length > 0;
29
+ const hasUrl = typeof rec.url === 'string' && /^https?:\/\/.+/i.test(rec.url.trim());
30
+ if (!hasCommand && !hasUrl)
31
+ return null;
32
+ const cfg = {};
33
+ if (hasCommand) {
34
+ cfg.command = rec.command.trim();
35
+ if (rec.args !== undefined) {
36
+ if (!Array.isArray(rec.args) || !rec.args.every(a => typeof a === 'string'))
37
+ return null;
38
+ cfg.args = rec.args;
39
+ }
40
+ if (rec.env !== undefined) {
41
+ if (!rec.env || typeof rec.env !== 'object' || Array.isArray(rec.env))
42
+ return null;
43
+ const env = {};
44
+ for (const [k, v] of Object.entries(rec.env)) {
45
+ if (typeof v !== 'string')
46
+ return null;
47
+ env[k] = v;
48
+ }
49
+ cfg.env = env;
50
+ }
51
+ if (rec.cwd !== undefined) {
52
+ if (typeof rec.cwd !== 'string' || !rec.cwd.trim())
53
+ return null;
54
+ cfg.cwd = rec.cwd;
55
+ }
56
+ }
57
+ if (hasUrl) {
58
+ cfg.url = rec.url.trim();
59
+ if (rec.headers !== undefined) {
60
+ if (!rec.headers || typeof rec.headers !== 'object' || Array.isArray(rec.headers))
61
+ return null;
62
+ const headers = {};
63
+ for (const [k, v] of Object.entries(rec.headers)) {
64
+ if (typeof v !== 'string')
65
+ return null;
66
+ headers[k] = v;
67
+ }
68
+ cfg.headers = headers;
69
+ }
70
+ }
71
+ if (rec.timeout !== undefined) {
72
+ const t = Number(rec.timeout);
73
+ if (!Number.isFinite(t))
74
+ return null;
75
+ cfg.timeout = Math.min(600000, Math.max(1000, Math.floor(t)));
76
+ }
77
+ return cfg;
78
+ }
79
+ /** Читать один конфиг-файл. Возвращает пары + число битых записей. */
80
+ export function loadMcpFile(filePath) {
81
+ const out = {};
82
+ let invalid = 0;
83
+ let raw;
84
+ try {
85
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
86
+ }
87
+ catch {
88
+ return { servers: out, invalid: 0 };
89
+ }
90
+ const section = (raw && typeof raw === 'object' ? raw.mcpServers : null) ?? raw;
91
+ if (!section || typeof section !== 'object' || Array.isArray(section))
92
+ return { servers: out, invalid: 0 };
93
+ for (const [name, cfg] of Object.entries(section)) {
94
+ const clean = sanitizeServerConfig(cfg);
95
+ if (!validMcpName(name) || !clean) {
96
+ invalid += 1;
97
+ continue;
98
+ }
99
+ out[name] = clean;
100
+ }
101
+ return { servers: out, invalid };
102
+ }
103
+ /** Мёрдж user + project (project побеждает). */
104
+ export function loadMcpConfig(cwd) {
105
+ const user = loadMcpFile(userMcpPath());
106
+ const project = loadMcpFile(projectMcpPath(cwd));
107
+ return { servers: { ...user.servers, ...project.servers }, invalid: user.invalid + project.invalid };
108
+ }
109
+ export function writeMcpServer(filePath, name, cfg) {
110
+ let doc = {};
111
+ try {
112
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
113
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
114
+ doc = parsed;
115
+ }
116
+ catch { /* новый файл */ }
117
+ const section = (doc.mcpServers && typeof doc.mcpServers === 'object' && !Array.isArray(doc.mcpServers)
118
+ ? doc.mcpServers
119
+ : {});
120
+ section[name] = cfg;
121
+ doc = { ...doc, mcpServers: section };
122
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
123
+ fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`);
124
+ }
125
+ export function removeMcpServer(filePath, name) {
126
+ let doc;
127
+ try {
128
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
129
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
130
+ return false;
131
+ doc = parsed;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ const section = doc.mcpServers;
137
+ if (!section || typeof section !== 'object' || Array.isArray(section))
138
+ return false;
139
+ const rec = section;
140
+ if (!(name in rec))
141
+ return false;
142
+ delete rec[name];
143
+ fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`);
144
+ return true;
145
+ }
146
+ /** Подключиться к удалённому MCP-серверу по SSE / HTTP, вернуть клиент с инструментами. */
147
+ export async function connectMcpSseServer(name, cfg, connectTimeoutMs = 30000) {
148
+ const url = cfg.url;
149
+ if (!url)
150
+ throw new Error(`MCP-сервер ${name} не содержит url`);
151
+ const abortCtrl = new AbortController();
152
+ const sseHeaders = {
153
+ Accept: 'text/event-stream',
154
+ ...(cfg.headers ?? {}),
155
+ };
156
+ let sseRes;
157
+ try {
158
+ sseRes = await fetch(url, {
159
+ headers: sseHeaders,
160
+ signal: abortCtrl.signal,
161
+ });
162
+ }
163
+ catch (err) {
164
+ throw new Error(`Ошибка подключения к MCP SSE ${name}: ${err.message}`);
165
+ }
166
+ if (!sseRes.ok || !sseRes.body) {
167
+ throw new Error(`MCP SSE ${name} вернул статус ${sseRes.status}: ${sseRes.statusText}`);
168
+ }
169
+ let postEndpoint = url;
170
+ let endpointResolved = false;
171
+ const endpointWaiters = [];
172
+ const pending = new Map();
173
+ let nextId = 1;
174
+ const reader = sseRes.body.getReader();
175
+ const decoder = new TextDecoder();
176
+ let carry = '';
177
+ const processEvent = (eventBlock) => {
178
+ const lines = eventBlock.split(/\r?\n/);
179
+ let eventType = 'message';
180
+ const dataLines = [];
181
+ for (const line of lines) {
182
+ if (line.startsWith('event:')) {
183
+ eventType = line.slice(6).trim();
184
+ }
185
+ else if (line.startsWith('data:')) {
186
+ dataLines.push(line.slice(5).trim());
187
+ }
188
+ }
189
+ const dataText = dataLines.join('\n').trim();
190
+ if (!dataText)
191
+ return;
192
+ if (eventType === 'endpoint') {
193
+ try {
194
+ postEndpoint = new URL(dataText, url).toString();
195
+ }
196
+ catch {
197
+ postEndpoint = dataText;
198
+ }
199
+ endpointResolved = true;
200
+ for (const w of endpointWaiters)
201
+ w(postEndpoint);
202
+ endpointWaiters.length = 0;
203
+ return;
204
+ }
205
+ let msg = null;
206
+ try {
207
+ msg = JSON.parse(dataText);
208
+ }
209
+ catch {
210
+ return;
211
+ }
212
+ if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0')
213
+ return;
214
+ if (typeof msg.id === 'number' || typeof msg.id === 'string') {
215
+ const entry = pending.get(msg.id);
216
+ if (entry) {
217
+ pending.delete(msg.id);
218
+ clearTimeout(entry.timer);
219
+ if (msg.error) {
220
+ entry.reject(new Error(typeof msg.error.message === 'string' ? msg.error.message : 'MCP error'));
221
+ }
222
+ else {
223
+ entry.resolve(msg.result);
224
+ }
225
+ }
226
+ }
227
+ };
228
+ void (async () => {
229
+ try {
230
+ for (;;) {
231
+ const { value, done } = await reader.read();
232
+ if (done)
233
+ break;
234
+ carry += decoder.decode(value, { stream: true });
235
+ const blocks = carry.split(/\r?\n\r?\n/);
236
+ carry = blocks.pop() ?? '';
237
+ for (const block of blocks) {
238
+ if (block.trim())
239
+ processEvent(block);
240
+ }
241
+ }
242
+ }
243
+ catch {
244
+ /* поток закрыт или прерван */
245
+ }
246
+ finally {
247
+ for (const [, entry] of pending) {
248
+ clearTimeout(entry.timer);
249
+ entry.reject(new Error(`MCP SSE поток закрыт`));
250
+ }
251
+ pending.clear();
252
+ }
253
+ })();
254
+ const waitForEndpoint = () => {
255
+ if (endpointResolved)
256
+ return Promise.resolve(postEndpoint);
257
+ return new Promise(resolve => {
258
+ const t = setTimeout(() => {
259
+ resolve(postEndpoint);
260
+ }, 1500);
261
+ endpointWaiters.push((resolvedUrl) => {
262
+ clearTimeout(t);
263
+ resolve(resolvedUrl);
264
+ });
265
+ });
266
+ };
267
+ await waitForEndpoint();
268
+ const sendPost = async (msg) => {
269
+ return fetch(postEndpoint, {
270
+ method: 'POST',
271
+ headers: {
272
+ 'Content-Type': 'application/json',
273
+ Accept: 'application/json, text/event-stream',
274
+ ...(cfg.headers ?? {}),
275
+ },
276
+ body: JSON.stringify(msg),
277
+ signal: abortCtrl.signal,
278
+ });
279
+ };
280
+ const request = (method, params, timeoutMs) => new Promise((res, rej) => {
281
+ const id = nextId;
282
+ nextId += 1;
283
+ const timer = setTimeout(() => {
284
+ pending.delete(id);
285
+ rej(new Error(`MCP timeout: ${method}`));
286
+ }, timeoutMs);
287
+ pending.set(id, { resolve: res, reject: rej, timer });
288
+ sendPost({ jsonrpc: '2.0', id, method, params }).then(async (httpRes) => {
289
+ if (!httpRes.ok) {
290
+ const errText = await httpRes.text().catch(() => '');
291
+ const entry = pending.get(id);
292
+ if (entry) {
293
+ pending.delete(id);
294
+ clearTimeout(entry.timer);
295
+ entry.reject(new Error(`MCP POST HTTP ${httpRes.status}: ${errText || httpRes.statusText}`));
296
+ }
297
+ return;
298
+ }
299
+ const ctype = httpRes.headers.get('content-type') || '';
300
+ if (ctype.includes('application/json')) {
301
+ try {
302
+ const json = await httpRes.json();
303
+ if (json && json.jsonrpc === '2.0' && json.id === id) {
304
+ const entry = pending.get(id);
305
+ if (entry) {
306
+ pending.delete(id);
307
+ clearTimeout(entry.timer);
308
+ if (json.error)
309
+ entry.reject(new Error(typeof json.error.message === 'string' ? json.error.message : 'MCP error'));
310
+ else
311
+ entry.resolve(json.result);
312
+ }
313
+ }
314
+ }
315
+ catch { /* ответ может поступить через SSE */ }
316
+ }
317
+ }).catch(err => {
318
+ const entry = pending.get(id);
319
+ if (entry) {
320
+ pending.delete(id);
321
+ clearTimeout(entry.timer);
322
+ entry.reject(err instanceof Error ? err : new Error(String(err)));
323
+ }
324
+ });
325
+ });
326
+ const sendNotification = (method, params) => {
327
+ void sendPost({ jsonrpc: '2.0', method, ...(params ? { params } : {}) }).catch(() => { });
328
+ };
329
+ const tools = [];
330
+ try {
331
+ await request('initialize', {
332
+ protocolVersion: '2024-11-05',
333
+ capabilities: {},
334
+ clientInfo: { name: 'surf', version: '0.12.0' },
335
+ }, connectTimeoutMs);
336
+ sendNotification('notifications/initialized');
337
+ const listed = await request('tools/list', {}, connectTimeoutMs);
338
+ const rawTools = Array.isArray(listed?.tools)
339
+ ? listed.tools
340
+ : [];
341
+ for (const t of rawTools) {
342
+ if (!t || typeof t !== 'object')
343
+ continue;
344
+ const rec = t;
345
+ if (typeof rec.name !== 'string' || !rec.name)
346
+ continue;
347
+ const schema = rec.inputSchema && typeof rec.inputSchema === 'object' && !Array.isArray(rec.inputSchema)
348
+ ? rec.inputSchema
349
+ : { type: 'object' };
350
+ tools.push({
351
+ server: name,
352
+ name: rec.name,
353
+ description: typeof rec.description === 'string' ? rec.description.slice(0, 1000) : '',
354
+ inputSchema: schema,
355
+ });
356
+ }
357
+ }
358
+ catch (err) {
359
+ try {
360
+ abortCtrl.abort();
361
+ }
362
+ catch { /* ignore */ }
363
+ throw err;
364
+ }
365
+ const client = {
366
+ name,
367
+ tools,
368
+ call: async (toolName, args, timeoutMs) => {
369
+ const res = await request('tools/call', { name: toolName, arguments: args ?? {} }, timeoutMs);
370
+ const parts = Array.isArray(res?.content) ? res.content : [];
371
+ const texts = parts
372
+ .filter(p => p && typeof p === 'object' && p.type === 'text' && typeof p.text === 'string')
373
+ .map(p => p.text);
374
+ const nonText = parts.length - texts.length;
375
+ const suffix = nonText > 0 ? `\n[ещё ${nonText} не-текстовых частей пропущено]` : '';
376
+ const out = `${texts.join('\n')}${suffix}`.trim();
377
+ if (res?.isError)
378
+ throw new Error(out || 'MCP-инструмент вернул ошибку');
379
+ return out || '(пустой результат)';
380
+ },
381
+ close: () => {
382
+ try {
383
+ abortCtrl.abort();
384
+ }
385
+ catch { /* ignore */ }
386
+ },
387
+ };
388
+ return client;
389
+ }
390
+ /** Подключиться к MCP-серверу по stdio, вернуть клиент с инструментами. */
391
+ export function connectMcpStdioServer(name, cfg, connectTimeoutMs = 30000) {
392
+ return new Promise((resolve, reject) => {
393
+ let child;
394
+ try {
395
+ child = spawn(cfg.command || '', cfg.args ?? [], {
396
+ env: { ...process.env, ...(cfg.env ?? {}) },
397
+ cwd: cfg.cwd,
398
+ stdio: ['pipe', 'pipe', 'pipe'],
399
+ windowsHide: true,
400
+ });
401
+ }
402
+ catch (err) {
403
+ reject(err instanceof Error ? err : new Error(String(err)));
404
+ return;
405
+ }
406
+ let settled = false;
407
+ const fail = (err) => {
408
+ if (settled)
409
+ return;
410
+ settled = true;
411
+ try {
412
+ child.kill();
413
+ }
414
+ catch { /* ignore */ }
415
+ reject(err);
416
+ };
417
+ child.on('error', err => fail(err instanceof Error ? err : new Error(String(err))));
418
+ const stderr = child.stderr;
419
+ if (stderr)
420
+ stderr.on('data', () => { });
421
+ const pending = new Map();
422
+ let nextId = 1;
423
+ let carry = '';
424
+ const stdin = child.stdin;
425
+ if (!stdin || !child.stdout) {
426
+ fail(new Error('stdio недоступен'));
427
+ return;
428
+ }
429
+ const send = (msg) => {
430
+ stdin.write(`${JSON.stringify(msg)}\n`);
431
+ };
432
+ const request = (method, params, timeoutMs) => new Promise((res, rej) => {
433
+ const id = nextId;
434
+ nextId += 1;
435
+ const timer = setTimeout(() => {
436
+ pending.delete(id);
437
+ rej(new Error(`MCP timeout: ${method}`));
438
+ }, timeoutMs);
439
+ pending.set(id, { resolve: res, reject: rej, timer });
440
+ send({ jsonrpc: '2.0', id, method, params });
441
+ });
442
+ child.stdout.on('data', (chunk) => {
443
+ carry += chunk.toString('utf8');
444
+ const lines = carry.split('\n');
445
+ carry = lines.pop() ?? '';
446
+ for (const line of lines) {
447
+ const trimmed = line.trim();
448
+ if (!trimmed)
449
+ continue;
450
+ let msg;
451
+ try {
452
+ msg = JSON.parse(trimmed);
453
+ }
454
+ catch {
455
+ continue; // логи сервера в stdout — игнорим
456
+ }
457
+ if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0')
458
+ continue;
459
+ if (typeof msg.id === 'number' || typeof msg.id === 'string') {
460
+ const entry = pending.get(msg.id);
461
+ if (!entry)
462
+ continue;
463
+ pending.delete(msg.id);
464
+ clearTimeout(entry.timer);
465
+ if (msg.error)
466
+ entry.reject(new Error(typeof msg.error.message === 'string' ? msg.error.message : 'MCP error'));
467
+ else
468
+ entry.resolve(msg.result);
469
+ }
470
+ // уведомления от сервера игнорим
471
+ }
472
+ });
473
+ child.on('exit', (code) => {
474
+ for (const [, entry] of pending) {
475
+ clearTimeout(entry.timer);
476
+ entry.reject(new Error(`MCP-сервер завершился (code ${code ?? '?'})`));
477
+ }
478
+ pending.clear();
479
+ if (!settled)
480
+ fail(new Error(`MCP-сервер завершился до handshake (code ${code ?? '?'})`));
481
+ });
482
+ const tools = [];
483
+ const doHandshake = async () => {
484
+ const init = await request('initialize', {
485
+ protocolVersion: '2024-11-05',
486
+ capabilities: {},
487
+ clientInfo: { name: 'surf', version: '0.12.0' },
488
+ }, connectTimeoutMs);
489
+ void init;
490
+ send({ jsonrpc: '2.0', method: 'notifications/initialized' });
491
+ const listed = await request('tools/list', {}, connectTimeoutMs);
492
+ const rawTools = Array.isArray(listed?.tools)
493
+ ? listed.tools
494
+ : [];
495
+ for (const t of rawTools) {
496
+ if (!t || typeof t !== 'object')
497
+ continue;
498
+ const rec = t;
499
+ if (typeof rec.name !== 'string' || !rec.name)
500
+ continue;
501
+ const schema = rec.inputSchema && typeof rec.inputSchema === 'object' && !Array.isArray(rec.inputSchema)
502
+ ? rec.inputSchema
503
+ : { type: 'object' };
504
+ tools.push({
505
+ server: name,
506
+ name: rec.name,
507
+ description: typeof rec.description === 'string' ? rec.description.slice(0, 1000) : '',
508
+ inputSchema: schema,
509
+ });
510
+ }
511
+ const client = {
512
+ name,
513
+ tools,
514
+ call: async (toolName, args, timeoutMs) => {
515
+ const res = await request('tools/call', { name: toolName, arguments: args ?? {} }, timeoutMs);
516
+ const parts = Array.isArray(res?.content) ? res.content : [];
517
+ const texts = parts
518
+ .filter(p => p && typeof p === 'object' && p.type === 'text' && typeof p.text === 'string')
519
+ .map(p => p.text);
520
+ const nonText = parts.length - texts.length;
521
+ const suffix = nonText > 0 ? `\n[ещё ${nonText} не-текстовых частей пропущено]` : '';
522
+ const out = `${texts.join('\n')}${suffix}`.trim();
523
+ if (res?.isError)
524
+ throw new Error(out || 'MCP-инструмент вернул ошибку');
525
+ return out || '(пустой результат)';
526
+ },
527
+ close: () => {
528
+ try {
529
+ child.kill();
530
+ }
531
+ catch { /* ignore */ }
532
+ },
533
+ };
534
+ return client;
535
+ };
536
+ doHandshake().then(client => {
537
+ if (settled) {
538
+ client.close();
539
+ return;
540
+ }
541
+ settled = true;
542
+ resolve(client);
543
+ }, err => fail(err instanceof Error ? err : new Error(String(err))));
544
+ });
545
+ }
546
+ /** Подключиться к MCP-серверу (stdio или SSE), вернуть клиент с инструментами. */
547
+ export function connectMcpServer(name, cfg, connectTimeoutMs = 30000) {
548
+ if (cfg.url) {
549
+ return connectMcpSseServer(name, cfg, connectTimeoutMs);
550
+ }
551
+ return connectMcpStdioServer(name, cfg, connectTimeoutMs);
552
+ }
553
+ //# sourceMappingURL=cliMcp.js.map