@znmin/claude-remote-cli 0.3.2 → 0.4.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,320 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { parseArgs } from 'node:util';
5
+ import { readJSON, withLock, writeJSON } from '../system.js';
6
+
7
+ // MCP servers configured by the developer. They run on the LOCAL computer, so
8
+ // they can reach intranet services and local programs, and are exposed to the
9
+ // server Claude through the existing reverse tunnel.
10
+ //
11
+ // Scopes follow Claude Code: local (private to one project, stored in our
12
+ // config directory) > project (.mcp.json in the project) > user (all projects).
13
+
14
+ export const PROJECT_FILE = '.mcp.json';
15
+ export const SCOPES = ['local', 'project', 'user'];
16
+ const NAME = /^[A-Za-z0-9_-]{1,64}$/;
17
+ const RESERVED = new Set(['local']);
18
+
19
+ export function validateServerName(name) {
20
+ if (typeof name !== 'string' || !NAME.test(name))
21
+ throw new Error(`MCP 名称 ${JSON.stringify(name)} 无效:只能使用字母、数字、下划线和短横线,最长 64 个字符。`);
22
+ if (RESERVED.has(name)) throw new Error('名称 local 已被本地工具占用,请换一个名称。');
23
+ return name;
24
+ }
25
+
26
+ const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
27
+
28
+ function stringMap(value, label) {
29
+ if (value === undefined) return {};
30
+ if (!isObject(value) || Object.values(value).some(item => typeof item !== 'string'))
31
+ throw new Error(`${label} 必须是值为字符串的对象。`);
32
+ return { ...value };
33
+ }
34
+
35
+ // Validate one server entry and keep only the fields this tool understands.
36
+ export function normalizeServer(config) {
37
+ if (!isObject(config)) throw new Error('配置必须是 JSON 对象。');
38
+ const type = config.type ?? (config.command !== undefined ? 'stdio' : config.url !== undefined ? 'http' : undefined);
39
+ if (type === 'stdio') {
40
+ if (typeof config.command !== 'string' || !config.command) throw new Error('stdio 类型需要 command。');
41
+ if (config.args !== undefined && (!Array.isArray(config.args) || config.args.some(arg => typeof arg !== 'string')))
42
+ throw new Error('args 必须是字符串数组。');
43
+ return { type, command: config.command, args: [...(config.args || [])], env: stringMap(config.env, 'env') };
44
+ }
45
+ if (type === 'http' || type === 'sse') {
46
+ if (typeof config.url !== 'string' || !config.url) throw new Error(`${type} 类型需要 url。`);
47
+ if (config.oauth !== undefined || config.headersHelper !== undefined)
48
+ throw new Error('暂不支持 OAuth 登录和 headersHelper;请改用固定 headers(可引用本机环境变量,如 "Authorization": "Bearer ${TOKEN}")。');
49
+ return { type, url: config.url, headers: stringMap(config.headers, 'headers') };
50
+ }
51
+ throw new Error(type === undefined ? '缺少 type、command 或 url。' : `不支持的类型 ${type};支持 stdio、http、sse。`);
52
+ }
53
+
54
+ // ${VAR} and ${VAR:-default}, expanded with the LOCAL environment so secrets
55
+ // never leave this computer.
56
+ export function expandServer(server, env) {
57
+ const missing = new Set();
58
+ const expand = value => value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, fallback) => {
59
+ if (env[name] !== undefined) return env[name];
60
+ if (fallback !== undefined) return fallback;
61
+ missing.add(name); return '';
62
+ });
63
+ const map = object => Object.fromEntries(Object.entries(object).map(([key, value]) => [key, expand(value)]));
64
+ const result = server.type === 'stdio'
65
+ ? { ...server, command: expand(server.command), args: server.args.map(expand), env: map(server.env) }
66
+ : { ...server, url: expand(server.url), headers: map(server.headers) };
67
+ if (missing.size) throw new Error(`缺少本机环境变量 ${[...missing].join('、')}。`);
68
+ if (result.type !== 'stdio') {
69
+ let url;
70
+ try { url = new URL(result.url); } catch { throw new Error(`url 无效:${result.url}`); }
71
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('url 只支持 http 或 https。');
72
+ }
73
+ return result;
74
+ }
75
+
76
+ export function describeServer(server) {
77
+ if (server.type === 'stdio') return [server.command, ...server.args].join(' ');
78
+ return `${server.type.toUpperCase()} ${server.url}`;
79
+ }
80
+
81
+ const fingerprint = raw => createHash('sha256').update(JSON.stringify(raw)).digest('hex');
82
+
83
+ export class McpStore {
84
+ constructor({ directory, lockFile, root }) {
85
+ this.file = path.join(directory, 'mcp.json');
86
+ this.lockFile = lockFile;
87
+ this.root = root;
88
+ this.projectFile = path.join(root, PROJECT_FILE);
89
+ }
90
+
91
+ async read() {
92
+ const data = await readJSON(this.file, {});
93
+ if (!isObject(data)) throw new Error(`${this.file} 格式无效。`);
94
+ data.mcpServers = isObject(data.mcpServers) ? data.mcpServers : {};
95
+ data.projects = isObject(data.projects) ? data.projects : {};
96
+ return data;
97
+ }
98
+
99
+ project(data) { return data.projects[this.root] ||= {}; }
100
+
101
+ update(callback) {
102
+ return withLock(this.lockFile, async () => {
103
+ const data = await this.read();
104
+ const result = await callback(data);
105
+ await writeJSON(this.file, data);
106
+ return result;
107
+ });
108
+ }
109
+
110
+ // Returns the parsed .mcp.json, or {} when absent. Throws on invalid JSON.
111
+ async readProjectFile() {
112
+ let text;
113
+ try { text = await fs.readFile(this.projectFile, 'utf8'); }
114
+ catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
115
+ let data;
116
+ try { data = JSON.parse(text); } catch (error) { throw new Error(`${PROJECT_FILE} 不是有效的 JSON:${error.message}`); }
117
+ if (!isObject(data)) throw new Error(`${PROJECT_FILE} 必须是 JSON 对象。`);
118
+ return data;
119
+ }
120
+
121
+ async writeProjectFile(data) {
122
+ // Meant to be committed: keep normal permissions, unlike our private files.
123
+ await fs.writeFile(this.projectFile, JSON.stringify(data, null, 2) + '\n');
124
+ }
125
+
126
+ // Every configured entry by scope, unvalidated.
127
+ async entries() {
128
+ const data = await this.read();
129
+ const warnings = [];
130
+ let projectServers = {};
131
+ try {
132
+ const file = await this.readProjectFile();
133
+ if (file.mcpServers !== undefined && !isObject(file.mcpServers)) throw new Error(`${PROJECT_FILE} 的 mcpServers 必须是对象。`);
134
+ projectServers = file.mcpServers || {};
135
+ } catch (error) { warnings.push(error.message + ' 已忽略项目 MCP 配置。'); }
136
+ return {
137
+ data, warnings, scopes: {
138
+ user: data.mcpServers,
139
+ project: projectServers,
140
+ local: isObject(data.projects[this.root]?.mcpServers) ? data.projects[this.root].mcpServers : {}
141
+ }
142
+ };
143
+ }
144
+ }
145
+
146
+ // Merge scopes (local > project > user) and validate the winning entries.
147
+ export async function loadServers(store) {
148
+ const { data, warnings, scopes } = await store.entries();
149
+ const chosen = new Map();
150
+ for (const scope of ['user', 'project', 'local'])
151
+ for (const [name, raw] of Object.entries(scopes[scope])) chosen.set(name, { name, scope, raw });
152
+ const servers = [];
153
+ for (const entry of chosen.values()) {
154
+ try {
155
+ validateServerName(entry.name);
156
+ servers.push({ ...entry, config: normalizeServer(entry.raw) });
157
+ } catch (error) { warnings.push(`MCP ${entry.name}(${entry.scope})配置无效,已跳过:${error.message}`); }
158
+ }
159
+ const choices = data.projects[store.root]?.projectServerChoices || {};
160
+ for (const server of servers) if (server.scope === 'project') {
161
+ const choice = choices[server.name];
162
+ server.approved = choice?.hash === fingerprint(server.raw) ? Boolean(choice.approved) : undefined;
163
+ }
164
+ return { servers, warnings };
165
+ }
166
+
167
+ // Project .mcp.json can start arbitrary local programs, so each entry needs the
168
+ // developer's approval. Choices are remembered per project until the entry changes.
169
+ export async function approveServers(store, servers, { confirm, warn }) {
170
+ const pending = servers.filter(server => server.scope === 'project' && server.approved === undefined);
171
+ if (pending.length && confirm) {
172
+ const decisions = [];
173
+ for (const server of pending) {
174
+ server.approved = await confirm(server);
175
+ decisions.push(server);
176
+ }
177
+ await store.update(data => {
178
+ const choices = store.project(data).projectServerChoices ||= {};
179
+ for (const server of decisions) choices[server.name] = { hash: fingerprint(server.raw), approved: server.approved };
180
+ });
181
+ } else for (const server of pending)
182
+ warn(`项目 MCP ${server.name} 尚未确认,本次不启用。请在交互终端中启动 claude 进行确认。`);
183
+ return servers.filter(server => server.scope !== 'project' || server.approved === true);
184
+ }
185
+
186
+ const USAGE = `用法:
187
+ claude-remote mcp list
188
+ claude-remote mcp get <名称>
189
+ claude-remote mcp add [-s local|project|user] [-e KEY=值]... <名称> -- <命令> [参数...]
190
+ claude-remote mcp add -t http|sse [-s 范围] [-H "名称: 值"]... <名称> <URL>
191
+ claude-remote mcp add-json [-s 范围] <名称> '<JSON>'
192
+ claude-remote mcp remove [-s 范围] <名称>
193
+ claude-remote mcp reset-project-choices
194
+
195
+ 这些 MCP 在本机运行或从本机连接(可访问内网),通过本地模式的 SSH 隧道提供给服务器上的 Claude。
196
+ 默认范围 local 只对当前项目生效;project 写入项目的 .mcp.json;user 对所有项目生效。
197
+ 值中的 \${VAR} 在本机启动时展开,凭据不会发送到服务器。`;
198
+
199
+ function scopeOption(value, fallback = 'local') {
200
+ const scope = value ?? fallback;
201
+ if (!SCOPES.includes(scope)) throw new Error(`范围只能是 ${SCOPES.join('、')}。`);
202
+ return scope;
203
+ }
204
+
205
+ async function saveServer(store, scope, name, raw) {
206
+ validateServerName(name);
207
+ normalizeServer(raw);
208
+ if (scope === 'project') {
209
+ const file = await store.readProjectFile();
210
+ if (file.mcpServers !== undefined && !isObject(file.mcpServers)) throw new Error(`${PROJECT_FILE} 的 mcpServers 必须是对象。`);
211
+ file.mcpServers = { ...file.mcpServers, [name]: raw };
212
+ await store.writeProjectFile(file);
213
+ // Whoever adds the entry here approves it for this computer.
214
+ await store.update(data => {
215
+ const choices = store.project(data).projectServerChoices ||= {};
216
+ choices[name] = { hash: fingerprint(raw), approved: true };
217
+ });
218
+ } else await store.update(data => {
219
+ const target = scope === 'user' ? data.mcpServers : (store.project(data).mcpServers ||= {});
220
+ target[name] = raw;
221
+ });
222
+ }
223
+
224
+ export async function runMcpCommand(store, args, { out = console.log } = {}) {
225
+ const [action, ...rest] = args;
226
+ if (!action || ['help', '--help', '-h'].includes(action)) { out(USAGE); return 0; }
227
+ if (action === 'list') {
228
+ if (rest.length) throw new Error(USAGE);
229
+ const { servers, warnings } = await loadServers(store);
230
+ for (const warning of warnings) out('警告:' + warning);
231
+ if (!servers.length) { out('尚未配置 MCP。运行 claude-remote mcp add --help 查看用法。'); return 0; }
232
+ for (const server of servers) {
233
+ const status = server.scope !== 'project' ? '' : server.approved === true ? '' : server.approved === false ? ',已拒绝' : ',待确认';
234
+ out(`${server.name}(${server.scope}${status}):${describeServer(server.config)}`);
235
+ }
236
+ return 0;
237
+ }
238
+ if (action === 'get') {
239
+ if (rest.length !== 1) throw new Error(USAGE);
240
+ const { scopes } = await store.entries();
241
+ const found = SCOPES.filter(scope => scopes[scope][rest[0]] !== undefined);
242
+ if (!found.length) throw new Error(`没有找到 MCP ${rest[0]}。`);
243
+ for (const scope of found) out(`${rest[0]}(${scope}):\n${JSON.stringify(scopes[scope][rest[0]], null, 2)}`);
244
+ return 0;
245
+ }
246
+ if (action === 'add') {
247
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: {
248
+ transport: { type: 'string', short: 't' }, scope: { type: 'string', short: 's' },
249
+ env: { type: 'string', short: 'e', multiple: true }, header: { type: 'string', short: 'H', multiple: true }
250
+ } });
251
+ const [name, target, ...commandArgs] = positionals;
252
+ if (!name || !target) throw new Error(USAGE);
253
+ const type = values.transport ?? 'stdio';
254
+ let raw;
255
+ if (type === 'stdio') {
256
+ if (values.header) throw new Error('-H 只用于 http/sse 类型。');
257
+ const env = {};
258
+ for (const item of values.env || []) {
259
+ const index = item.indexOf('=');
260
+ if (index < 1) throw new Error(`环境变量格式应为 KEY=值:${item}`);
261
+ env[item.slice(0, index)] = item.slice(index + 1);
262
+ }
263
+ raw = { type, command: target, args: commandArgs, ...(Object.keys(env).length ? { env } : {}) };
264
+ } else if (type === 'http' || type === 'sse') {
265
+ if (values.env) throw new Error('-e 只用于 stdio 类型。');
266
+ if (commandArgs.length) throw new Error('http/sse 类型只接受一个 URL。');
267
+ const headers = {};
268
+ for (const item of values.header || []) {
269
+ const index = item.indexOf(':');
270
+ if (index < 1) throw new Error(`请求头格式应为 "名称: 值":${item}`);
271
+ headers[item.slice(0, index).trim()] = item.slice(index + 1).trim();
272
+ }
273
+ raw = { type, url: target, ...(Object.keys(headers).length ? { headers } : {}) };
274
+ } else throw new Error('类型只能是 stdio、http 或 sse。');
275
+ const scope = scopeOption(values.scope);
276
+ await saveServer(store, scope, name, raw);
277
+ out(`已添加 MCP ${name}(${scope}):${describeServer(normalizeServer(raw))}`);
278
+ return 0;
279
+ }
280
+ if (action === 'add-json') {
281
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { scope: { type: 'string', short: 's' } } });
282
+ if (positionals.length !== 2) throw new Error(USAGE);
283
+ let raw;
284
+ try { raw = JSON.parse(positionals[1]); } catch (error) { throw new Error('JSON 无效:' + error.message); }
285
+ const scope = scopeOption(values.scope);
286
+ await saveServer(store, scope, positionals[0], raw);
287
+ out(`已添加 MCP ${positionals[0]}(${scope}):${describeServer(normalizeServer(raw))}`);
288
+ return 0;
289
+ }
290
+ if (action === 'remove') {
291
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { scope: { type: 'string', short: 's' } } });
292
+ if (positionals.length !== 1) throw new Error(USAGE);
293
+ const name = positionals[0];
294
+ const { scopes } = await store.entries();
295
+ const found = SCOPES.filter(scope => scopes[scope][name] !== undefined);
296
+ let scope = values.scope === undefined ? undefined : scopeOption(values.scope);
297
+ if (scope === undefined) {
298
+ if (found.length > 1) throw new Error(`MCP ${name} 存在于多个范围(${found.join('、')}),请用 -s 指定。`);
299
+ scope = found[0];
300
+ }
301
+ if (!scope || !found.includes(scope)) throw new Error(`没有找到 MCP ${name}${values.scope ? `(${values.scope})` : ''}。`);
302
+ if (scope === 'project') {
303
+ const file = await store.readProjectFile();
304
+ delete file.mcpServers[name];
305
+ await store.writeProjectFile(file);
306
+ } else await store.update(data => {
307
+ if (scope === 'user') delete data.mcpServers[name];
308
+ else delete store.project(data).mcpServers?.[name];
309
+ });
310
+ out(`已删除 MCP ${name}(${scope})。`);
311
+ return 0;
312
+ }
313
+ if (action === 'reset-project-choices') {
314
+ if (rest.length) throw new Error(USAGE);
315
+ await store.update(data => { delete store.project(data).projectServerChoices; });
316
+ out('已清除当前项目 .mcp.json 的确认记录,下次启动时重新询问。');
317
+ return 0;
318
+ }
319
+ throw new Error(`未知的 mcp 子命令 ${action}。\n${USAGE}`);
320
+ }
@@ -0,0 +1,199 @@
1
+ import path from 'node:path';
2
+ import { spawn } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
7
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import {
10
+ CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourceTemplatesRequestSchema,
11
+ ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema
12
+ } from '@modelcontextprotocol/sdk/types.js';
13
+ import { describeServer, expandServer } from './mcp-config.js';
14
+
15
+ const PACKAGE = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
16
+
17
+ // Claude persists very large MCP results on the server, where local mode cannot
18
+ // read them back, so results are kept below this many characters.
19
+ export const MAX_RESULT = 40000;
20
+ export const CONNECT_TIMEOUT = 30000;
21
+ export const CALL_TIMEOUT = 180000;
22
+ const STDERR_LIMIT = 4000;
23
+
24
+ // Shorten long text fields until the whole result fits. Binary data (images,
25
+ // blobs) is left alone: cutting it would corrupt it.
26
+ export function boundResult(result) {
27
+ if (JSON.stringify(result).length <= MAX_RESULT) return result;
28
+ const note = { type: 'text', text: '[结果过大,已截断较长文本。请缩小查询范围或分页读取。]' };
29
+ const shrink = (value, size) => {
30
+ if (Array.isArray(value)) return value.map(item => shrink(item, size));
31
+ if (!value || typeof value !== 'object') return value;
32
+ return Object.fromEntries(Object.entries(value).map(([key, item]) =>
33
+ [key, key === 'text' && typeof item === 'string' && item.length > size
34
+ ? item.slice(0, size) + `…[已截断 ${item.length - size} 字符]` : shrink(item, size)]));
35
+ };
36
+ // Truncated text no longer matches structured output, so drop it.
37
+ const rest = { ...result };
38
+ delete rest.structuredContent;
39
+ for (let size = 16000; size >= 100; size = Math.floor(size / 2)) {
40
+ const shrunk = shrink(rest, size);
41
+ if (Array.isArray(shrunk.content)) shrunk.content = [...shrunk.content, note];
42
+ if (JSON.stringify(shrunk).length <= MAX_RESULT) return shrunk;
43
+ }
44
+ return { ...(result.isError ? { isError: true } : {}),
45
+ content: [{ type: 'text', text: JSON.stringify(rest).slice(0, 16000) }, note] };
46
+ }
47
+
48
+ function windowsTreeKill(pid, env) {
49
+ const taskkill = path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'taskkill.exe');
50
+ return new Promise(resolve => {
51
+ const killer = spawn(taskkill, ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
52
+ killer.once('error', resolve); killer.once('close', resolve);
53
+ });
54
+ }
55
+
56
+ // One MCP server reached from this computer. Reconnects once if a stdio
57
+ // process exits or an HTTP session drops during the Claude session.
58
+ export class Upstream {
59
+ constructor(server, { root, env = process.env, debug = () => {}, platform = process.platform } = {}) {
60
+ this.name = server.name; this.scope = server.scope; this.server = server;
61
+ this.root = root; this.env = env; this.debug = debug; this.platform = platform;
62
+ this.stderr = ''; this.closed = false; this.current = null;
63
+ }
64
+
65
+ describe() { return describeServer(this.server.config); }
66
+
67
+ async open(signal) {
68
+ const config = expandServer(this.server.config, this.env);
69
+ const client = new Client({ name: 'claude-remote-proxy', version: PACKAGE.version }, { capabilities: {} });
70
+ let transport;
71
+ if (config.type === 'stdio') {
72
+ // Same environment as the local terminal, like Claude Code gives stdio servers.
73
+ transport = new StdioClientTransport({ command: config.command, args: config.args,
74
+ env: { ...this.env, ...config.env }, cwd: this.root, stderr: 'pipe' });
75
+ // Keep stderr off the terminal: the remote Claude UI is drawn there.
76
+ transport.stderr?.setEncoding('utf8');
77
+ transport.stderr?.on('data', chunk => {
78
+ this.stderr = (this.stderr + chunk).slice(-STDERR_LIMIT);
79
+ this.debug(`[MCP ${this.name}] ${chunk.trimEnd()}`);
80
+ });
81
+ } else {
82
+ const requestInit = { headers: config.headers };
83
+ transport = config.type === 'http'
84
+ ? new StreamableHTTPClientTransport(new URL(config.url), { requestInit })
85
+ : new SSEClientTransport(new URL(config.url), { requestInit });
86
+ }
87
+ const connection = { client, transport };
88
+ client.onclose = () => { if (this.current?.connection === connection) this.current = null; };
89
+ try { await client.connect(transport, { signal, timeout: CONNECT_TIMEOUT }); }
90
+ catch (error) {
91
+ await this.closeConnection(connection);
92
+ const detail = this.stderr.trim() ? `\n${this.stderr.trim().split('\n').slice(-5).join('\n')}` : '';
93
+ throw new Error(error.message + detail);
94
+ }
95
+ return connection;
96
+ }
97
+
98
+ // Connected client; opens or re-opens the connection when needed.
99
+ async client(signal) {
100
+ if (this.closed) throw new Error(`MCP ${this.name} 已关闭。`);
101
+ if (!this.current) {
102
+ const pending = { promise: this.open(signal) };
103
+ this.current = pending;
104
+ pending.promise.then(connection => { pending.connection = connection; },
105
+ () => { if (this.current === pending) this.current = null; });
106
+ }
107
+ return (await this.current.promise).client;
108
+ }
109
+
110
+ async connect(signal) {
111
+ const client = await this.client(signal);
112
+ this.info = { capabilities: client.getServerCapabilities() || {}, instructions: client.getInstructions(),
113
+ version: client.getServerVersion() };
114
+ return this;
115
+ }
116
+
117
+ async closeConnection({ client, transport }) {
118
+ const pid = transport.pid;
119
+ const closing = client.close().catch(() => {});
120
+ if (this.platform === 'win32' && pid) {
121
+ // Commands such as npx run through cmd.exe; stop the whole tree if the
122
+ // server does not exit after stdin closes.
123
+ const finished = await Promise.race([closing.then(() => true), new Promise(resolve => setTimeout(resolve, 1500, false))]);
124
+ if (!finished) await windowsTreeKill(pid, this.env);
125
+ }
126
+ await closing;
127
+ }
128
+
129
+ async close() {
130
+ this.closed = true;
131
+ const pending = this.current; this.current = null;
132
+ if (!pending) return;
133
+ const connection = await pending.promise.catch(() => null);
134
+ if (connection) await this.closeConnection(connection);
135
+ }
136
+
137
+ // A fresh MCP server for one stateless HTTP request from the remote Claude,
138
+ // mirroring the upstream's tools, resources and prompts.
139
+ createServer() {
140
+ const capabilities = {}, upstream = this.info?.capabilities || {};
141
+ if (upstream.tools) capabilities.tools = {};
142
+ if (upstream.resources) capabilities.resources = {};
143
+ if (upstream.prompts) capabilities.prompts = {};
144
+ const server = new Server({ name: this.info?.version?.name || this.name, version: this.info?.version?.version || '0.0.0' },
145
+ { capabilities, ...(this.info?.instructions ? { instructions: this.info.instructions } : {}) });
146
+ const forward = (schema, call) => server.setRequestHandler(schema, async (request, extra) =>
147
+ call(await this.client(extra.signal), request.params, { signal: extra.signal }));
148
+ if (capabilities.tools) {
149
+ forward(ListToolsRequestSchema, async (client, params, options) => {
150
+ const result = await client.listTools(params, options);
151
+ // Results may be truncated on the way back, so do not promise a schema.
152
+ return { ...result, tools: result.tools.map(tool => { const copy = { ...tool }; delete copy.outputSchema; return copy; }) };
153
+ });
154
+ forward(CallToolRequestSchema, async (client, params, options) => {
155
+ try {
156
+ const result = await client.callTool(params, undefined,
157
+ { ...options, timeout: CALL_TIMEOUT, resetTimeoutOnProgress: true });
158
+ if (!result.content?.length && result.structuredContent !== undefined)
159
+ result.content = [{ type: 'text', text: JSON.stringify(result.structuredContent) }];
160
+ return boundResult(result);
161
+ } catch (error) {
162
+ return { isError: true, content: [{ type: 'text', text: `MCP ${this.name} 调用失败:${error.message}` }] };
163
+ }
164
+ });
165
+ }
166
+ if (capabilities.resources) {
167
+ forward(ListResourcesRequestSchema, (client, params, options) => client.listResources(params, options));
168
+ forward(ListResourceTemplatesRequestSchema, (client, params, options) => client.listResourceTemplates(params, options));
169
+ forward(ReadResourceRequestSchema, async (client, params, options) => boundResult(await client.readResource(params, options)));
170
+ }
171
+ if (capabilities.prompts) {
172
+ forward(ListPromptsRequestSchema, (client, params, options) => client.listPrompts(params, options));
173
+ forward(GetPromptRequestSchema, async (client, params, options) => boundResult(await client.getPrompt(params, options)));
174
+ }
175
+ return server;
176
+ }
177
+ }
178
+
179
+ // Connect all servers in parallel. A server that fails is reported and left
180
+ // out; it never blocks the session.
181
+ export async function connectUpstreams(servers, { root, env, debug, warn, signal, create = server => new Upstream(server, { root, env, debug }) }) {
182
+ const connected = new Map();
183
+ await Promise.all(servers.map(async server => {
184
+ const upstream = create(server);
185
+ try {
186
+ await upstream.connect(signal);
187
+ connected.set(server.name, upstream);
188
+ } catch (error) {
189
+ await upstream.close();
190
+ if (!signal?.aborted) warn(`MCP ${server.name} 连接失败,本次不可用:${error.message}`);
191
+ }
192
+ }));
193
+ return connected;
194
+ }
195
+
196
+ export async function closeUpstreams(upstreams) {
197
+ await Promise.allSettled([...upstreams.values()].map(upstream => upstream.close()));
198
+ upstreams.clear();
199
+ }