u1s1-cli 1.7.1 → 1.8.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.
- package/dist/agent-setup.d.ts +7 -0
- package/dist/agent-setup.js +29 -0
- package/dist/index.js +10 -2
- package/dist/mcp/client.d.ts +53 -0
- package/dist/mcp/client.js +452 -0
- package/dist/mcp/command.d.ts +25 -0
- package/dist/mcp/command.js +349 -0
- package/dist/mcp/config.d.ts +59 -0
- package/dist/mcp/config.js +0 -0
- package/dist/mcp/extension.d.ts +14 -0
- package/dist/mcp/extension.js +128 -0
- package/dist/mcp/tools.d.ts +22 -0
- package/dist/mcp/tools.js +96 -0
- package/dist/web.js +3 -1
- package/package.json +1 -1
package/dist/agent-setup.d.ts
CHANGED
|
@@ -49,6 +49,13 @@ export declare function writeErrorHumanizeExtension(): void;
|
|
|
49
49
|
* print/json 一次性模式不起轮询;import 失败静默跳过(同 error-humanize)。
|
|
50
50
|
*/
|
|
51
51
|
export declare function writeAnnouncementsExtension(): void;
|
|
52
|
+
/**
|
|
53
|
+
* 生成「自定义 MCP 服务」扩展到 <agentDir>/extensions/u1s1-mcp.js。
|
|
54
|
+
* 读 ~/.u1s1/mcp.json,把每个服务的工具注册成 mcp_<服务>_<工具>;逻辑在 mcp/extension.ts,
|
|
55
|
+
* 这里只负责投影(Desktop App 共享)。没配置任何服务时扩展直接返回,零开销。
|
|
56
|
+
* 子会话(spawn_subagent)里不挂 MCP 工具:避免每个子进程都拉起一份服务。
|
|
57
|
+
*/
|
|
58
|
+
export declare function writeMcpExtension(): void;
|
|
52
59
|
/**
|
|
53
60
|
* 生成「OpenRouter 应用归因」扩展到 <agentDir>/extensions/u1s1-attribution.js。
|
|
54
61
|
* 用户自己配 OPENROUTER_API_KEY 直连时,pi 内置的归因头归到 pi.dev 名下(且受遥测
|
package/dist/agent-setup.js
CHANGED
|
@@ -332,6 +332,35 @@ export function writeAnnouncementsExtension() {
|
|
|
332
332
|
` });\n` +
|
|
333
333
|
`}\n`);
|
|
334
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* 生成「自定义 MCP 服务」扩展到 <agentDir>/extensions/u1s1-mcp.js。
|
|
337
|
+
* 读 ~/.u1s1/mcp.json,把每个服务的工具注册成 mcp_<服务>_<工具>;逻辑在 mcp/extension.ts,
|
|
338
|
+
* 这里只负责投影(Desktop App 共享)。没配置任何服务时扩展直接返回,零开销。
|
|
339
|
+
* 子会话(spawn_subagent)里不挂 MCP 工具:避免每个子进程都拉起一份服务。
|
|
340
|
+
*/
|
|
341
|
+
export function writeMcpExtension() {
|
|
342
|
+
const dir = join(agentDir, "extensions");
|
|
343
|
+
mkdirSync(dir, { recursive: true });
|
|
344
|
+
const url = new URL("./mcp/extension.js", import.meta.url).href;
|
|
345
|
+
writeFileSync(join(dir, "u1s1-mcp.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
|
|
346
|
+
`export default async function (pi) {\n` +
|
|
347
|
+
` if (process.env.U1S1_IN_SUBAGENT === "1") return;\n` +
|
|
348
|
+
` let registerMcpServers;\n` +
|
|
349
|
+
` try {\n` +
|
|
350
|
+
` registerMcpServers = (await import(${JSON.stringify(url)})).registerMcpServers;\n` +
|
|
351
|
+
` } catch {\n` +
|
|
352
|
+
` return;\n` +
|
|
353
|
+
` }\n` +
|
|
354
|
+
` try {\n` +
|
|
355
|
+
` await registerMcpServers(pi);\n` +
|
|
356
|
+
` } catch (err) {\n` +
|
|
357
|
+
` const msg = err instanceof Error ? err.message : String(err);\n` +
|
|
358
|
+
` pi.on("session_start", (_event, ctx) => {\n` +
|
|
359
|
+
` if (ctx.hasUI) ctx.ui.notify("MCP 服务加载失败:" + msg, "warning");\n` +
|
|
360
|
+
` });\n` +
|
|
361
|
+
` }\n` +
|
|
362
|
+
`}\n`);
|
|
363
|
+
}
|
|
335
364
|
/**
|
|
336
365
|
* 生成「OpenRouter 应用归因」扩展到 <agentDir>/extensions/u1s1-attribution.js。
|
|
337
366
|
* 用户自己配 OPENROUTER_API_KEY 直连时,pi 内置的归因头归到 pi.dev 名下(且受遥测
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync, spawnSync } from "node:child_process";
|
|
3
3
|
import { writeFileSync } from "node:fs";
|
|
4
|
-
import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, ensureWorkflowPromptTemplate, scrubForeignProviderEnv, toProviderModels, writeAnnouncementsExtension, writeAttributionExtension, writeCompactUiExtension, writeErrorHumanizeExtension, writeWebToolsExtension, } from "./agent-setup.js";
|
|
4
|
+
import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, ensureWorkflowPromptTemplate, scrubForeignProviderEnv, toProviderModels, writeAnnouncementsExtension, writeAttributionExtension, writeMcpExtension, writeCompactUiExtension, writeErrorHumanizeExtension, writeWebToolsExtension, } from "./agent-setup.js";
|
|
5
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import { printConsoleBanner } from "./brand.js";
|
|
7
7
|
import { agentDir, apiModelToDef, CUSTOM_ENDPOINTS, isPortableInstall, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, readSettings, refValid, resolvePreferredModel, setModelsFromApi, VERSION, } from "./config.js";
|
|
@@ -255,6 +255,8 @@ async function runAgent(cfg, args) {
|
|
|
255
255
|
writeAnnouncementsExtension();
|
|
256
256
|
// OpenRouter 应用归因:直连流量计入 u1s1 的公开排行
|
|
257
257
|
writeAttributionExtension();
|
|
258
|
+
// 自定义 MCP 服务的工具(~/.u1s1/mcp.json,同样投影,Desktop 共享)
|
|
259
|
+
writeMcpExtension();
|
|
258
260
|
ensureTmuxKeyboardProtocol();
|
|
259
261
|
// must be set before pi reads them (getAgentDir() reads at call time);
|
|
260
262
|
// 常量部分已在 runAgent 开头、发起 import 之前设好
|
|
@@ -537,6 +539,7 @@ async function run() {
|
|
|
537
539
|
console.log(" u1s1 deploy remove 删除已发布的站点");
|
|
538
540
|
console.log(" u1s1 feedback \"一句话\" 反馈问题或建议(自动建工单,--bug/--question…)");
|
|
539
541
|
console.log(" u1s1 import 导入历史会话(import skills 导入技能)");
|
|
542
|
+
console.log(" u1s1 mcp 配置第三方 MCP 服务(add/list/test/remove/import)");
|
|
540
543
|
console.log(" u1s1 bench 模型编码能力评测");
|
|
541
544
|
console.log(" u1s1 --version 查看版本");
|
|
542
545
|
console.log("");
|
|
@@ -583,6 +586,11 @@ async function run() {
|
|
|
583
586
|
await feedbackCommand(args.slice(1));
|
|
584
587
|
return;
|
|
585
588
|
}
|
|
589
|
+
if (cmd === "mcp") {
|
|
590
|
+
const { mcpCommand } = await import("./mcp/command.js");
|
|
591
|
+
await mcpCommand(args.slice(1));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
586
594
|
if (cmd === "model") {
|
|
587
595
|
const { modelCommand } = await import("./model.js");
|
|
588
596
|
await modelCommand(args[1]);
|
|
@@ -656,7 +664,7 @@ async function run() {
|
|
|
656
664
|
// autoUpdate 开着且启动时发现了新版:现在装(TUI 已退出,原地重铺安全)
|
|
657
665
|
installPendingUpdate();
|
|
658
666
|
}
|
|
659
|
-
const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "bench", "feedback", "help"];
|
|
667
|
+
const KNOWN_COMMANDS = ["deploy", "login", "logout", "usage", "model", "update", "import", "mcp", "bench", "feedback", "help"];
|
|
660
668
|
/** 没有自己 --help 处理的简单子命令:问用法时只打印说明,不执行任何动作。 */
|
|
661
669
|
const SIMPLE_COMMAND_HELP = {
|
|
662
670
|
login: [
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { McpServerConfig, McpToolInfo } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* 极简 MCP 客户端:只做 initialize / tools/list / tools/call 三件事,
|
|
4
|
+
* 支持 stdio(本机进程,按行 JSON-RPC)和 Streamable HTTP(POST JSON,
|
|
5
|
+
* 响应可能是 JSON 也可能是 SSE)两种传输。不引 SDK:pi 本身没有 MCP client,
|
|
6
|
+
* 我们只需要把远端工具挂成 pi 工具,协议面很窄,自己写反而少一坨依赖。
|
|
7
|
+
*/
|
|
8
|
+
export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
9
|
+
export declare const DEFAULT_CONNECT_TIMEOUT_MS = 20000;
|
|
10
|
+
export declare const DEFAULT_CALL_TIMEOUT_MS = 120000;
|
|
11
|
+
export interface McpClientOptions {
|
|
12
|
+
connectTimeoutMs?: number;
|
|
13
|
+
callTimeoutMs?: number;
|
|
14
|
+
/** 服务端 stderr / 传输层告警;默认丢弃 */
|
|
15
|
+
onLog?: (line: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface McpServerInfo {
|
|
18
|
+
serverName?: string;
|
|
19
|
+
serverVersion?: string;
|
|
20
|
+
protocolVersion: string;
|
|
21
|
+
}
|
|
22
|
+
export interface McpCallResult {
|
|
23
|
+
text: string;
|
|
24
|
+
isError: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare class McpError extends Error {
|
|
27
|
+
readonly code?: number | undefined;
|
|
28
|
+
constructor(message: string, code?: number | undefined);
|
|
29
|
+
}
|
|
30
|
+
/** 解析 SSE 正文,返回每个 event 的 data 拼接结果 */
|
|
31
|
+
export declare function parseSseData(body: string): string[];
|
|
32
|
+
/** 把 tools/call 返回的 content 数组压成模型可读的文本 */
|
|
33
|
+
export declare function contentToText(result: unknown): McpCallResult;
|
|
34
|
+
export declare function normalizeToolInfo(raw: unknown): McpToolInfo | undefined;
|
|
35
|
+
export declare class McpClient {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
private readonly config;
|
|
38
|
+
private readonly opts;
|
|
39
|
+
private transport;
|
|
40
|
+
private nextId;
|
|
41
|
+
private readonly connectTimeoutMs;
|
|
42
|
+
private readonly callTimeoutMs;
|
|
43
|
+
private connected;
|
|
44
|
+
info: McpServerInfo | undefined;
|
|
45
|
+
constructor(name: string, config: McpServerConfig, opts?: McpClientOptions);
|
|
46
|
+
/** 建连并完成 initialize 握手;重复调用返回同一个 promise,失败后可重试 */
|
|
47
|
+
connect(): Promise<McpServerInfo>;
|
|
48
|
+
private doConnect;
|
|
49
|
+
private rpc;
|
|
50
|
+
listTools(): Promise<McpToolInfo[]>;
|
|
51
|
+
callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<McpCallResult>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { VERSION } from "../config.js";
|
|
3
|
+
/**
|
|
4
|
+
* 极简 MCP 客户端:只做 initialize / tools/list / tools/call 三件事,
|
|
5
|
+
* 支持 stdio(本机进程,按行 JSON-RPC)和 Streamable HTTP(POST JSON,
|
|
6
|
+
* 响应可能是 JSON 也可能是 SSE)两种传输。不引 SDK:pi 本身没有 MCP client,
|
|
7
|
+
* 我们只需要把远端工具挂成 pi 工具,协议面很窄,自己写反而少一坨依赖。
|
|
8
|
+
*/
|
|
9
|
+
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
10
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
|
|
11
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 120_000;
|
|
12
|
+
export class McpError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(message, code) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "McpError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** 谁先到算谁的:结果、超时、取消三者任一落定就清掉定时器与监听,别让悬着的定时器吊住进程 */
|
|
21
|
+
function withTimeout(promise, timeoutMs, signal, what) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let settled = false;
|
|
24
|
+
const finish = (fn) => (v) => {
|
|
25
|
+
if (settled)
|
|
26
|
+
return;
|
|
27
|
+
settled = true;
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
signal?.removeEventListener("abort", onAbort);
|
|
30
|
+
fn(v);
|
|
31
|
+
};
|
|
32
|
+
const onAbort = () => finish(reject)(new McpError(`${what} 已取消`));
|
|
33
|
+
const timer = setTimeout(() => finish(reject)(new McpError(`${what} 超过 ${Math.round(timeoutMs / 1000)}s 未响应`)), timeoutMs);
|
|
34
|
+
promise.then(finish(resolve), finish(reject));
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
onAbort();
|
|
37
|
+
else
|
|
38
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function asRecord(value) {
|
|
42
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
43
|
+
}
|
|
44
|
+
// ---------------- stdio ----------------
|
|
45
|
+
class StdioTransport {
|
|
46
|
+
command;
|
|
47
|
+
args;
|
|
48
|
+
env;
|
|
49
|
+
onLog;
|
|
50
|
+
child;
|
|
51
|
+
buffer = "";
|
|
52
|
+
pending = new Map();
|
|
53
|
+
exited;
|
|
54
|
+
stderrTail = [];
|
|
55
|
+
constructor(command, args, env, onLog) {
|
|
56
|
+
this.command = command;
|
|
57
|
+
this.args = args;
|
|
58
|
+
this.env = env;
|
|
59
|
+
this.onLog = onLog;
|
|
60
|
+
}
|
|
61
|
+
start() {
|
|
62
|
+
const child = spawn(this.command, this.args, {
|
|
63
|
+
env: { ...process.env, ...this.env },
|
|
64
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
65
|
+
// Windows 上 npx / *.cmd 只能经 shell 启动
|
|
66
|
+
shell: process.platform === "win32",
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
});
|
|
69
|
+
this.child = child;
|
|
70
|
+
child.stdout?.setEncoding("utf8");
|
|
71
|
+
child.stdout?.on("data", (chunk) => this.onData(chunk));
|
|
72
|
+
child.stderr?.setEncoding("utf8");
|
|
73
|
+
child.stderr?.on("data", (chunk) => {
|
|
74
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
75
|
+
if (!line.trim())
|
|
76
|
+
continue;
|
|
77
|
+
this.stderrTail.push(line);
|
|
78
|
+
if (this.stderrTail.length > 20)
|
|
79
|
+
this.stderrTail.shift();
|
|
80
|
+
this.onLog?.(line);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// CLI 自己退出时把服务进程一起带走,别留孤儿
|
|
84
|
+
const onParentExit = () => {
|
|
85
|
+
try {
|
|
86
|
+
child.kill("SIGKILL");
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
};
|
|
90
|
+
process.once("exit", onParentExit);
|
|
91
|
+
child.on("error", (err) => {
|
|
92
|
+
// spawn 失败(ENOENT 等)不会再有 exit 事件
|
|
93
|
+
if (child.pid === undefined)
|
|
94
|
+
process.removeListener("exit", onParentExit);
|
|
95
|
+
this.fail(new McpError(`启动 ${this.command} 失败:${err.message}`));
|
|
96
|
+
});
|
|
97
|
+
child.on("exit", (code, sig) => {
|
|
98
|
+
process.removeListener("exit", onParentExit);
|
|
99
|
+
const tail = this.stderrTail.slice(-5).join("\n");
|
|
100
|
+
this.fail(new McpError(`MCP 服务进程已退出(${sig ?? `code ${code ?? 0}`})${tail ? `\n${tail}` : ""}`));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
fail(err) {
|
|
104
|
+
if (this.exited)
|
|
105
|
+
return;
|
|
106
|
+
this.exited = err;
|
|
107
|
+
for (const p of this.pending.values())
|
|
108
|
+
p.reject(err);
|
|
109
|
+
this.pending.clear();
|
|
110
|
+
}
|
|
111
|
+
onData(chunk) {
|
|
112
|
+
this.buffer += chunk;
|
|
113
|
+
let idx = this.buffer.indexOf("\n");
|
|
114
|
+
while (idx >= 0) {
|
|
115
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
116
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
117
|
+
if (line)
|
|
118
|
+
this.onLine(line);
|
|
119
|
+
idx = this.buffer.indexOf("\n");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
onLine(line) {
|
|
123
|
+
let msg;
|
|
124
|
+
try {
|
|
125
|
+
msg = JSON.parse(line);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
this.onLog?.(line);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (msg.id !== undefined && msg.method === undefined) {
|
|
132
|
+
const p = this.pending.get(msg.id);
|
|
133
|
+
if (p) {
|
|
134
|
+
this.pending.delete(msg.id);
|
|
135
|
+
p.resolve(msg);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (msg.id !== undefined && msg.method) {
|
|
140
|
+
// 服务端反向请求(sampling / roots 等)不支持,回一个 method not found 免得它挂着
|
|
141
|
+
this.write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `Method not supported by u1s1: ${msg.method}` } });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
write(msg) {
|
|
145
|
+
if (this.exited)
|
|
146
|
+
throw this.exited;
|
|
147
|
+
const stdin = this.child?.stdin;
|
|
148
|
+
if (!stdin || stdin.destroyed)
|
|
149
|
+
throw new McpError("MCP 服务进程 stdin 已关闭");
|
|
150
|
+
stdin.write(`${JSON.stringify(msg)}\n`);
|
|
151
|
+
}
|
|
152
|
+
request(msg, timeoutMs, signal) {
|
|
153
|
+
const id = msg.id;
|
|
154
|
+
const promise = new Promise((resolve, reject) => {
|
|
155
|
+
this.pending.set(id, { resolve, reject });
|
|
156
|
+
try {
|
|
157
|
+
this.write(msg);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
this.pending.delete(id);
|
|
161
|
+
reject(err);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return withTimeout(promise, timeoutMs, signal, msg.method ?? "请求").finally(() => this.pending.delete(id));
|
|
165
|
+
}
|
|
166
|
+
async notify(msg) {
|
|
167
|
+
this.write(msg);
|
|
168
|
+
}
|
|
169
|
+
async close() {
|
|
170
|
+
const child = this.child;
|
|
171
|
+
if (!child)
|
|
172
|
+
return;
|
|
173
|
+
this.fail(new McpError("MCP 连接已关闭"));
|
|
174
|
+
try {
|
|
175
|
+
child.stdin?.end();
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
// 没 pid = 根本没启动起来(spawn error),不会有 exit 事件,别等
|
|
179
|
+
if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
|
|
180
|
+
await new Promise((resolve) => {
|
|
181
|
+
const done = () => {
|
|
182
|
+
clearTimeout(soft);
|
|
183
|
+
clearTimeout(hard);
|
|
184
|
+
resolve();
|
|
185
|
+
};
|
|
186
|
+
child.once("exit", done);
|
|
187
|
+
child.kill();
|
|
188
|
+
const soft = setTimeout(() => child.kill("SIGKILL"), 2_000);
|
|
189
|
+
const hard = setTimeout(done, 3_000);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ---------------- Streamable HTTP ----------------
|
|
195
|
+
/** 解析 SSE 正文,返回每个 event 的 data 拼接结果 */
|
|
196
|
+
export function parseSseData(body) {
|
|
197
|
+
const out = [];
|
|
198
|
+
let data = [];
|
|
199
|
+
for (const rawLine of body.split(/\r?\n/)) {
|
|
200
|
+
if (rawLine === "") {
|
|
201
|
+
if (data.length)
|
|
202
|
+
out.push(data.join("\n"));
|
|
203
|
+
data = [];
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (rawLine.startsWith(":"))
|
|
207
|
+
continue;
|
|
208
|
+
const idx = rawLine.indexOf(":");
|
|
209
|
+
const field = idx === -1 ? rawLine : rawLine.slice(0, idx);
|
|
210
|
+
if (field !== "data")
|
|
211
|
+
continue;
|
|
212
|
+
let value = idx === -1 ? "" : rawLine.slice(idx + 1);
|
|
213
|
+
if (value.startsWith(" "))
|
|
214
|
+
value = value.slice(1);
|
|
215
|
+
data.push(value);
|
|
216
|
+
}
|
|
217
|
+
if (data.length)
|
|
218
|
+
out.push(data.join("\n"));
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
class HttpTransport {
|
|
222
|
+
url;
|
|
223
|
+
headers;
|
|
224
|
+
sessionId;
|
|
225
|
+
protocolVersion;
|
|
226
|
+
constructor(url, headers) {
|
|
227
|
+
this.url = url;
|
|
228
|
+
this.headers = headers;
|
|
229
|
+
}
|
|
230
|
+
setProtocolVersion(v) {
|
|
231
|
+
this.protocolVersion = v;
|
|
232
|
+
}
|
|
233
|
+
buildHeaders() {
|
|
234
|
+
const h = {
|
|
235
|
+
"content-type": "application/json",
|
|
236
|
+
accept: "application/json, text/event-stream",
|
|
237
|
+
...this.headers,
|
|
238
|
+
};
|
|
239
|
+
if (this.sessionId)
|
|
240
|
+
h["mcp-session-id"] = this.sessionId;
|
|
241
|
+
if (this.protocolVersion)
|
|
242
|
+
h["mcp-protocol-version"] = this.protocolVersion;
|
|
243
|
+
return h;
|
|
244
|
+
}
|
|
245
|
+
async post(msg, timeoutMs, signal) {
|
|
246
|
+
const ctrl = new AbortController();
|
|
247
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
248
|
+
const onAbort = () => ctrl.abort();
|
|
249
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
250
|
+
try {
|
|
251
|
+
return await fetch(this.url, { method: "POST", headers: this.buildHeaders(), body: JSON.stringify(msg), signal: ctrl.signal });
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (signal?.aborted)
|
|
255
|
+
throw new McpError(`${msg.method ?? "请求"} 已取消`);
|
|
256
|
+
if (ctrl.signal.aborted)
|
|
257
|
+
throw new McpError(`${msg.method ?? "请求"} 超过 ${Math.round(timeoutMs / 1000)}s 未响应`);
|
|
258
|
+
throw new McpError(`连接 ${this.url} 失败:${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
signal?.removeEventListener("abort", onAbort);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async request(msg, timeoutMs, signal) {
|
|
266
|
+
const resp = await this.post(msg, timeoutMs, signal);
|
|
267
|
+
const sid = resp.headers.get("mcp-session-id");
|
|
268
|
+
if (sid)
|
|
269
|
+
this.sessionId = sid;
|
|
270
|
+
if (!resp.ok) {
|
|
271
|
+
const text = (await resp.text().catch(() => "")).slice(0, 300);
|
|
272
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
273
|
+
throw new McpError(`服务拒绝访问(HTTP ${resp.status}),请检查 headers 里的鉴权信息${text ? `:${text}` : ""}`, resp.status);
|
|
274
|
+
}
|
|
275
|
+
throw new McpError(`HTTP ${resp.status}${text ? `:${text}` : ""}`, resp.status);
|
|
276
|
+
}
|
|
277
|
+
const ctype = (resp.headers.get("content-type") ?? "").toLowerCase();
|
|
278
|
+
const body = await resp.text();
|
|
279
|
+
const candidates = ctype.includes("text/event-stream") ? parseSseData(body) : [body];
|
|
280
|
+
for (const raw of candidates) {
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(raw);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
289
|
+
for (const item of list) {
|
|
290
|
+
const m = item;
|
|
291
|
+
if (m && m.id === msg.id && m.method === undefined)
|
|
292
|
+
return m;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
throw new McpError(`服务没有返回 ${msg.method ?? "请求"} 的结果(content-type ${ctype || "unknown"})`);
|
|
296
|
+
}
|
|
297
|
+
async notify(msg) {
|
|
298
|
+
const resp = await this.post(msg, 10_000);
|
|
299
|
+
// 规范要求 202;有些实现回 200/204,都当成功。鉴权失败要冒出来
|
|
300
|
+
if (resp.status === 401 || resp.status === 403)
|
|
301
|
+
throw new McpError(`服务拒绝访问(HTTP ${resp.status})`, resp.status);
|
|
302
|
+
await resp.body?.cancel().catch(() => { });
|
|
303
|
+
}
|
|
304
|
+
async close() {
|
|
305
|
+
if (!this.sessionId)
|
|
306
|
+
return;
|
|
307
|
+
try {
|
|
308
|
+
await fetch(this.url, { method: "DELETE", headers: this.buildHeaders(), signal: AbortSignal.timeout(3_000) });
|
|
309
|
+
}
|
|
310
|
+
catch { }
|
|
311
|
+
this.sessionId = undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ---------------- client ----------------
|
|
315
|
+
/** 把 tools/call 返回的 content 数组压成模型可读的文本 */
|
|
316
|
+
export function contentToText(result) {
|
|
317
|
+
const rec = asRecord(result) ?? {};
|
|
318
|
+
const parts = [];
|
|
319
|
+
const content = Array.isArray(rec["content"]) ? rec["content"] : [];
|
|
320
|
+
for (const raw of content) {
|
|
321
|
+
const item = asRecord(raw);
|
|
322
|
+
if (!item)
|
|
323
|
+
continue;
|
|
324
|
+
const type = item["type"];
|
|
325
|
+
if (type === "text" && typeof item["text"] === "string") {
|
|
326
|
+
parts.push(item["text"]);
|
|
327
|
+
}
|
|
328
|
+
else if (type === "image" || type === "audio") {
|
|
329
|
+
const data = typeof item["data"] === "string" ? item["data"] : "";
|
|
330
|
+
parts.push(`[${type} ${String(item["mimeType"] ?? "")} ${Buffer.from(data, "base64").length} bytes, omitted]`);
|
|
331
|
+
}
|
|
332
|
+
else if (type === "resource") {
|
|
333
|
+
const res = asRecord(item["resource"]);
|
|
334
|
+
if (res && typeof res["text"] === "string")
|
|
335
|
+
parts.push(res["text"]);
|
|
336
|
+
else
|
|
337
|
+
parts.push(`[resource ${String(res?.["uri"] ?? "")} (binary, omitted)]`);
|
|
338
|
+
}
|
|
339
|
+
else if (type === "resource_link") {
|
|
340
|
+
parts.push(`[link] ${String(item["uri"] ?? "")}${item["name"] ? ` ${String(item["name"])}` : ""}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (parts.length === 0 && rec["structuredContent"] !== undefined) {
|
|
344
|
+
parts.push(JSON.stringify(rec["structuredContent"], null, 2));
|
|
345
|
+
}
|
|
346
|
+
return { text: parts.join("\n").trim(), isError: rec["isError"] === true };
|
|
347
|
+
}
|
|
348
|
+
export function normalizeToolInfo(raw) {
|
|
349
|
+
const rec = asRecord(raw);
|
|
350
|
+
if (!rec || typeof rec["name"] !== "string" || !rec["name"])
|
|
351
|
+
return undefined;
|
|
352
|
+
const schema = asRecord(rec["inputSchema"]) ?? { type: "object", properties: {} };
|
|
353
|
+
return {
|
|
354
|
+
name: rec["name"],
|
|
355
|
+
description: typeof rec["description"] === "string" ? rec["description"] : "",
|
|
356
|
+
inputSchema: { ...schema, type: "object" },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
export class McpClient {
|
|
360
|
+
name;
|
|
361
|
+
config;
|
|
362
|
+
opts;
|
|
363
|
+
transport;
|
|
364
|
+
nextId = 1;
|
|
365
|
+
connectTimeoutMs;
|
|
366
|
+
callTimeoutMs;
|
|
367
|
+
connected;
|
|
368
|
+
info;
|
|
369
|
+
constructor(name, config, opts = {}) {
|
|
370
|
+
this.name = name;
|
|
371
|
+
this.config = config;
|
|
372
|
+
this.opts = opts;
|
|
373
|
+
this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
374
|
+
this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
375
|
+
}
|
|
376
|
+
/** 建连并完成 initialize 握手;重复调用返回同一个 promise,失败后可重试 */
|
|
377
|
+
connect() {
|
|
378
|
+
if (!this.connected) {
|
|
379
|
+
this.connected = this.doConnect().catch(async (err) => {
|
|
380
|
+
this.connected = undefined;
|
|
381
|
+
await this.close().catch(() => { });
|
|
382
|
+
throw err;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return this.connected;
|
|
386
|
+
}
|
|
387
|
+
async doConnect() {
|
|
388
|
+
const transport = this.config.type === "stdio"
|
|
389
|
+
? new StdioTransport(this.config.command, this.config.args, this.config.env, this.opts.onLog)
|
|
390
|
+
: new HttpTransport(this.config.url, this.config.headers);
|
|
391
|
+
if (transport instanceof StdioTransport)
|
|
392
|
+
transport.start();
|
|
393
|
+
this.transport = transport;
|
|
394
|
+
const result = await this.rpc("initialize", {
|
|
395
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
396
|
+
capabilities: {},
|
|
397
|
+
clientInfo: { name: "u1s1", version: VERSION },
|
|
398
|
+
}, this.connectTimeoutMs);
|
|
399
|
+
const rec = asRecord(result) ?? {};
|
|
400
|
+
const serverInfo = asRecord(rec["serverInfo"]);
|
|
401
|
+
const protocolVersion = typeof rec["protocolVersion"] === "string" ? rec["protocolVersion"] : MCP_PROTOCOL_VERSION;
|
|
402
|
+
if (transport instanceof HttpTransport)
|
|
403
|
+
transport.setProtocolVersion(protocolVersion);
|
|
404
|
+
await transport.notify({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
405
|
+
this.info = {
|
|
406
|
+
serverName: typeof serverInfo?.["name"] === "string" ? serverInfo["name"] : undefined,
|
|
407
|
+
serverVersion: typeof serverInfo?.["version"] === "string" ? serverInfo["version"] : undefined,
|
|
408
|
+
protocolVersion,
|
|
409
|
+
};
|
|
410
|
+
return this.info;
|
|
411
|
+
}
|
|
412
|
+
async rpc(method, params, timeoutMs, signal) {
|
|
413
|
+
const transport = this.transport;
|
|
414
|
+
if (!transport)
|
|
415
|
+
throw new McpError("尚未连接");
|
|
416
|
+
const id = this.nextId++;
|
|
417
|
+
const resp = await transport.request({ jsonrpc: "2.0", id, method, params }, timeoutMs, signal);
|
|
418
|
+
if (resp.error) {
|
|
419
|
+
throw new McpError(`${resp.error.message}${resp.error.data !== undefined ? ` ${JSON.stringify(resp.error.data).slice(0, 300)}` : ""}`, resp.error.code);
|
|
420
|
+
}
|
|
421
|
+
return resp.result;
|
|
422
|
+
}
|
|
423
|
+
async listTools() {
|
|
424
|
+
await this.connect();
|
|
425
|
+
const tools = [];
|
|
426
|
+
let cursor;
|
|
427
|
+
for (let page = 0; page < 50; page++) {
|
|
428
|
+
const result = asRecord(await this.rpc("tools/list", cursor ? { cursor } : {}, this.connectTimeoutMs)) ?? {};
|
|
429
|
+
for (const raw of Array.isArray(result["tools"]) ? result["tools"] : []) {
|
|
430
|
+
const tool = normalizeToolInfo(raw);
|
|
431
|
+
if (tool)
|
|
432
|
+
tools.push(tool);
|
|
433
|
+
}
|
|
434
|
+
cursor = typeof result["nextCursor"] === "string" && result["nextCursor"] ? result["nextCursor"] : undefined;
|
|
435
|
+
if (!cursor)
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
return tools;
|
|
439
|
+
}
|
|
440
|
+
async callTool(name, args, signal) {
|
|
441
|
+
await this.connect();
|
|
442
|
+
const result = await this.rpc("tools/call", { name, arguments: args }, this.callTimeoutMs, signal);
|
|
443
|
+
return contentToText(result);
|
|
444
|
+
}
|
|
445
|
+
async close() {
|
|
446
|
+
const t = this.transport;
|
|
447
|
+
this.transport = undefined;
|
|
448
|
+
this.connected = undefined;
|
|
449
|
+
if (t)
|
|
450
|
+
await t.close();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type McpServerConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* `u1s1 mcp` 子命令:管理自定义 MCP 服务。
|
|
4
|
+
* add 时先连一次拉工具清单再落盘,坏配置不会拖慢每次启动。
|
|
5
|
+
*/
|
|
6
|
+
export declare function printMcpHelp(): void;
|
|
7
|
+
export declare class McpUsageError extends Error {
|
|
8
|
+
}
|
|
9
|
+
export interface ParsedAdd {
|
|
10
|
+
name: string;
|
|
11
|
+
server: McpServerConfig;
|
|
12
|
+
force: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseAddArgs(args: string[]): ParsedAdd;
|
|
15
|
+
export interface ImportCandidate {
|
|
16
|
+
name: string;
|
|
17
|
+
server: McpServerConfig;
|
|
18
|
+
source: string;
|
|
19
|
+
}
|
|
20
|
+
/** Claude Code 的 MCP 配置:~/.claude.json(用户级 + 各项目)与项目 .mcp.json */
|
|
21
|
+
export declare function collectClaudeMcpServers(home?: string, cwd?: string): {
|
|
22
|
+
candidates: ImportCandidate[];
|
|
23
|
+
unsupported: string[];
|
|
24
|
+
};
|
|
25
|
+
export declare function mcpCommand(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { formatHomePath } from "../brand.js";
|
|
5
|
+
import { McpClient } from "./client.js";
|
|
6
|
+
import { loadMcpConfig, loadMcpToolCache, maskSecret, MCP_SERVER_NAME_RE, mcpConfigPath, normalizeServerConfig, parseKeyValue, saveMcpConfig, saveMcpToolCache, } from "./config.js";
|
|
7
|
+
import { mcpToolName } from "./tools.js";
|
|
8
|
+
/**
|
|
9
|
+
* `u1s1 mcp` 子命令:管理自定义 MCP 服务。
|
|
10
|
+
* add 时先连一次拉工具清单再落盘,坏配置不会拖慢每次启动。
|
|
11
|
+
*/
|
|
12
|
+
export function printMcpHelp() {
|
|
13
|
+
console.log("");
|
|
14
|
+
console.log(" 配置第三方 MCP 服务,让它提供的工具(搜索、数据库、浏览器…)在对话里直接可用");
|
|
15
|
+
console.log("");
|
|
16
|
+
console.log(" 用法:");
|
|
17
|
+
console.log(" u1s1 mcp add 名称 命令 [参数...] 本机命令型服务(stdio),例:");
|
|
18
|
+
console.log(" u1s1 mcp add tavily --env TAVILY_API_KEY=tvly-xxx npx -y tavily-mcp");
|
|
19
|
+
console.log(" u1s1 mcp add 名称 --url 地址 HTTP 型服务(Streamable HTTP),例:");
|
|
20
|
+
console.log(" u1s1 mcp add exa --url https://mcp.exa.ai/mcp --header \"Authorization: Bearer xxx\"");
|
|
21
|
+
console.log(" --env K=V / --header \"K: V\" 可重复;--force 连接失败也保存");
|
|
22
|
+
console.log(" u1s1 mcp list 看已配置的服务和工具");
|
|
23
|
+
console.log(" u1s1 mcp test 名称 连一次,列出它提供的工具");
|
|
24
|
+
console.log(" u1s1 mcp remove 名称 删除");
|
|
25
|
+
console.log(" u1s1 mcp import [--dry-run] 从 Claude Code 的配置(~/.claude.json、项目 .mcp.json)导入");
|
|
26
|
+
console.log("");
|
|
27
|
+
console.log(` 配置存在 ${formatHomePath(mcpConfigPath())}(与 Claude Code 的 mcpServers 同格式,可手改),`);
|
|
28
|
+
console.log(" 只在本机,不会上传。下次进对话工具即生效,名字形如 mcp_名称_工具。");
|
|
29
|
+
console.log(" 命令型服务会以你的权限在本机运行,只添加可信来源。");
|
|
30
|
+
console.log("");
|
|
31
|
+
}
|
|
32
|
+
export class McpUsageError extends Error {
|
|
33
|
+
}
|
|
34
|
+
function fail(message) {
|
|
35
|
+
throw new McpUsageError(message);
|
|
36
|
+
}
|
|
37
|
+
function describeServer(server) {
|
|
38
|
+
if (server.type === "stdio") {
|
|
39
|
+
const env = Object.entries(server.env).map(([k, v]) => `${k}=${maskSecret(v)}`);
|
|
40
|
+
return `${[server.command, ...server.args].join(" ")}${env.length ? ` (env: ${env.join(" ")})` : ""}`;
|
|
41
|
+
}
|
|
42
|
+
const headers = Object.keys(server.headers);
|
|
43
|
+
return `${server.url}${headers.length ? ` (headers: ${headers.join(", ")})` : ""}`;
|
|
44
|
+
}
|
|
45
|
+
async function probe(name, server) {
|
|
46
|
+
const client = new McpClient(name, server, { onLog: (line) => console.error(` [${name}] ${line}`) });
|
|
47
|
+
try {
|
|
48
|
+
const info = await client.connect();
|
|
49
|
+
const tools = await client.listTools();
|
|
50
|
+
return { tools, serverName: info.serverName };
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await client.close().catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function printTools(name, tools) {
|
|
57
|
+
if (tools.length === 0) {
|
|
58
|
+
console.log(" (该服务没有提供任何工具)");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const t of tools) {
|
|
62
|
+
const desc = t.description.replace(/\s+/g, " ").trim();
|
|
63
|
+
console.log(` · ${mcpToolName(name, t.name)}${desc ? ` ${desc.length > 80 ? `${desc.slice(0, 79)}…` : desc}` : ""}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function cacheTools(name, tools, serverName) {
|
|
67
|
+
const cache = loadMcpToolCache();
|
|
68
|
+
cache.servers[name] = { fetchedAt: new Date().toISOString(), serverName, tools };
|
|
69
|
+
saveMcpToolCache(cache);
|
|
70
|
+
}
|
|
71
|
+
function dropCache(name) {
|
|
72
|
+
const cache = loadMcpToolCache();
|
|
73
|
+
if (!cache.servers[name])
|
|
74
|
+
return;
|
|
75
|
+
delete cache.servers[name];
|
|
76
|
+
saveMcpToolCache(cache);
|
|
77
|
+
}
|
|
78
|
+
export function parseAddArgs(args) {
|
|
79
|
+
const name = args[0];
|
|
80
|
+
if (!name || name.startsWith("-"))
|
|
81
|
+
fail("请给服务起个名字,例:u1s1 mcp add tavily npx -y tavily-mcp");
|
|
82
|
+
if (!MCP_SERVER_NAME_RE.test(name))
|
|
83
|
+
fail(`服务名只能用小写字母、数字、- 和 _,且以字母或数字开头(最长 32 位):${name}`);
|
|
84
|
+
let url;
|
|
85
|
+
const env = {};
|
|
86
|
+
const headers = {};
|
|
87
|
+
let force = false;
|
|
88
|
+
let command;
|
|
89
|
+
const commandArgs = [];
|
|
90
|
+
for (let i = 1; i < args.length; i++) {
|
|
91
|
+
const a = args[i];
|
|
92
|
+
if (command !== undefined) {
|
|
93
|
+
commandArgs.push(a);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (a === "--") {
|
|
97
|
+
command = args[i + 1];
|
|
98
|
+
commandArgs.push(...args.slice(i + 2));
|
|
99
|
+
if (command === undefined)
|
|
100
|
+
fail("-- 后面要跟命令");
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (a === "--force" || a === "-f") {
|
|
104
|
+
force = true;
|
|
105
|
+
}
|
|
106
|
+
else if (a === "--url" || a.startsWith("--url=")) {
|
|
107
|
+
url = a.includes("=") ? a.slice(6) : args[++i];
|
|
108
|
+
if (!url)
|
|
109
|
+
fail("--url 后面要跟地址");
|
|
110
|
+
}
|
|
111
|
+
else if (a === "--env" || a === "-e" || a.startsWith("--env=")) {
|
|
112
|
+
const raw = a.includes("=") && a.startsWith("--env=") ? a.slice(6) : args[++i];
|
|
113
|
+
const kv = raw ? parseKeyValue(raw, "=") : undefined;
|
|
114
|
+
if (!kv)
|
|
115
|
+
fail(`--env 要写成 KEY=VALUE:${raw ?? ""}`);
|
|
116
|
+
env[kv[0]] = kv[1];
|
|
117
|
+
}
|
|
118
|
+
else if (a === "--header" || a === "-H" || a.startsWith("--header=")) {
|
|
119
|
+
const raw = a.startsWith("--header=") ? a.slice(9) : args[++i];
|
|
120
|
+
const kv = raw ? parseKeyValue(raw, ":") : undefined;
|
|
121
|
+
if (!kv)
|
|
122
|
+
fail(`--header 要写成 "Name: value":${raw ?? ""}`);
|
|
123
|
+
headers[kv[0]] = kv[1];
|
|
124
|
+
}
|
|
125
|
+
else if (a.startsWith("-") && a.length > 1) {
|
|
126
|
+
fail(`不认识的选项 ${a}(命令自己的参数请放在命令后面,或用 -- 隔开)`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
command = a;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (url && command)
|
|
133
|
+
fail("--url 和命令只能二选一");
|
|
134
|
+
if (url) {
|
|
135
|
+
if (!/^https?:\/\//i.test(url))
|
|
136
|
+
fail(`--url 必须是 http(s) 地址:${url}`);
|
|
137
|
+
if (Object.keys(env).length)
|
|
138
|
+
fail("HTTP 型服务不支持 --env,鉴权请用 --header");
|
|
139
|
+
return { name, server: { type: "http", url, headers }, force };
|
|
140
|
+
}
|
|
141
|
+
if (!command)
|
|
142
|
+
fail("请给出启动命令(如 npx -y some-mcp-server)或 --url 地址");
|
|
143
|
+
if (Object.keys(headers).length)
|
|
144
|
+
fail("命令型服务不支持 --header,请用 --env 传密钥");
|
|
145
|
+
return { name, server: { type: "stdio", command, args: commandArgs, env }, force };
|
|
146
|
+
}
|
|
147
|
+
async function addCommand(args) {
|
|
148
|
+
const { name, server, force } = parseAddArgs(args);
|
|
149
|
+
const config = loadMcpConfig();
|
|
150
|
+
const existed = Boolean(config.mcpServers[name]);
|
|
151
|
+
console.log(`${existed ? "更新" : "添加"} MCP 服务 ${name}:${describeServer(server)}`);
|
|
152
|
+
console.log("连接中…");
|
|
153
|
+
let tools = [];
|
|
154
|
+
let serverName;
|
|
155
|
+
let probed = false;
|
|
156
|
+
try {
|
|
157
|
+
({ tools, serverName } = await probe(name, server));
|
|
158
|
+
probed = true;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
162
|
+
if (!force)
|
|
163
|
+
fail(`连接失败,未保存:${message}\n 确认命令/地址和密钥无误后重试;想先保存加 --force`);
|
|
164
|
+
console.error(`⚠ 连接失败:${message}\n 已按 --force 保存,进对话前请用 u1s1 mcp test ${name} 确认可用`);
|
|
165
|
+
}
|
|
166
|
+
config.mcpServers[name] = server;
|
|
167
|
+
saveMcpConfig(config);
|
|
168
|
+
if (probed) {
|
|
169
|
+
cacheTools(name, tools, serverName);
|
|
170
|
+
console.log(`✓ 已连接${serverName ? ` ${serverName}` : ""},提供 ${tools.length} 个工具:`);
|
|
171
|
+
printTools(name, tools);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
dropCache(name);
|
|
175
|
+
}
|
|
176
|
+
console.log(`✓ 已保存到 ${formatHomePath(mcpConfigPath())},下次进入对话即可使用`);
|
|
177
|
+
if (server.type === "stdio")
|
|
178
|
+
console.log(" 提示:命令型服务会以你的权限在本机运行,只添加可信来源。");
|
|
179
|
+
}
|
|
180
|
+
function listCommand() {
|
|
181
|
+
const config = loadMcpConfig();
|
|
182
|
+
const names = Object.keys(config.mcpServers);
|
|
183
|
+
if (names.length === 0) {
|
|
184
|
+
console.log("还没有配置 MCP 服务。用 u1s1 mcp add 添加,或 u1s1 mcp import 从 Claude Code 导入。");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const cache = loadMcpToolCache();
|
|
188
|
+
console.log(`已配置 ${names.length} 个 MCP 服务(${formatHomePath(mcpConfigPath())}):`);
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const server = config.mcpServers[name];
|
|
191
|
+
const cached = cache.servers[name];
|
|
192
|
+
console.log("");
|
|
193
|
+
console.log(`${name} [${server.type}] ${describeServer(server)}`);
|
|
194
|
+
if (!cached) {
|
|
195
|
+
console.log(" 工具清单尚未获取(进对话时会自动连一次;也可 u1s1 mcp test 看看)");
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
console.log(` ${cached.tools.length} 个工具(${cached.fetchedAt.slice(0, 16).replace("T", " ")} 获取):`);
|
|
199
|
+
printTools(name, cached.tools);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function testCommand(name) {
|
|
203
|
+
if (!name)
|
|
204
|
+
fail("用法:u1s1 mcp test 名称");
|
|
205
|
+
const config = loadMcpConfig();
|
|
206
|
+
const server = config.mcpServers[name];
|
|
207
|
+
if (!server)
|
|
208
|
+
fail(`没有叫 ${name} 的 MCP 服务,u1s1 mcp list 看看已有的`);
|
|
209
|
+
console.log(`连接 ${name}:${describeServer(server)}`);
|
|
210
|
+
const started = Date.now();
|
|
211
|
+
let tools;
|
|
212
|
+
let serverName;
|
|
213
|
+
try {
|
|
214
|
+
({ tools, serverName } = await probe(name, server));
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
fail(`连接失败:${err instanceof Error ? err.message : String(err)}`);
|
|
218
|
+
}
|
|
219
|
+
cacheTools(name, tools, serverName);
|
|
220
|
+
console.log(`✓ 连接成功${serverName ? `(${serverName})` : ""},${Date.now() - started} ms,提供 ${tools.length} 个工具:`);
|
|
221
|
+
printTools(name, tools);
|
|
222
|
+
}
|
|
223
|
+
function removeCommand(name) {
|
|
224
|
+
if (!name)
|
|
225
|
+
fail("用法:u1s1 mcp remove 名称");
|
|
226
|
+
const config = loadMcpConfig();
|
|
227
|
+
if (!config.mcpServers[name])
|
|
228
|
+
fail(`没有叫 ${name} 的 MCP 服务`);
|
|
229
|
+
delete config.mcpServers[name];
|
|
230
|
+
saveMcpConfig(config);
|
|
231
|
+
dropCache(name);
|
|
232
|
+
console.log(`✓ 已删除 ${name}`);
|
|
233
|
+
}
|
|
234
|
+
/** Claude Code 的 MCP 配置:~/.claude.json(用户级 + 各项目)与项目 .mcp.json */
|
|
235
|
+
export function collectClaudeMcpServers(home = homedir(), cwd = process.cwd()) {
|
|
236
|
+
const candidates = [];
|
|
237
|
+
const unsupported = [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
const take = (servers, source) => {
|
|
240
|
+
if (!servers || typeof servers !== "object")
|
|
241
|
+
return;
|
|
242
|
+
for (const [name, raw] of Object.entries(servers)) {
|
|
243
|
+
const key = name.toLowerCase();
|
|
244
|
+
if (seen.has(key))
|
|
245
|
+
continue;
|
|
246
|
+
const server = normalizeServerConfig(raw);
|
|
247
|
+
if (!server) {
|
|
248
|
+
unsupported.push(`${name}(${source})`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!MCP_SERVER_NAME_RE.test(name)) {
|
|
252
|
+
unsupported.push(`${name}(${source},名字不合规)`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
seen.add(key);
|
|
256
|
+
candidates.push({ name, server, source });
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const readJson = (path) => {
|
|
260
|
+
if (!existsSync(path))
|
|
261
|
+
return undefined;
|
|
262
|
+
try {
|
|
263
|
+
const v = JSON.parse(readFileSync(path, "utf8"));
|
|
264
|
+
return v && typeof v === "object" ? v : undefined;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const project = readJson(join(cwd, ".mcp.json"));
|
|
271
|
+
take(project?.["mcpServers"], formatHomePath(join(cwd, ".mcp.json")));
|
|
272
|
+
const user = readJson(join(home, ".claude.json"));
|
|
273
|
+
const projects = user?.["projects"];
|
|
274
|
+
if (projects && typeof projects === "object") {
|
|
275
|
+
const entry = projects[resolve(cwd)];
|
|
276
|
+
if (entry && typeof entry === "object")
|
|
277
|
+
take(entry["mcpServers"], "~/.claude.json 项目配置");
|
|
278
|
+
}
|
|
279
|
+
take(user?.["mcpServers"], "~/.claude.json");
|
|
280
|
+
return { candidates, unsupported };
|
|
281
|
+
}
|
|
282
|
+
async function importCommand(args) {
|
|
283
|
+
const dryRun = args.includes("--dry-run");
|
|
284
|
+
const { candidates, unsupported } = collectClaudeMcpServers();
|
|
285
|
+
for (const u of unsupported)
|
|
286
|
+
console.log(`⚠ 跳过 ${u}:只支持命令型(stdio)和 HTTP 型服务`);
|
|
287
|
+
if (candidates.length === 0) {
|
|
288
|
+
console.log("没有在 Claude Code 的配置里找到可导入的 MCP 服务。");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const config = loadMcpConfig();
|
|
292
|
+
console.log(`找到 ${candidates.length} 个 MCP 服务:`);
|
|
293
|
+
for (const c of candidates) {
|
|
294
|
+
const tag = config.mcpServers[c.name] ? "(已存在,将覆盖)" : "";
|
|
295
|
+
console.log(` ${c.name} ${describeServer(c.server)} ← ${c.source} ${tag}`);
|
|
296
|
+
}
|
|
297
|
+
if (dryRun) {
|
|
298
|
+
console.log("--dry-run:未写盘");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const c of candidates)
|
|
302
|
+
config.mcpServers[c.name] = c.server;
|
|
303
|
+
saveMcpConfig(config);
|
|
304
|
+
console.log(`✓ 已保存到 ${formatHomePath(mcpConfigPath())}`);
|
|
305
|
+
let failed = 0;
|
|
306
|
+
for (const c of candidates) {
|
|
307
|
+
process.stdout.write(`连接 ${c.name}… `);
|
|
308
|
+
try {
|
|
309
|
+
const { tools, serverName } = await probe(c.name, c.server);
|
|
310
|
+
cacheTools(c.name, tools, serverName);
|
|
311
|
+
console.log(`✓ ${tools.length} 个工具`);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
failed += 1;
|
|
315
|
+
dropCache(c.name);
|
|
316
|
+
console.log(`✗ ${(err instanceof Error ? err.message : String(err)).split("\n")[0]}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (failed)
|
|
320
|
+
console.log(`${failed} 个服务连接失败,进对话前请用 u1s1 mcp test 名称 排查(失败的服务不会影响其他工具)`);
|
|
321
|
+
}
|
|
322
|
+
export async function mcpCommand(args) {
|
|
323
|
+
const sub = args[0];
|
|
324
|
+
try {
|
|
325
|
+
if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
|
|
326
|
+
printMcpHelp();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (sub === "add")
|
|
330
|
+
return await addCommand(args.slice(1));
|
|
331
|
+
if (sub === "list" || sub === "ls")
|
|
332
|
+
return listCommand();
|
|
333
|
+
if (sub === "test")
|
|
334
|
+
return await testCommand(args[1]);
|
|
335
|
+
if (sub === "remove" || sub === "rm" || sub === "delete")
|
|
336
|
+
return removeCommand(args[1]);
|
|
337
|
+
if (sub === "import")
|
|
338
|
+
return await importCommand(args.slice(1));
|
|
339
|
+
fail(`不认识的子命令 ${sub},u1s1 mcp --help 看用法`);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
if (err instanceof McpUsageError) {
|
|
343
|
+
console.error(`✗ ${err.message}`);
|
|
344
|
+
process.exitCode = 1;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自定义 MCP 服务配置:~/.u1s1/mcp.json,形状与 Claude Code 的 mcpServers 一致,
|
|
3
|
+
* 方便手改或从别的工具照抄。里面可能有 API Key(env / headers),所以走
|
|
4
|
+
* writePrivateFile(0600),并且永远不上传、不进遥测。
|
|
5
|
+
*/
|
|
6
|
+
export interface McpStdioServer {
|
|
7
|
+
type: "stdio";
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
env: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export interface McpHttpServer {
|
|
13
|
+
type: "http";
|
|
14
|
+
url: string;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export type McpServerConfig = McpStdioServer | McpHttpServer;
|
|
18
|
+
export interface McpConfig {
|
|
19
|
+
mcpServers: Record<string, McpServerConfig>;
|
|
20
|
+
}
|
|
21
|
+
export interface McpToolInfo {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchema: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface McpCachedServer {
|
|
27
|
+
fetchedAt: string;
|
|
28
|
+
serverName?: string;
|
|
29
|
+
tools: McpToolInfo[];
|
|
30
|
+
}
|
|
31
|
+
export interface McpToolCache {
|
|
32
|
+
version: 1;
|
|
33
|
+
servers: Record<string, McpCachedServer>;
|
|
34
|
+
}
|
|
35
|
+
/** 服务名同时用作工具名前缀,限成模型 API 都接受的字符。 */
|
|
36
|
+
export declare const MCP_SERVER_NAME_RE: RegExp;
|
|
37
|
+
export declare function mcpConfigPath(): string;
|
|
38
|
+
export declare function mcpCachePath(): string;
|
|
39
|
+
/**
|
|
40
|
+
* 把一条 mcpServers 记录规整成内部形状。兼容 Claude Code 的写法:
|
|
41
|
+
* `{command,args,env}`(stdio)、`{type:"http",url,headers}`;`type:"sse"`(旧版
|
|
42
|
+
* SSE 传输)不支持,返回 undefined 让调用方提示。
|
|
43
|
+
*/
|
|
44
|
+
export declare function normalizeServerConfig(raw: unknown): McpServerConfig | undefined;
|
|
45
|
+
export declare function parseMcpConfig(raw: unknown): {
|
|
46
|
+
config: McpConfig;
|
|
47
|
+
invalid: string[];
|
|
48
|
+
};
|
|
49
|
+
export declare function loadMcpConfig(path?: string): McpConfig;
|
|
50
|
+
/** 写盘时保留用户手写的其他顶层字段,只换 mcpServers。 */
|
|
51
|
+
export declare function saveMcpConfig(config: McpConfig, path?: string): void;
|
|
52
|
+
export declare function loadMcpToolCache(path?: string): McpToolCache;
|
|
53
|
+
export declare function saveMcpToolCache(cache: McpToolCache, path?: string): void;
|
|
54
|
+
/** 工具列表是否变化(名称 + 参数结构),用来决定要不要提示重启。 */
|
|
55
|
+
export declare function sameToolList(a: McpToolInfo[], b: McpToolInfo[]): boolean;
|
|
56
|
+
/** `K=V` → [K, V];没有 = 或 K 为空返回 undefined。 */
|
|
57
|
+
export declare function parseKeyValue(raw: string, sep: "=" | ":"): [string, string] | undefined;
|
|
58
|
+
/** 展示用:把 env / headers 里像密钥的值打码,列表和日志都不能露 Key。 */
|
|
59
|
+
export declare function maskSecret(value: string): string;
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
interface RegisterResult {
|
|
3
|
+
registered: Array<{
|
|
4
|
+
server: string;
|
|
5
|
+
tools: number;
|
|
6
|
+
fromCache: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
failed: Array<{
|
|
9
|
+
server: string;
|
|
10
|
+
error: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export declare function registerMcpServers(pi: ExtensionAPI): Promise<RegisterResult>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { loadMcpConfig, loadMcpToolCache, sameToolList, saveMcpToolCache, } from "./config.js";
|
|
2
|
+
import { McpClient } from "./client.js";
|
|
3
|
+
import { createMcpToolSet } from "./tools.js";
|
|
4
|
+
/**
|
|
5
|
+
* 扩展入口:启动时把 ~/.u1s1/mcp.json 里每个服务的工具注册进 pi。
|
|
6
|
+
* - 有缓存(上次 add/test/启动拉过清单)的服务直接按缓存注册,零启动开销,首次调用再连;
|
|
7
|
+
* - 没缓存的服务并行连一次拉清单(限时),失败只提示不阻塞;
|
|
8
|
+
* - session_start 后后台刷新一遍清单,变了就更新缓存并提示"重启后生效"。
|
|
9
|
+
*/
|
|
10
|
+
const FIRST_LOAD_TIMEOUT_MS = 15_000;
|
|
11
|
+
async function fetchTools(server, config, timeoutMs) {
|
|
12
|
+
const client = new McpClient(server, config, { connectTimeoutMs: timeoutMs });
|
|
13
|
+
try {
|
|
14
|
+
const info = await client.connect();
|
|
15
|
+
const tools = await client.listTools();
|
|
16
|
+
return { tools, serverName: info.serverName };
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
await client.close().catch(() => { });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function registerMcpServers(pi) {
|
|
23
|
+
const result = { registered: [], failed: [] };
|
|
24
|
+
const config = loadMcpConfig();
|
|
25
|
+
const names = Object.keys(config.mcpServers);
|
|
26
|
+
if (names.length === 0)
|
|
27
|
+
return result;
|
|
28
|
+
const cache = loadMcpToolCache();
|
|
29
|
+
const sets = [];
|
|
30
|
+
const warnings = [];
|
|
31
|
+
let ui;
|
|
32
|
+
const warn = (message) => {
|
|
33
|
+
if (ui) {
|
|
34
|
+
try {
|
|
35
|
+
ui.notify(message, "warning");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
warnings.push(message);
|
|
41
|
+
};
|
|
42
|
+
const register = (server, serverConfig, tools, fromCache) => {
|
|
43
|
+
const set = createMcpToolSet(server, serverConfig, tools);
|
|
44
|
+
for (const tool of set.tools)
|
|
45
|
+
pi.registerTool(tool);
|
|
46
|
+
sets.push(set);
|
|
47
|
+
result.registered.push({ server, tools: tools.length, fromCache });
|
|
48
|
+
};
|
|
49
|
+
let cacheDirty = false;
|
|
50
|
+
await Promise.all(names.map(async (server) => {
|
|
51
|
+
const serverConfig = config.mcpServers[server];
|
|
52
|
+
const cached = cache.servers[server];
|
|
53
|
+
if (cached) {
|
|
54
|
+
register(server, serverConfig, cached.tools, true);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const { tools, serverName } = await fetchTools(server, serverConfig, FIRST_LOAD_TIMEOUT_MS);
|
|
59
|
+
cache.servers[server] = { fetchedAt: new Date().toISOString(), serverName, tools };
|
|
60
|
+
cacheDirty = true;
|
|
61
|
+
register(server, serverConfig, tools, false);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
65
|
+
result.failed.push({ server, error: message });
|
|
66
|
+
warnings.push(`MCP 服务 ${server} 连接失败,本次会话不可用:${message.split("\n")[0]}\n 用 u1s1 mcp test ${server} 排查`);
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
if (cacheDirty) {
|
|
70
|
+
try {
|
|
71
|
+
saveMcpToolCache(pruneCache(cache, names));
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
}
|
|
75
|
+
let refreshed = false;
|
|
76
|
+
pi.on("session_start", (_event, ctx) => {
|
|
77
|
+
if (ctx.hasUI)
|
|
78
|
+
ui = ctx.ui;
|
|
79
|
+
for (const w of warnings.splice(0)) {
|
|
80
|
+
try {
|
|
81
|
+
ui?.notify(w, "warning");
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
}
|
|
85
|
+
if (refreshed)
|
|
86
|
+
return;
|
|
87
|
+
refreshed = true;
|
|
88
|
+
void refreshCache(config, cache, warn);
|
|
89
|
+
});
|
|
90
|
+
pi.on("session_shutdown", async () => {
|
|
91
|
+
await Promise.all(sets.map((s) => s.close().catch(() => { })));
|
|
92
|
+
});
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
function pruneCache(cache, keep) {
|
|
96
|
+
const servers = {};
|
|
97
|
+
for (const name of keep)
|
|
98
|
+
if (cache.servers[name])
|
|
99
|
+
servers[name] = cache.servers[name];
|
|
100
|
+
return { version: 1, servers };
|
|
101
|
+
}
|
|
102
|
+
/** 后台刷新工具清单:只写缓存,变化了提示一句,不动本会话已注册的工具 */
|
|
103
|
+
async function refreshCache(config, cache, warn) {
|
|
104
|
+
const names = Object.keys(config.mcpServers);
|
|
105
|
+
let dirty = false;
|
|
106
|
+
const changed = [];
|
|
107
|
+
await Promise.all(names.map(async (server) => {
|
|
108
|
+
try {
|
|
109
|
+
const { tools, serverName } = await fetchTools(server, config.mcpServers[server], FIRST_LOAD_TIMEOUT_MS);
|
|
110
|
+
const prev = cache.servers[server];
|
|
111
|
+
if (prev && !sameToolList(prev.tools, tools))
|
|
112
|
+
changed.push(server);
|
|
113
|
+
cache.servers[server] = { fetchedAt: new Date().toISOString(), serverName, tools };
|
|
114
|
+
dirty = true;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// 后台刷新失败不打扰:真正调用时会报清楚
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
120
|
+
if (dirty) {
|
|
121
|
+
try {
|
|
122
|
+
saveMcpToolCache(pruneCache(cache, names));
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
}
|
|
126
|
+
if (changed.length)
|
|
127
|
+
warn(`MCP 服务 ${changed.join("、")} 的工具列表有变化,重新进入会话后生效`);
|
|
128
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TSchema } from "typebox";
|
|
3
|
+
import { McpClient, type McpClientOptions } from "./client.js";
|
|
4
|
+
import type { McpServerConfig, McpToolInfo } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* 把 MCP 服务的工具挂成 pi 工具。名字统一 `mcp_<服务>_<工具>`,让模型一眼看出来源;
|
|
7
|
+
* 连接是惰性的:注册时只用缓存的工具清单,第一次调用才真正拉起服务。
|
|
8
|
+
* 结果上限放到内置搜索(3 万字)的三倍多——用户抱怨的正是"动不动就截断"。
|
|
9
|
+
*/
|
|
10
|
+
export declare const MCP_MAX_TEXT_CHARS = 100000;
|
|
11
|
+
export declare function mcpToolName(server: string, tool: string): string;
|
|
12
|
+
export declare function truncateMcpText(text: string, max?: number): string;
|
|
13
|
+
/** 模型 API 只认 object 顶层;$schema 之类元字段去掉,缺 properties 补空 */
|
|
14
|
+
export declare function toParameterSchema(inputSchema: Record<string, unknown>): TSchema;
|
|
15
|
+
export type McpToolSetOptions = McpClientOptions;
|
|
16
|
+
export interface McpToolSet {
|
|
17
|
+
server: string;
|
|
18
|
+
tools: ToolDefinition[];
|
|
19
|
+
client: McpClient;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare function createMcpToolSet(server: string, config: McpServerConfig, tools: McpToolInfo[], opts?: McpToolSetOptions): McpToolSet;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { compactResultRender } from "../tools.js";
|
|
3
|
+
import { McpClient } from "./client.js";
|
|
4
|
+
/**
|
|
5
|
+
* 把 MCP 服务的工具挂成 pi 工具。名字统一 `mcp_<服务>_<工具>`,让模型一眼看出来源;
|
|
6
|
+
* 连接是惰性的:注册时只用缓存的工具清单,第一次调用才真正拉起服务。
|
|
7
|
+
* 结果上限放到内置搜索(3 万字)的三倍多——用户抱怨的正是"动不动就截断"。
|
|
8
|
+
*/
|
|
9
|
+
export const MCP_MAX_TEXT_CHARS = 100_000;
|
|
10
|
+
const MODEL_TOOL_NAME_MAX = 64;
|
|
11
|
+
export function mcpToolName(server, tool) {
|
|
12
|
+
const clean = tool.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "") || "tool";
|
|
13
|
+
return `mcp_${server}_${clean}`.slice(0, MODEL_TOOL_NAME_MAX);
|
|
14
|
+
}
|
|
15
|
+
export function truncateMcpText(text, max = MCP_MAX_TEXT_CHARS) {
|
|
16
|
+
if (text.length <= max)
|
|
17
|
+
return text;
|
|
18
|
+
return `${text.slice(0, max)}\n\n…(MCP 工具返回内容过长已截断,共 ${text.length} 字符,保留前 ${max} 字符)`;
|
|
19
|
+
}
|
|
20
|
+
/** 模型 API 只认 object 顶层;$schema 之类元字段去掉,缺 properties 补空 */
|
|
21
|
+
export function toParameterSchema(inputSchema) {
|
|
22
|
+
const { $schema: _drop, ...rest } = inputSchema;
|
|
23
|
+
const schema = { ...rest, type: "object" };
|
|
24
|
+
if (!schema["properties"] || typeof schema["properties"] !== "object")
|
|
25
|
+
schema["properties"] = {};
|
|
26
|
+
return schema;
|
|
27
|
+
}
|
|
28
|
+
function previewArgs(params) {
|
|
29
|
+
let s;
|
|
30
|
+
try {
|
|
31
|
+
s = JSON.stringify(params) ?? "";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
s = "";
|
|
35
|
+
}
|
|
36
|
+
s = s.replace(/\s*\n\s*/g, " ");
|
|
37
|
+
return s.length > 100 ? `${s.slice(0, 99)}…` : s;
|
|
38
|
+
}
|
|
39
|
+
export function createMcpToolSet(server, config, tools, opts = {}) {
|
|
40
|
+
let client = new McpClient(server, config, opts);
|
|
41
|
+
const definitions = [];
|
|
42
|
+
async function call(tool, params, signal) {
|
|
43
|
+
try {
|
|
44
|
+
return await client.callTool(tool.name, params, signal);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
// 进程退出 / 连接断了:丢掉旧客户端,下次调用重新拉起
|
|
49
|
+
if (/已退出|已关闭|stdin/.test(message)) {
|
|
50
|
+
const old = client;
|
|
51
|
+
client = new McpClient(server, config, opts);
|
|
52
|
+
old.close().catch(() => { });
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`MCP 服务 ${server} 调用 ${tool.name} 失败:${message}`, { cause: err });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const tool of tools) {
|
|
58
|
+
const name = mcpToolName(server, tool.name);
|
|
59
|
+
const description = tool.description.trim() || `Tool "${tool.name}" from MCP server "${server}".`;
|
|
60
|
+
definitions.push({
|
|
61
|
+
name,
|
|
62
|
+
label: `MCP ${server}:${tool.name}`,
|
|
63
|
+
description: description.length > 2_000 ? `${description.slice(0, 2_000)}…` : description,
|
|
64
|
+
parameters: toParameterSchema(tool.inputSchema),
|
|
65
|
+
renderShell: "self",
|
|
66
|
+
renderCall() {
|
|
67
|
+
return new Text("", 0, 0);
|
|
68
|
+
},
|
|
69
|
+
renderResult(result, options, theme, context) {
|
|
70
|
+
const d = result.details;
|
|
71
|
+
const args = d?.args ? ` ${d.args}` : "";
|
|
72
|
+
const n = typeof d?.chars === "number" ? ` · ${d.chars} 字符${d.truncated ? "(已截断)" : ""}` : "";
|
|
73
|
+
return compactResultRender({ result, options, theme, context, summaryLine: `🔌 ${server}:${tool.name}${args}${n}` });
|
|
74
|
+
},
|
|
75
|
+
async execute(_toolCallId, params, signal) {
|
|
76
|
+
const args = (params && typeof params === "object" ? params : {});
|
|
77
|
+
const res = await call(tool, args, signal);
|
|
78
|
+
if (res.isError)
|
|
79
|
+
throw new Error(`MCP 服务 ${server} 的 ${tool.name} 返回错误:${res.text || "(无详情)"}`);
|
|
80
|
+
const text = res.text || "(empty result)";
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: "text", text: truncateMcpText(text) }],
|
|
83
|
+
details: { args: previewArgs(args), chars: text.length, truncated: text.length > MCP_MAX_TEXT_CHARS },
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
server,
|
|
90
|
+
tools: definitions,
|
|
91
|
+
get client() {
|
|
92
|
+
return client;
|
|
93
|
+
},
|
|
94
|
+
close: () => client.close(),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/web.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { cleanupBrandThemes, endpointKeyEnv, ensureAuthCredential, ensureBrandPrompt, ensureWorkflowPromptTemplate, ensureDefaultSettings, ensureProviderModels, scrubForeignProviderEnv, writeAttributionExtension, writeWebToolsExtension, } from "./agent-setup.js";
|
|
4
|
+
import { cleanupBrandThemes, endpointKeyEnv, ensureAuthCredential, ensureBrandPrompt, ensureWorkflowPromptTemplate, ensureDefaultSettings, ensureProviderModels, scrubForeignProviderEnv, writeAttributionExtension, writeMcpExtension, writeWebToolsExtension, } from "./agent-setup.js";
|
|
5
5
|
import { agentDir, apiModelToDef, MODELS, resolvePreferredModel, setModelsFromApi, u1s1Dir, writeAgentDefaultModel, } from "./config.js";
|
|
6
6
|
import { ensureSearchTools } from "./search-tools.js";
|
|
7
7
|
import { ensureUsableShell } from "./shell-doctor.js";
|
|
@@ -98,6 +98,8 @@ export async function prepareWebEnv(cfg) {
|
|
|
98
98
|
});
|
|
99
99
|
// OpenRouter 应用归因:直连流量计入 u1s1 的公开排行(与 TUI 同源)
|
|
100
100
|
writeAttributionExtension();
|
|
101
|
+
// 自定义 MCP 服务的工具(与 TUI 同源)
|
|
102
|
+
writeMcpExtension();
|
|
101
103
|
// Desktop App 新会话从 settings.json 的 defaultModel 取模型(TUI 是每次传 --model),
|
|
102
104
|
// 确保它有值;resolvePreferredModel 优先尊重已有的 in-session 选择,不会回退覆盖。
|
|
103
105
|
const pref = resolvePreferredModel(cfg);
|