@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/bin.js +7657 -0
- package/dist/bin.js.map +7 -0
- package/extensions/mcp-extension.js +295 -0
- package/gui/assets/index-BC6qaWvB.js +26 -0
- package/gui/assets/index-CBwrpcqx.js +60 -0
- package/gui/assets/vendor-BtP0CW_r.js +32 -0
- package/gui/index.html +40 -0
- package/package.json +33 -0
|
@@ -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
|