@rahularya01/pi-essentials 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 +324 -0
- package/examples/mcp.json +30 -0
- package/examples/pi-essentials.json +32 -0
- package/examples/pi-settings.json +5 -0
- package/package.json +88 -0
- package/skills/pi-essentials/SKILL.md +50 -0
- package/src/config.ts +351 -0
- package/src/errors.ts +96 -0
- package/src/index.ts +43 -0
- package/src/mcp/commands.ts +390 -0
- package/src/mcp/config.ts +157 -0
- package/src/mcp/credential-store.ts +153 -0
- package/src/mcp/index.ts +67 -0
- package/src/mcp/manager.ts +941 -0
- package/src/mcp/oauth.ts +262 -0
- package/src/mcp/proxy-tool.ts +213 -0
- package/src/mcp/render.ts +164 -0
- package/src/mcp/types.ts +63 -0
- package/src/paths.ts +48 -0
- package/src/questions/ask.ts +134 -0
- package/src/questions/index.ts +72 -0
- package/src/questions/render.ts +69 -0
- package/src/questions/validate.ts +85 -0
- package/src/security/env.ts +132 -0
- package/src/security/limits.ts +20 -0
- package/src/security/ssrf.ts +237 -0
- package/src/subagents/activity.ts +132 -0
- package/src/subagents/builtins/oracle.md +11 -0
- package/src/subagents/builtins/reviewer.md +11 -0
- package/src/subagents/builtins/scout.md +12 -0
- package/src/subagents/builtins/worker.md +11 -0
- package/src/subagents/discover.ts +54 -0
- package/src/subagents/herdr.ts +150 -0
- package/src/subagents/index.ts +642 -0
- package/src/subagents/inspector-tail.d.mts +1 -0
- package/src/subagents/inspector-tail.mjs +140 -0
- package/src/subagents/render.ts +464 -0
- package/src/subagents/runner.ts +468 -0
- package/src/subagents/schema.ts +107 -0
- package/src/subagents/types.ts +131 -0
- package/src/subagents/worktree.ts +131 -0
- package/src/todos/index.ts +170 -0
- package/src/todos/render.ts +198 -0
- package/src/todos/state.ts +310 -0
- package/src/ui/render.ts +215 -0
- package/src/web/activity.ts +91 -0
- package/src/web/cache.ts +153 -0
- package/src/web/extract.ts +75 -0
- package/src/web/fetch.ts +167 -0
- package/src/web/html-to-markdown.ts +284 -0
- package/src/web/http.ts +238 -0
- package/src/web/index.ts +214 -0
- package/src/web/providers/brave.ts +27 -0
- package/src/web/providers/duckduckgo.ts +60 -0
- package/src/web/providers/exa.ts +29 -0
- package/src/web/providers/jina.ts +25 -0
- package/src/web/providers/searxng.ts +29 -0
- package/src/web/providers/tavily.ts +31 -0
- package/src/web/providers/types.ts +75 -0
- package/src/web/render.ts +130 -0
- package/src/web/search.ts +108 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { errorMessage } from "../errors.ts";
|
|
3
|
+
import type { McpManager } from "./manager.ts";
|
|
4
|
+
|
|
5
|
+
export interface AutocompleteItem {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MCP_SUBCOMMANDS: Array<{ name: string; description: string }> = [
|
|
12
|
+
{ name: "status", description: "Show MCP server status table" },
|
|
13
|
+
{ name: "tools", description: "List all discovered tools across servers" },
|
|
14
|
+
{ name: "enable", description: "Enable a disabled MCP server" },
|
|
15
|
+
{ name: "disable", description: "Disable an MCP server" },
|
|
16
|
+
{ name: "reconnect", description: "Reconnect a specific server or all servers" },
|
|
17
|
+
{ name: "auth", description: "Authenticate with an OAuth MCP server" },
|
|
18
|
+
{ name: "auth-start", description: "Start background OAuth flow" },
|
|
19
|
+
{ name: "auth-complete", description: "Complete OAuth flow with redirect URL or code" },
|
|
20
|
+
{ name: "logout", description: "Clear stored OAuth credentials for a server" },
|
|
21
|
+
{ name: "disconnect", description: "Disconnect an active server or all servers" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function getMcpArgumentCompletions(
|
|
25
|
+
manager: McpManager,
|
|
26
|
+
argumentPrefix: string,
|
|
27
|
+
): AutocompleteItem[] | null {
|
|
28
|
+
const trimmedLeading = argumentPrefix.trimStart();
|
|
29
|
+
const spaceIndex = trimmedLeading.indexOf(" ");
|
|
30
|
+
|
|
31
|
+
if (spaceIndex === -1) {
|
|
32
|
+
const q = trimmedLeading.toLowerCase();
|
|
33
|
+
const matches = MCP_SUBCOMMANDS.filter((sub) => sub.name.toLowerCase().startsWith(q));
|
|
34
|
+
if (matches.length === 0) return null;
|
|
35
|
+
return matches.map((sub) => ({
|
|
36
|
+
value: sub.name,
|
|
37
|
+
label: sub.name,
|
|
38
|
+
description: sub.description,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sub = trimmedLeading.slice(0, spaceIndex).toLowerCase();
|
|
43
|
+
const serverPrefix = trimmedLeading.slice(spaceIndex + 1).trimStart().toLowerCase();
|
|
44
|
+
|
|
45
|
+
let servers: Array<{ name: string; info: string }> = [];
|
|
46
|
+
|
|
47
|
+
if (sub === "enable") {
|
|
48
|
+
servers = manager
|
|
49
|
+
.listServers()
|
|
50
|
+
.filter((s) => s.disabled)
|
|
51
|
+
.map((s) => ({ name: s.name, info: `${s.transport} (disabled)` }));
|
|
52
|
+
} else if (sub === "disable") {
|
|
53
|
+
servers = manager
|
|
54
|
+
.listServers()
|
|
55
|
+
.filter((s) => !s.disabled)
|
|
56
|
+
.map((s) => ({ name: s.name, info: `${s.status} (${s.transport})` }));
|
|
57
|
+
} else if (sub === "auth" || sub === "auth-start" || sub === "logout") {
|
|
58
|
+
servers = manager.getOAuthServers().map((s) => ({ name: s.name, info: `status: ${s.status}` }));
|
|
59
|
+
} else if (sub === "reconnect") {
|
|
60
|
+
servers = manager
|
|
61
|
+
.listServers()
|
|
62
|
+
.filter((s) => !s.disabled)
|
|
63
|
+
.map((s) => ({ name: s.name, info: `status: ${s.status}` }));
|
|
64
|
+
} else if (sub === "disconnect") {
|
|
65
|
+
servers = manager
|
|
66
|
+
.listServers()
|
|
67
|
+
.filter((s) => s.status === "connected")
|
|
68
|
+
.map((s) => ({ name: s.name, info: "connected" }));
|
|
69
|
+
} else {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const matches = servers.filter((s) => s.name.toLowerCase().startsWith(serverPrefix));
|
|
74
|
+
if (matches.length === 0) return null;
|
|
75
|
+
|
|
76
|
+
return matches.map((s) => ({
|
|
77
|
+
value: `${sub} ${s.name}`,
|
|
78
|
+
label: s.name,
|
|
79
|
+
description: s.info,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const USAGE =
|
|
84
|
+
"Usage: /mcp [status|tools|enable [name]|disable [name]|reconnect [name]|auth <name> [redirectUrl]|auth-start <name>|auth-complete <name> <redirectUrl>|logout <name>|disconnect [name]]";
|
|
85
|
+
|
|
86
|
+
export function registerMcpCommands(
|
|
87
|
+
pi: ExtensionAPI,
|
|
88
|
+
manager: McpManager,
|
|
89
|
+
onStatusChange?: (ctx: ExtensionContext) => void,
|
|
90
|
+
): void {
|
|
91
|
+
pi.registerCommand("mcp", {
|
|
92
|
+
description:
|
|
93
|
+
"Show MCP server status, manage servers, or authenticate OAuth. Usage: /mcp [enable|disable|auth|reconnect]",
|
|
94
|
+
getArgumentCompletions: (argumentPrefix: string) => getMcpArgumentCompletions(manager, argumentPrefix),
|
|
95
|
+
handler: async (args, ctx) => {
|
|
96
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
97
|
+
const sub = parts[0]?.toLowerCase();
|
|
98
|
+
try {
|
|
99
|
+
switch (sub) {
|
|
100
|
+
case undefined: {
|
|
101
|
+
if (ctx.hasUI) {
|
|
102
|
+
const actions = [
|
|
103
|
+
"📊 Status — View MCP server status table",
|
|
104
|
+
"🔧 Tools — List all discovered tools",
|
|
105
|
+
"⚡ Reconnect — Reconnect MCP servers",
|
|
106
|
+
"✅ Enable — Enable an MCP server",
|
|
107
|
+
"⛔ Disable — Disable an MCP server",
|
|
108
|
+
"🔑 Auth — Authenticate with an OAuth server",
|
|
109
|
+
"🚪 Disconnect — Disconnect MCP servers",
|
|
110
|
+
];
|
|
111
|
+
const choice = await ctx.ui.select("MCP Actions", actions);
|
|
112
|
+
if (!choice) return;
|
|
113
|
+
if (choice.startsWith("📊 Status")) {
|
|
114
|
+
ctx.ui.notify(manager.formatStatus(), "info");
|
|
115
|
+
} else if (choice.startsWith("🔧 Tools")) {
|
|
116
|
+
ctx.ui.notify("Connecting to MCP servers…", "info");
|
|
117
|
+
await manager.ensureAllTools();
|
|
118
|
+
const tools = manager.allCachedTools();
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
tools.length === 0
|
|
121
|
+
? "No MCP tools available. Check /mcp status."
|
|
122
|
+
: tools.map((t) => `${t.prefixedName} — ${t.description || "(no description)"}`).join("\n"),
|
|
123
|
+
"info",
|
|
124
|
+
);
|
|
125
|
+
} else if (choice.startsWith("⚡ Reconnect")) {
|
|
126
|
+
await manager.refreshAll();
|
|
127
|
+
ctx.ui.notify(`Reconnected MCP servers\n\n${manager.formatStatus()}`, "info");
|
|
128
|
+
} else if (choice.startsWith("✅ Enable")) {
|
|
129
|
+
const disabled = manager.listServers().filter((s) => s.disabled);
|
|
130
|
+
if (disabled.length === 0) {
|
|
131
|
+
ctx.ui.notify("No disabled MCP servers found.", "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const picked = await ctx.ui.select(
|
|
135
|
+
"Select MCP server to enable",
|
|
136
|
+
disabled.map((s) => `${s.name} (${s.transport})`),
|
|
137
|
+
);
|
|
138
|
+
if (!picked) return;
|
|
139
|
+
const name = picked.split(" ")[0];
|
|
140
|
+
await manager.setServerDisabled(name, false);
|
|
141
|
+
ctx.ui.notify(`Enabled MCP server "${name}".`, "info");
|
|
142
|
+
} else if (choice.startsWith("⛔ Disable")) {
|
|
143
|
+
const enabled = manager.listServers().filter((s) => !s.disabled);
|
|
144
|
+
if (enabled.length === 0) {
|
|
145
|
+
ctx.ui.notify("No enabled MCP servers found.", "info");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const picked = await ctx.ui.select(
|
|
149
|
+
"Select MCP server to disable",
|
|
150
|
+
enabled.map((s) => `${s.name} (${s.status}, ${s.transport})`),
|
|
151
|
+
);
|
|
152
|
+
if (!picked) return;
|
|
153
|
+
const name = picked.split(" ")[0];
|
|
154
|
+
await manager.setServerDisabled(name, true);
|
|
155
|
+
ctx.ui.notify(`Disabled MCP server "${name}".`, "info");
|
|
156
|
+
} else if (choice.startsWith("🔑 Auth")) {
|
|
157
|
+
const oauthServers = manager.getOAuthServers();
|
|
158
|
+
if (oauthServers.length === 0) {
|
|
159
|
+
ctx.ui.notify("No OAuth-capable MCP servers configured.", "warning");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const picked = await ctx.ui.select(
|
|
163
|
+
"Select MCP server to authenticate",
|
|
164
|
+
oauthServers.map((s) => `${s.name} (${s.status})`),
|
|
165
|
+
);
|
|
166
|
+
if (!picked) return;
|
|
167
|
+
const name = picked.split(" ")[0];
|
|
168
|
+
ctx.ui.notify(await manager.auth(name), "info");
|
|
169
|
+
} else if (choice.startsWith("🚪 Disconnect")) {
|
|
170
|
+
await manager.disconnect();
|
|
171
|
+
ctx.ui.notify("Disconnected all MCP servers", "info");
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
ctx.ui.notify(manager.formatStatus(), "info");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case "status":
|
|
180
|
+
ctx.ui.notify(manager.formatStatus(), "info");
|
|
181
|
+
return;
|
|
182
|
+
|
|
183
|
+
case "tools": {
|
|
184
|
+
ctx.ui.notify("Connecting to MCP servers…", "info");
|
|
185
|
+
await manager.ensureAllTools();
|
|
186
|
+
const tools = manager.allCachedTools();
|
|
187
|
+
ctx.ui.notify(
|
|
188
|
+
tools.length === 0
|
|
189
|
+
? "No MCP tools available. Check /mcp status."
|
|
190
|
+
: tools.map((t) => `${t.prefixedName} — ${t.description || "(no description)"}`).join("\n"),
|
|
191
|
+
"info",
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case "enable": {
|
|
197
|
+
let name = parts[1];
|
|
198
|
+
if (!name) {
|
|
199
|
+
if (ctx.hasUI) {
|
|
200
|
+
const disabled = manager.listServers().filter((s) => s.disabled);
|
|
201
|
+
if (disabled.length === 0) {
|
|
202
|
+
ctx.ui.notify("No disabled MCP servers found.", "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const choices = disabled.map((s) => `${s.name} (${s.transport})`);
|
|
206
|
+
const picked = await ctx.ui.select("Select MCP server to enable", choices);
|
|
207
|
+
if (!picked) return;
|
|
208
|
+
name = picked.split(" ")[0];
|
|
209
|
+
} else {
|
|
210
|
+
ctx.ui.notify("Usage: /mcp enable <server>", "warning");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
await manager.setServerDisabled(name, false);
|
|
215
|
+
ctx.ui.notify(`Enabled MCP server "${name}".`, "info");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case "disable": {
|
|
220
|
+
let name = parts[1];
|
|
221
|
+
if (!name) {
|
|
222
|
+
if (ctx.hasUI) {
|
|
223
|
+
const enabled = manager.listServers().filter((s) => !s.disabled);
|
|
224
|
+
if (enabled.length === 0) {
|
|
225
|
+
ctx.ui.notify("No enabled MCP servers found.", "info");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const choices = enabled.map((s) => `${s.name} (${s.status}, ${s.transport})`);
|
|
229
|
+
const picked = await ctx.ui.select("Select MCP server to disable", choices);
|
|
230
|
+
if (!picked) return;
|
|
231
|
+
name = picked.split(" ")[0];
|
|
232
|
+
} else {
|
|
233
|
+
ctx.ui.notify("Usage: /mcp disable <server>", "warning");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
await manager.setServerDisabled(name, true);
|
|
238
|
+
ctx.ui.notify(`Disabled MCP server "${name}".`, "info");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case "reconnect": {
|
|
243
|
+
const name = parts[1];
|
|
244
|
+
if (name) {
|
|
245
|
+
await manager.connect(name, undefined, true);
|
|
246
|
+
ctx.ui.notify(`Reconnected ${name}`, "info");
|
|
247
|
+
} else {
|
|
248
|
+
await manager.refreshAll();
|
|
249
|
+
ctx.ui.notify(`Reconnected MCP servers\n\n${manager.formatStatus()}`, "info");
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
case "auth": {
|
|
255
|
+
let name = parts[1];
|
|
256
|
+
if (!name) {
|
|
257
|
+
if (ctx.hasUI) {
|
|
258
|
+
const oauthServers = manager.getOAuthServers();
|
|
259
|
+
if (oauthServers.length === 0) {
|
|
260
|
+
ctx.ui.notify("No OAuth-capable MCP servers configured.", "warning");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const choices = oauthServers.map((s) => `${s.name} (${s.status})`);
|
|
264
|
+
const picked = await ctx.ui.select("Select MCP server to authenticate", choices);
|
|
265
|
+
if (!picked) return;
|
|
266
|
+
name = picked.split(" ")[0];
|
|
267
|
+
} else {
|
|
268
|
+
ctx.ui.notify("Usage: /mcp auth <server> [redirectUrl]", "warning");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
ctx.ui.notify(await manager.auth(name, parts[2]), "info");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case "auth-start": {
|
|
277
|
+
let name = parts[1];
|
|
278
|
+
if (!name) {
|
|
279
|
+
if (ctx.hasUI) {
|
|
280
|
+
const oauthServers = manager.getOAuthServers();
|
|
281
|
+
if (oauthServers.length === 0) {
|
|
282
|
+
ctx.ui.notify("No OAuth-capable MCP servers configured.", "warning");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const choices = oauthServers.map((s) => `${s.name} (${s.status})`);
|
|
286
|
+
const picked = await ctx.ui.select("Select MCP server for OAuth start", choices);
|
|
287
|
+
if (!picked) return;
|
|
288
|
+
name = picked.split(" ")[0];
|
|
289
|
+
} else {
|
|
290
|
+
ctx.ui.notify("Usage: /mcp auth-start <server>", "warning");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const { message } = await manager.authStart(name);
|
|
295
|
+
ctx.ui.notify(message, "info");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case "auth-complete": {
|
|
300
|
+
const name = parts[1];
|
|
301
|
+
const redirectUrl = parts[2];
|
|
302
|
+
if (!name || !redirectUrl) {
|
|
303
|
+
ctx.ui.notify("Usage: /mcp auth-complete <server> <redirectUrl>", "warning");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
ctx.ui.notify(await manager.authComplete(name, { redirectUrl }), "info");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case "logout": {
|
|
311
|
+
let name = parts[1];
|
|
312
|
+
if (!name) {
|
|
313
|
+
if (ctx.hasUI) {
|
|
314
|
+
const oauthServers = manager.getOAuthServers();
|
|
315
|
+
if (oauthServers.length === 0) {
|
|
316
|
+
ctx.ui.notify("No OAuth-capable MCP servers configured.", "warning");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const choices = oauthServers.map((s) => `${s.name} (${s.status})`);
|
|
320
|
+
const picked = await ctx.ui.select("Select MCP server to logout", choices);
|
|
321
|
+
if (!picked) return;
|
|
322
|
+
name = picked.split(" ")[0];
|
|
323
|
+
} else {
|
|
324
|
+
ctx.ui.notify("Usage: /mcp logout <server>", "warning");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
ctx.ui.notify(await manager.logout(name), "info");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case "disconnect":
|
|
333
|
+
await manager.disconnect(parts[1]);
|
|
334
|
+
ctx.ui.notify(parts[1] ? `Disconnected ${parts[1]}` : "Disconnected all MCP servers", "info");
|
|
335
|
+
return;
|
|
336
|
+
|
|
337
|
+
default:
|
|
338
|
+
ctx.ui.notify(USAGE, "warning");
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
342
|
+
} finally {
|
|
343
|
+
onStatusChange?.(ctx);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
pi.registerCommand("mcp-auth", {
|
|
349
|
+
description: "Authenticate with an OAuth MCP server. Usage: /mcp-auth [name]",
|
|
350
|
+
getArgumentCompletions: (argumentPrefix: string) => {
|
|
351
|
+
const q = argumentPrefix.trim().toLowerCase();
|
|
352
|
+
const servers = manager.getOAuthServers();
|
|
353
|
+
const matches = servers.filter((s) => s.name.toLowerCase().startsWith(q));
|
|
354
|
+
if (matches.length === 0) return null;
|
|
355
|
+
return matches.map((s) => ({
|
|
356
|
+
value: s.name,
|
|
357
|
+
label: s.name,
|
|
358
|
+
description: `status: ${s.status}`,
|
|
359
|
+
}));
|
|
360
|
+
},
|
|
361
|
+
handler: async (args, ctx) => {
|
|
362
|
+
const name = args.trim().split(/\s+/)[0];
|
|
363
|
+
try {
|
|
364
|
+
if (!name) {
|
|
365
|
+
if (ctx.hasUI) {
|
|
366
|
+
const oauthServers = manager.getOAuthServers();
|
|
367
|
+
if (oauthServers.length === 0) {
|
|
368
|
+
ctx.ui.notify("No OAuth-capable MCP servers configured.", "warning");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const choices = oauthServers.map((s) => `${s.name} (${s.status})`);
|
|
372
|
+
const picked = await ctx.ui.select("Select MCP server to authenticate", choices);
|
|
373
|
+
if (!picked) return;
|
|
374
|
+
const chosen = picked.split(" ")[0];
|
|
375
|
+
ctx.ui.notify(await manager.auth(chosen), "info");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
ctx.ui.notify("Usage: /mcp-auth <server>", "warning");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
ctx.ui.notify(await manager.auth(name), "info");
|
|
382
|
+
} catch (error) {
|
|
383
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
384
|
+
} finally {
|
|
385
|
+
onStatusChange?.(ctx);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { McpFileShape, McpServerDefinition, ResolvedServer } from "./types.ts";
|
|
4
|
+
import { mcpConfigCandidates } from "../paths.ts";
|
|
5
|
+
|
|
6
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function positiveNumber(value: unknown): number | undefined {
|
|
11
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stringArray(value: unknown): value is string[] {
|
|
15
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stringRecord(value: unknown): value is Record<string, string> {
|
|
19
|
+
return isObject(value) && Object.values(value).every((item) => typeof item === "string");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function optionalString(value: unknown): value is string | undefined {
|
|
23
|
+
return value === undefined || typeof value === "string";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asServer(value: unknown, isOverride = false): { definition?: McpServerDefinition; problem?: string } {
|
|
27
|
+
if (!isObject(value)) return { problem: "entry is not an object" };
|
|
28
|
+
const command = typeof value.command === "string" && value.command.trim() ? value.command : undefined;
|
|
29
|
+
const url = typeof value.url === "string" && value.url.trim() ? value.url : undefined;
|
|
30
|
+
if (!command && !url && !isOverride) return { problem: 'entry needs either "command" (stdio) or "url" (http)' };
|
|
31
|
+
if (command && url) return { problem: 'entry sets both "command" and "url"; use one transport' };
|
|
32
|
+
if (url) {
|
|
33
|
+
try {
|
|
34
|
+
if (!["http:", "https:"].includes(new URL(url).protocol)) return { problem: '"url" must use http or https' };
|
|
35
|
+
} catch {
|
|
36
|
+
return { problem: '"url" must be a valid http(s) URL' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const key of ["args", "includeTools", "excludeTools"] as const) {
|
|
40
|
+
if (value[key] !== undefined && !stringArray(value[key])) return { problem: `"${key}" must be an array of strings` };
|
|
41
|
+
}
|
|
42
|
+
for (const key of ["env", "headers"] as const) {
|
|
43
|
+
if (value[key] !== undefined && !stringRecord(value[key])) return { problem: `"${key}" must map strings to strings` };
|
|
44
|
+
}
|
|
45
|
+
for (const key of ["cwd", "bearerToken", "bearerTokenEnv"] as const) {
|
|
46
|
+
if (!optionalString(value[key])) return { problem: `"${key}" must be a string` };
|
|
47
|
+
}
|
|
48
|
+
if (value.disabled !== undefined && typeof value.disabled !== "boolean") return { problem: '"disabled" must be a boolean' };
|
|
49
|
+
if (value.lifecycle !== undefined && !["lazy", "eager", "keep-alive"].includes(String(value.lifecycle))) {
|
|
50
|
+
return { problem: '"lifecycle" must be lazy, eager, or keep-alive' };
|
|
51
|
+
}
|
|
52
|
+
if (value.auth !== undefined && !["bearer", "oauth"].includes(String(value.auth))) {
|
|
53
|
+
return { problem: '"auth" must be bearer or oauth' };
|
|
54
|
+
}
|
|
55
|
+
if (value.toolPrefix !== undefined && !["server", "none"].includes(String(value.toolPrefix))) {
|
|
56
|
+
return { problem: '"toolPrefix" must be server or none' };
|
|
57
|
+
}
|
|
58
|
+
for (const key of ["idleTimeout", "requestTimeoutMs"] as const) {
|
|
59
|
+
if (value[key] !== undefined && positiveNumber(value[key]) === undefined) {
|
|
60
|
+
return { problem: `"${key}" must be a positive finite number` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (value.oauth !== undefined) {
|
|
64
|
+
if (!isObject(value.oauth)) return { problem: '"oauth" must be an object' };
|
|
65
|
+
for (const key of ["clientId", "clientSecret", "scope", "redirectUri"] as const) {
|
|
66
|
+
if (!optionalString(value.oauth[key])) return { problem: `"oauth.${key}" must be a string` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { definition: value as McpServerDefinition };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readFile(filePath: string, warnings: string[]): McpFileShape {
|
|
73
|
+
if (!fs.existsSync(filePath)) return {};
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
|
76
|
+
if (!isObject(parsed)) {
|
|
77
|
+
warnings.push(`Ignoring MCP config at ${filePath}: expected a JSON object.`);
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
if (parsed.mcpServers !== undefined && !isObject(parsed.mcpServers)) {
|
|
81
|
+
warnings.push(`Ignoring "mcpServers" in ${filePath}: expected an object keyed by server name.`);
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
return parsed as McpFileShape;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
warnings.push(`Ignoring malformed MCP config at ${filePath}: ${(error as Error).message}`);
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadMcpServers(cwd: string): {
|
|
92
|
+
servers: ResolvedServer[];
|
|
93
|
+
warnings: string[];
|
|
94
|
+
settings?: McpFileShape["settings"];
|
|
95
|
+
} {
|
|
96
|
+
const warnings: string[] = [];
|
|
97
|
+
const byName = new Map<string, ResolvedServer>();
|
|
98
|
+
let settings: McpFileShape["settings"] | undefined;
|
|
99
|
+
|
|
100
|
+
for (const filePath of mcpConfigCandidates(cwd)) {
|
|
101
|
+
const file = readFile(filePath, warnings);
|
|
102
|
+
if (isObject(file.settings)) {
|
|
103
|
+
settings = {
|
|
104
|
+
...settings,
|
|
105
|
+
requestTimeoutMs: positiveNumber(file.settings.requestTimeoutMs) ?? settings?.requestTimeoutMs,
|
|
106
|
+
idleTimeout: positiveNumber(file.settings.idleTimeout) ?? settings?.idleTimeout,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
for (const [name, raw] of Object.entries(file.mcpServers ?? {})) {
|
|
110
|
+
if (!name.trim()) {
|
|
111
|
+
warnings.push(`Skipping MCP server with an empty name in ${filePath}`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const existing = byName.get(name);
|
|
115
|
+
const { definition, problem } = asServer(raw, Boolean(existing));
|
|
116
|
+
if (!definition) {
|
|
117
|
+
warnings.push(`Skipping MCP server "${name}" in ${filePath}: ${problem}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Later files intentionally override earlier ones (project beats user).
|
|
121
|
+
const mergedDef: McpServerDefinition = existing
|
|
122
|
+
? { ...existing.definition, ...definition }
|
|
123
|
+
: definition;
|
|
124
|
+
byName.set(name, { name, definition: mergedDef, source: filePath });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { servers: [...byName.values()], warnings, settings };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function saveProjectMcpOverride(
|
|
131
|
+
cwd: string,
|
|
132
|
+
serverName: string,
|
|
133
|
+
patch: Partial<McpServerDefinition>,
|
|
134
|
+
): void {
|
|
135
|
+
const dir = path.join(cwd, ".pi");
|
|
136
|
+
const filePath = path.join(dir, "mcp.json");
|
|
137
|
+
let content: McpFileShape = {};
|
|
138
|
+
if (fs.existsSync(filePath)) {
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
|
141
|
+
if (isObject(parsed)) content = parsed as McpFileShape;
|
|
142
|
+
} catch {
|
|
143
|
+
content = {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
content.mcpServers = content.mcpServers ?? {};
|
|
147
|
+
const current = content.mcpServers[serverName] ?? {};
|
|
148
|
+
content.mcpServers[serverName] = { ...current, ...patch };
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
fs.writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`, "utf8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function serverTransport(def: McpServerDefinition): "stdio" | "http" | "unknown" {
|
|
154
|
+
if (def.command) return "stdio";
|
|
155
|
+
if (def.url) return "http";
|
|
156
|
+
return "unknown";
|
|
157
|
+
}
|