@versot/vaguspi 0.1.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,295 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Type } from "typebox";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ // ── Shared connection registry (module level) ───────────────────────────
8
+ const sharedClients = new Map();
9
+ const sharedToolCache = new Map();
10
+ const connecting = new Map();
11
+ /** Connection key: name, plus cwd when the server carries one (project scope). */
12
+ function serverKey(server) {
13
+ return server.config.cwd ? `${server.name}@${server.config.cwd}` : server.name;
14
+ }
15
+ /** Names known to provide MCP tool registration in a pi session. */
16
+ const KNOWN_MCP_ADAPTER_HINTS = ["mcp-adapter", "pi-mcp", "mcp_server"];
17
+ /** Read { mcpServers: {...} } from a JSON file (missing/corrupt → {}). */
18
+ function readMcpFile(path) {
19
+ try {
20
+ const raw = JSON.parse(readFileSync(path, "utf8"));
21
+ const servers = raw.mcpServers;
22
+ return typeof servers === "object" && servers !== null
23
+ ? servers
24
+ : {};
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /** Merge user + project MCP configs (project wins on name clashes). */
31
+ function loadServers(agentDir, cwd) {
32
+ const user = readMcpFile(join(agentDir, "mcp.json"));
33
+ const project = readMcpFile(join(cwd, ".mcp.json"));
34
+ // Normalize: accept BOTH the flat `{ name: config }` shape AND the
35
+ // Claude-style `{ "mcpServers": { name: config } }` wrapper. Other tools
36
+ // (Claude Desktop, Cursor, …) write the wrapped form, so be lenient.
37
+ const normalize = (raw) => {
38
+ if (raw && typeof raw === "object" && "mcpServers" in raw) {
39
+ const wrapped = raw.mcpServers;
40
+ if (wrapped && typeof wrapped === "object" && !Array.isArray(wrapped)) {
41
+ return wrapped;
42
+ }
43
+ }
44
+ return raw;
45
+ };
46
+ const merged = { ...normalize(user), ...normalize(project) };
47
+ return Object.entries(merged)
48
+ .map(([name, cfg]) => ({
49
+ name,
50
+ config: (typeof cfg === "object" && cfg !== null ? cfg : {}),
51
+ }))
52
+ // Honor per-server `enabled: false` — those servers never connect, so
53
+ // they cost zero context (the "unused MCP doesn't consume context" property).
54
+ .filter((s) => s.config.enabled !== false);
55
+ }
56
+ /** Best-effort check for another extension that already provides MCP tools. */
57
+ function hasExistingMcpAdapter(pi) {
58
+ try {
59
+ const active = pi.getActiveTools();
60
+ return active.some((tool) => KNOWN_MCP_ADAPTER_HINTS.some((hint) => tool.toLowerCase().includes(hint)));
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /** Convert an MCP JSON Schema to a TypeBox schema. Falls back to Type.Any(). */
67
+ function toTypeBoxSchema(inputSchema) {
68
+ if (!inputSchema || typeof inputSchema !== "object")
69
+ return Type.Any();
70
+ const schema = inputSchema;
71
+ const type = schema.type;
72
+ if (type === "object" && typeof schema.properties === "object" && schema.properties !== null) {
73
+ const props = {};
74
+ for (const [key, val] of Object.entries(schema.properties)) {
75
+ props[key] = toTypeBoxSchema(val);
76
+ }
77
+ return Type.Object(props, { additionalProperties: true });
78
+ }
79
+ if (type === "string")
80
+ return Type.String();
81
+ if (type === "number" || type === "integer")
82
+ return Type.Number();
83
+ if (type === "boolean")
84
+ return Type.Boolean();
85
+ if (type === "array")
86
+ return Type.Array(toTypeBoxSchema(schema.items));
87
+ if (type === "null")
88
+ return Type.Null();
89
+ // Unknown / complex schemas: pass through verbatim so the LLM still sees
90
+ // the real structure.
91
+ return Type.Unsafe(inputSchema);
92
+ }
93
+ /** Serialize an MCP tool result into pi's AgentToolResult shape. */
94
+ function toPiToolResult(result) {
95
+ const content = [];
96
+ if (Array.isArray(result.content)) {
97
+ for (const block of result.content) {
98
+ const b = block;
99
+ if (b?.type === "text" && typeof b.text === "string") {
100
+ content.push({ type: "text", text: b.text });
101
+ }
102
+ else if (b?.type === "image") {
103
+ content.push({ type: "text", text: "[image]" });
104
+ }
105
+ else if (b) {
106
+ content.push({ type: "text", text: JSON.stringify(b) });
107
+ }
108
+ }
109
+ }
110
+ if (content.length === 0)
111
+ content.push({ type: "text", text: "(no content)" });
112
+ return { content, details: {}, isError: result.isError === true };
113
+ }
114
+ export default function (pi) {
115
+ // ── LOAD-TIME SAFE (pure computation + registrations only) ──────────
116
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
117
+ /** Tool names already registered into THIS pi instance (session_start can
118
+ * fire multiple times per instance: startup/new/resume/fork/reload). */
119
+ const registeredTools = new Set();
120
+ /** Get the shared client for a server, connecting once per process. */
121
+ async function ensureClient(server, cwd) {
122
+ const key = serverKey(server);
123
+ if (sharedClients.has(key))
124
+ return sharedClients.get(key);
125
+ if (connecting.has(key)) {
126
+ await connecting.get(key);
127
+ return sharedClients.get(key);
128
+ }
129
+ const cfg = server.config;
130
+ const promise = (async () => {
131
+ const client = new Client({ name: `pi-web-mcp:${server.name}`, version: "0.1.0" });
132
+ const transportType = cfg.type ?? (cfg.url ? "http" : "stdio");
133
+ if (transportType === "http" && cfg.url) {
134
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
135
+ const transport = new StreamableHTTPClientTransport(new URL(cfg.url));
136
+ await client.connect(transport);
137
+ }
138
+ else {
139
+ if (!cfg.command)
140
+ throw new Error("missing command");
141
+ // A session's cwd may point at a deleted directory (old session on a
142
+ // removed project). spawn with a nonexistent cwd fails → cross-spawn
143
+ // reports a fake ENOENT. Fall back to the daemon's own cwd.
144
+ const spawnCwd = cfg.cwd ?? cwd;
145
+ const transport = new StdioClientTransport({
146
+ command: cfg.command,
147
+ args: cfg.args ?? [],
148
+ env: cfg.env ?? {},
149
+ cwd: existsSync(spawnCwd) ? spawnCwd : process.cwd(),
150
+ });
151
+ await client.connect(transport);
152
+ }
153
+ // Cache the tool list once per process.
154
+ const listed = await client.listTools();
155
+ sharedToolCache.set(key, listed.tools);
156
+ sharedClients.set(key, client);
157
+ })();
158
+ connecting.set(key, promise);
159
+ try {
160
+ await promise;
161
+ return sharedClients.get(key);
162
+ }
163
+ catch (err) {
164
+ console.warn(`[mcp] failed to connect "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
165
+ return undefined;
166
+ }
167
+ finally {
168
+ connecting.delete(key);
169
+ }
170
+ }
171
+ /** Register a server's tools into THIS pi instance (idempotent). */
172
+ async function registerServerTools(server, cwd) {
173
+ const key = serverKey(server);
174
+ const client = await ensureClient(server, cwd);
175
+ if (!client)
176
+ return;
177
+ const tools = sharedToolCache.get(key) ?? [];
178
+ for (const tool of tools) {
179
+ const toolName = tool.name;
180
+ const fullName = toolName.includes(".") ? toolName : `${server.name}_${toolName}`;
181
+ if (registeredTools.has(fullName))
182
+ continue;
183
+ try {
184
+ pi.registerTool({
185
+ name: fullName,
186
+ label: tool.description ?? toolName,
187
+ description: tool.description ?? `MCP tool from server "${server.name}"`,
188
+ parameters: toTypeBoxSchema(tool.inputSchema),
189
+ async execute(_toolCallId, params) {
190
+ try {
191
+ // Look up the CURRENT shared client at call time (survives reconnect).
192
+ const current = sharedClients.get(key);
193
+ if (!current) {
194
+ return { content: [{ type: "text", text: "MCP server not connected" }], details: {}, isError: true };
195
+ }
196
+ const result = await current.callTool({
197
+ name: toolName,
198
+ arguments: params,
199
+ });
200
+ return toPiToolResult(result);
201
+ }
202
+ catch (err) {
203
+ return {
204
+ content: [{ type: "text", text: `MCP tool error: ${err instanceof Error ? err.message : String(err)}` }],
205
+ details: {},
206
+ isError: true,
207
+ };
208
+ }
209
+ },
210
+ });
211
+ registeredTools.add(fullName);
212
+ }
213
+ catch (err) {
214
+ console.warn(`[mcp] failed to register tool "${fullName}": ${err instanceof Error ? err.message : String(err)}`);
215
+ }
216
+ }
217
+ }
218
+ /** Connect + register all enabled servers (best-effort, never blocks). */
219
+ async function connectAll(cwd) {
220
+ try {
221
+ const servers = loadServers(agentDir, cwd);
222
+ if (servers.length === 0)
223
+ return;
224
+ for (const server of servers) {
225
+ await registerServerTools(server, cwd);
226
+ }
227
+ }
228
+ catch (err) {
229
+ console.warn(`[mcp] connectAll failed: ${err instanceof Error ? err.message : String(err)}`);
230
+ }
231
+ }
232
+ /** Close all shared connections + clear caches (used by /mcp reconnect). */
233
+ async function reconnectAll(cwd) {
234
+ for (const [key, client] of sharedClients) {
235
+ try {
236
+ await client.close();
237
+ }
238
+ catch {
239
+ // best-effort
240
+ }
241
+ sharedClients.delete(key);
242
+ sharedToolCache.delete(key);
243
+ }
244
+ registeredTools.clear();
245
+ await connectAll(cwd);
246
+ }
247
+ pi.on("session_start", async (event, ctx) => {
248
+ try {
249
+ // Always connect + register: session_start fires on reload too, and
250
+ // the runner is fresh (tools cleared). registeredTools (module-level)
251
+ // must be cleared so every tool re-registers into the new runner.
252
+ // hasExistingMcpAdapter is intentionally skipped — the built-in MCP
253
+ // extension is the primary adapter; if another adapter also provides
254
+ // MCP tools, they coexist without deconfliction.
255
+ const cwd = ctx.cwd ?? process.cwd();
256
+ registeredTools.clear();
257
+ void connectAll(cwd).catch(() => { });
258
+ }
259
+ catch {
260
+ // MCP must never break a session.
261
+ }
262
+ });
263
+ pi.on("session_shutdown", () => {
264
+ // Shared connections live for the daemon process lifetime — a session
265
+ // ending must NOT close connections other parallel sessions still use.
266
+ // The OS reclaims MCP child processes when the daemon exits.
267
+ });
268
+ // ── TOP-LEVEL command registration (allowed at load time — registration
269
+ // only, not an action-method call).
270
+ pi.registerCommand("mcp", {
271
+ description: "List configured MCP servers and their connection status",
272
+ handler: async (args, ctx) => {
273
+ try {
274
+ const cwd = ctx.cwd ?? process.cwd();
275
+ const servers = loadServers(agentDir, cwd);
276
+ if (servers.length === 0) {
277
+ ctx.ui.notify("MCP: no servers configured (add them in the web GUI → 设置 → MCP 服务器)", "info");
278
+ return;
279
+ }
280
+ const lines = servers.map((s) => {
281
+ const state = sharedClients.has(serverKey(s)) ? "connected" : "not connected";
282
+ return `${s.name}: ${state}`;
283
+ });
284
+ ctx.ui.notify(`MCP servers:\n${lines.join("\n")}`, "info");
285
+ if (args.trim() === "reconnect") {
286
+ await reconnectAll(cwd);
287
+ }
288
+ }
289
+ catch {
290
+ // non-fatal
291
+ }
292
+ },
293
+ });
294
+ }
295
+ //# sourceMappingURL=index.js.map