@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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/examples/mcp.json +30 -0
  4. package/examples/pi-essentials.json +32 -0
  5. package/examples/pi-settings.json +5 -0
  6. package/package.json +88 -0
  7. package/skills/pi-essentials/SKILL.md +50 -0
  8. package/src/config.ts +351 -0
  9. package/src/errors.ts +96 -0
  10. package/src/index.ts +43 -0
  11. package/src/mcp/commands.ts +390 -0
  12. package/src/mcp/config.ts +157 -0
  13. package/src/mcp/credential-store.ts +153 -0
  14. package/src/mcp/index.ts +67 -0
  15. package/src/mcp/manager.ts +941 -0
  16. package/src/mcp/oauth.ts +262 -0
  17. package/src/mcp/proxy-tool.ts +213 -0
  18. package/src/mcp/render.ts +164 -0
  19. package/src/mcp/types.ts +63 -0
  20. package/src/paths.ts +48 -0
  21. package/src/questions/ask.ts +134 -0
  22. package/src/questions/index.ts +72 -0
  23. package/src/questions/render.ts +69 -0
  24. package/src/questions/validate.ts +85 -0
  25. package/src/security/env.ts +132 -0
  26. package/src/security/limits.ts +20 -0
  27. package/src/security/ssrf.ts +237 -0
  28. package/src/subagents/activity.ts +132 -0
  29. package/src/subagents/builtins/oracle.md +11 -0
  30. package/src/subagents/builtins/reviewer.md +11 -0
  31. package/src/subagents/builtins/scout.md +12 -0
  32. package/src/subagents/builtins/worker.md +11 -0
  33. package/src/subagents/discover.ts +54 -0
  34. package/src/subagents/herdr.ts +150 -0
  35. package/src/subagents/index.ts +642 -0
  36. package/src/subagents/inspector-tail.d.mts +1 -0
  37. package/src/subagents/inspector-tail.mjs +140 -0
  38. package/src/subagents/render.ts +464 -0
  39. package/src/subagents/runner.ts +468 -0
  40. package/src/subagents/schema.ts +107 -0
  41. package/src/subagents/types.ts +131 -0
  42. package/src/subagents/worktree.ts +131 -0
  43. package/src/todos/index.ts +170 -0
  44. package/src/todos/render.ts +198 -0
  45. package/src/todos/state.ts +310 -0
  46. package/src/ui/render.ts +215 -0
  47. package/src/web/activity.ts +91 -0
  48. package/src/web/cache.ts +153 -0
  49. package/src/web/extract.ts +75 -0
  50. package/src/web/fetch.ts +167 -0
  51. package/src/web/html-to-markdown.ts +284 -0
  52. package/src/web/http.ts +238 -0
  53. package/src/web/index.ts +214 -0
  54. package/src/web/providers/brave.ts +27 -0
  55. package/src/web/providers/duckduckgo.ts +60 -0
  56. package/src/web/providers/exa.ts +29 -0
  57. package/src/web/providers/jina.ts +25 -0
  58. package/src/web/providers/searxng.ts +29 -0
  59. package/src/web/providers/tavily.ts +31 -0
  60. package/src/web/providers/types.ts +75 -0
  61. package/src/web/render.ts +130 -0
  62. package/src/web/search.ts +108 -0
@@ -0,0 +1,941 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
6
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
7
+ import type { ResolvedConfig } from "../config.ts";
8
+ import { capText, errorMessage, isAbortError, PiEssentialsError } from "../errors.ts";
9
+ import { interpolateEnvValue, interpolateRecord, timeoutSignal } from "../security/env.ts";
10
+ import { CONNECT_TIMEOUT_MS, MAX_TOOL_RESULT_CHARS } from "../security/limits.ts";
11
+ import { loadMcpServers, saveProjectMcpOverride, serverTransport } from "./config.ts";
12
+ import { FileOAuthProvider, maybeOpenUrl, startLoopbackCallback, type LoopbackCallback } from "./oauth.ts";
13
+ import type { CachedTool, McpFileShape, ResolvedServer, ServerSnapshot, ServerStatus } from "./types.ts";
14
+
15
+ interface LiveConnection {
16
+ client: Client;
17
+ transport: Transport;
18
+ lastUsed: number;
19
+ idleTimer?: NodeJS.Timeout;
20
+ }
21
+
22
+ interface ServerState {
23
+ server: ResolvedServer;
24
+ status: ServerStatus;
25
+ error?: string;
26
+ tools: CachedTool[];
27
+ live?: LiveConnection;
28
+ connectPromise?: Promise<void>;
29
+ /** Set once automatic discovery has tried this server, so it is not retried on every call. */
30
+ discovered?: boolean;
31
+ }
32
+
33
+ function globMatch(pattern: string, value: string): boolean {
34
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
35
+ return new RegExp(`^${escaped}$`, "i").test(value);
36
+ }
37
+
38
+ function allowedTool(server: ResolvedServer, originalName: string, prefixedName: string): boolean {
39
+ const def = server.definition;
40
+ const names = [originalName, prefixedName];
41
+ if (def.includeTools?.length) {
42
+ const ok = def.includeTools.some((p) => names.some((n) => globMatch(p, n)));
43
+ if (!ok) return false;
44
+ }
45
+ if (def.excludeTools?.length) {
46
+ if (def.excludeTools.some((p) => names.some((n) => globMatch(p, n)))) return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function prefixName(serverName: string, toolName: string, mode: "server" | "none" | undefined): string {
52
+ if (mode === "none") return toolName;
53
+ const safe = serverName.replace(/[^A-Za-z0-9_]+/g, "_");
54
+ return `${safe}_${toolName}`;
55
+ }
56
+
57
+ function envRecord(env: NodeJS.ProcessEnv): Record<string, string> {
58
+ const out: Record<string, string> = {};
59
+ for (const [key, value] of Object.entries(env)) {
60
+ if (value !== undefined) out[key] = value;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function httpStatus(error: unknown): number | undefined {
66
+ if (!error || typeof error !== "object") return undefined;
67
+ const anyErr = error as { status?: unknown; code?: unknown };
68
+ if (typeof anyErr.status === "number") return anyErr.status;
69
+ if (typeof anyErr.code === "number") return anyErr.code;
70
+ // Only trust a status read out of the message when it is phrased as one, so an
71
+ // unrelated "404" inside a server's prose does not trigger the SSE fallback.
72
+ const match = /\b(?:HTTP|status(?: code)?:?)\s*(\d{3})\b/i.exec(errorMessage(error));
73
+ return match ? Number(match[1]) : undefined;
74
+ }
75
+
76
+ function needsSseFallback(error: unknown): boolean {
77
+ const status = httpStatus(error);
78
+ return status === 404 || status === 405 || status === 406 || status === 415;
79
+ }
80
+
81
+ /** Longest inline value kept for a non-text content part before summarizing it. */
82
+ const MAX_INLINE_PART_CHARS = 2000;
83
+
84
+ function describeContentPart(item: Record<string, unknown>): string {
85
+ const type = typeof item.type === "string" ? item.type : "unknown";
86
+
87
+ // Binary payloads (screenshots, audio) must never be pasted into the parent
88
+ // context: a single base64 image can be megabytes of unreadable tokens.
89
+ if (typeof item.data === "string") {
90
+ const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream";
91
+ const kb = Math.round((item.data.length * 3) / 4 / 1024);
92
+ return `[${type} content omitted: ${mime}, ~${kb} KB base64]`;
93
+ }
94
+ if (type === "resource" || type === "resource_link") {
95
+ const resource = (item.resource ?? item) as Record<string, unknown>;
96
+ if (typeof resource.text === "string") return resource.text;
97
+ if (typeof resource.blob === "string") {
98
+ const mime = typeof resource.mimeType === "string" ? resource.mimeType : "application/octet-stream";
99
+ return `[resource omitted: ${String(resource.uri ?? "unknown")} (${mime})]`;
100
+ }
101
+ return `[resource: ${String(resource.uri ?? "unknown")}]`;
102
+ }
103
+ try {
104
+ const json = JSON.stringify(item);
105
+ return json.length > MAX_INLINE_PART_CHARS
106
+ ? `${json.slice(0, MAX_INLINE_PART_CHARS)}… [${json.length - MAX_INLINE_PART_CHARS} more characters omitted]`
107
+ : json;
108
+ } catch {
109
+ return `[unserializable ${type} content]`;
110
+ }
111
+ }
112
+
113
+ function stringifyToolResult(result: unknown): string {
114
+ if (!result || typeof result !== "object") return String(result ?? "");
115
+ const record = result as { content?: unknown; structuredContent?: unknown; isError?: boolean };
116
+ const content = record.content;
117
+
118
+ if (!Array.isArray(content)) {
119
+ try {
120
+ return JSON.stringify(result, null, 2);
121
+ } catch {
122
+ return String(result);
123
+ }
124
+ }
125
+
126
+ const parts: string[] = [];
127
+ const textJson = new Set<string>();
128
+ for (const item of content) {
129
+ if (!item || typeof item !== "object") continue;
130
+ const rec = item as Record<string, unknown>;
131
+ if (rec.type === "text" && typeof rec.text === "string") {
132
+ parts.push(rec.text);
133
+ try {
134
+ textJson.add(JSON.stringify(JSON.parse(rec.text)));
135
+ } catch {
136
+ // Ordinary prose is not a structured-content duplicate.
137
+ }
138
+ } else parts.push(describeContentPart(rec));
139
+ }
140
+
141
+ if (record.structuredContent !== undefined) {
142
+ try {
143
+ const compact = JSON.stringify(record.structuredContent);
144
+ // Some MCP servers mirror structuredContent into a JSON text part. Keep
145
+ // both kinds of output, but do not paste the same JSON into context twice.
146
+ if (!textJson.has(compact)) parts.push(JSON.stringify(record.structuredContent, null, 2));
147
+ } catch {
148
+ // Ignore an unserializable structured value; content parts remain useful.
149
+ }
150
+ }
151
+
152
+ if (record.isError) return parts.join("\n") || "MCP tool returned an error.";
153
+ return parts.join("\n\n") || "(empty MCP result)";
154
+ }
155
+
156
+ function mcpResultError(result: unknown): PiEssentialsError | undefined {
157
+ if (!result || typeof result !== "object" || !(result as { isError?: unknown }).isError) return undefined;
158
+ return new PiEssentialsError(stringifyToolResult(result), "MCP_TOOL_ERROR");
159
+ }
160
+
161
+ function resolveTool(tools: CachedTool[], name: string): CachedTool | undefined {
162
+ const prefixed = tools.find((tool) => tool.prefixedName === name);
163
+ if (prefixed) return prefixed;
164
+ const unprefixed = tools.filter((tool) => tool.name === name);
165
+ if (unprefixed.length <= 1) return unprefixed[0];
166
+ throw new PiEssentialsError(
167
+ `Ambiguous MCP tool "${name}". Use a prefixed tool name: ${unprefixed.map((tool) => tool.prefixedName).join(", ")}.`,
168
+ "MCP_AMBIGUOUS_TOOL",
169
+ );
170
+ }
171
+
172
+ export const __testing = { stringifyToolResult, mcpResultError, resolveTool, httpStatus };
173
+
174
+ interface PendingAuth {
175
+ loopback: LoopbackCallback;
176
+ timeout: NodeJS.Timeout;
177
+ /** The exact redirect URI this attempt advertised, needed to complete with a bare `code`. */
178
+ redirectUri: string;
179
+ }
180
+
181
+ export class McpManager {
182
+ private readonly states = new Map<string, ServerState>();
183
+ private closed = false;
184
+ /** Config problems surfaced through the UI at session start, not the console. */
185
+ readonly warnings: string[] = [];
186
+ private readonly fileSettings: McpFileShape["settings"];
187
+ /** One in-flight browser-based auth attempt per server; see authStart/authComplete. */
188
+ private readonly pendingAuth = new Map<string, PendingAuth>();
189
+ private readonly authListeners = new Set<(server: string, message: string) => void>();
190
+
191
+ constructor(
192
+ private readonly cwd: string,
193
+ private readonly config: ResolvedConfig,
194
+ ) {
195
+ const { servers, warnings, settings } = loadMcpServers(cwd);
196
+ this.warnings.push(...warnings);
197
+ this.fileSettings = settings;
198
+ for (const server of servers) {
199
+ this.states.set(server.name, {
200
+ server,
201
+ status: server.definition.disabled === true ? "disabled" : "idle",
202
+ tools: [],
203
+ });
204
+ }
205
+ }
206
+
207
+ /** Allow a manager to be reused after `shutdown()` (for example across /reload). */
208
+ reset(): void {
209
+ this.closed = false;
210
+ for (const state of this.states.values()) state.discovered = false;
211
+ }
212
+
213
+ private requestTimeoutFor(state: ServerState): number {
214
+ return (
215
+ state.server.definition.requestTimeoutMs ?? this.fileSettings?.requestTimeoutMs ?? this.config.mcp.requestTimeoutMs
216
+ );
217
+ }
218
+
219
+ private idleTimeoutFor(state: ServerState): number {
220
+ const minutes = state.server.definition.idleTimeout ?? this.fileSettings?.idleTimeout;
221
+ if (minutes && minutes > 0) return minutes * 60_000;
222
+ return this.config.mcp.idleTimeoutMs;
223
+ }
224
+
225
+ listServers(): ServerSnapshot[] {
226
+ return [...this.states.values()].map((state) => ({
227
+ name: state.server.name,
228
+ status: state.status,
229
+ toolCount: state.tools.length,
230
+ disabled: state.status === "disabled",
231
+ transport: serverTransport(state.server.definition),
232
+ error: state.error,
233
+ source: state.server.source,
234
+ }));
235
+ }
236
+
237
+ getOAuthServers(): ServerSnapshot[] {
238
+ return this.listServers().filter((s) => s.transport === "http");
239
+ }
240
+
241
+ async setServerDisabled(name: string, disabled: boolean, persist = true): Promise<void> {
242
+ const state = this.requireServer(name);
243
+ state.server.definition.disabled = disabled;
244
+ if (disabled) {
245
+ await this.disconnectState(state);
246
+ state.status = "disabled";
247
+ state.tools = [];
248
+ } else {
249
+ if (state.status === "disabled") state.status = "idle";
250
+ state.discovered = false;
251
+ }
252
+ if (persist) {
253
+ saveProjectMcpOverride(this.cwd, name, { disabled });
254
+ }
255
+ }
256
+
257
+ allCachedTools(): CachedTool[] {
258
+ return [...this.states.values()].flatMap((s) => s.tools);
259
+ }
260
+
261
+ findTool(name: string): CachedTool | undefined {
262
+ return resolveTool(this.allCachedTools(), name);
263
+ }
264
+
265
+ searchTools(query: string): CachedTool[] {
266
+ const q = query.trim().toLowerCase();
267
+ const tools = this.allCachedTools();
268
+ if (!q) return tools;
269
+ const scored = tools
270
+ .map((tool) => {
271
+ const hay = `${tool.prefixedName} ${tool.name} ${tool.description}`.toLowerCase();
272
+ let score = 0;
273
+ if (tool.prefixedName.toLowerCase() === q || tool.name.toLowerCase() === q) score += 100;
274
+ if (tool.prefixedName.toLowerCase().includes(q) || tool.name.toLowerCase().includes(q)) score += 40;
275
+ if (hay.includes(q)) score += 10;
276
+ for (const word of q.split(/\s+/)) {
277
+ if (word && hay.includes(word)) score += 3;
278
+ }
279
+ return { tool, score };
280
+ })
281
+ .filter((row) => row.score > 0)
282
+ .sort((a, b) => b.score - a.score);
283
+ return scored.map((row) => row.tool);
284
+ }
285
+
286
+ async ensureMetadata(signal?: AbortSignal): Promise<void> {
287
+ const tasks: Promise<void>[] = [];
288
+ for (const state of this.states.values()) {
289
+ if (state.status === "disabled") continue;
290
+ const lifecycle = state.server.definition.lifecycle ?? "lazy";
291
+ if (lifecycle === "eager" || lifecycle === "keep-alive") {
292
+ tasks.push(this.connect(state.server.name, signal).catch(() => undefined));
293
+ }
294
+ }
295
+ await Promise.all(tasks);
296
+ }
297
+
298
+ async connect(name: string, signal?: AbortSignal, force = false): Promise<void> {
299
+ const state = this.requireServer(name);
300
+ if (state.status === "disabled") throw new PiEssentialsError(`MCP server "${name}" is disabled.`, "MCP_DISABLED");
301
+ if (state.live && !force) {
302
+ state.live.lastUsed = Date.now();
303
+ this.scheduleIdle(state);
304
+ return;
305
+ }
306
+ if (state.connectPromise && !force) return state.connectPromise;
307
+ const attempt = this.connectInternal(state, signal);
308
+ state.connectPromise = attempt;
309
+ try {
310
+ await attempt;
311
+ } finally {
312
+ // A concurrent force-reconnect may already have installed a newer attempt.
313
+ if (state.connectPromise === attempt) state.connectPromise = undefined;
314
+ }
315
+ }
316
+
317
+ async disconnect(name?: string): Promise<void> {
318
+ const targets = name ? [this.requireServer(name)] : [...this.states.values()];
319
+ await Promise.all(targets.map((state) => this.disconnectState(state)));
320
+ }
321
+
322
+ async shutdown(): Promise<void> {
323
+ this.closed = true;
324
+ for (const name of [...this.pendingAuth.keys()]) this.cancelPendingAuth(name);
325
+ await this.disconnect();
326
+ }
327
+
328
+ /** Notified when a background browser-based auth (started by authStart) finishes without a follow-up call. */
329
+ onAuthUpdate(listener: (server: string, message: string) => void): () => void {
330
+ this.authListeners.add(listener);
331
+ return () => this.authListeners.delete(listener);
332
+ }
333
+
334
+ private cancelPendingAuth(name: string): void {
335
+ const pending = this.pendingAuth.get(name);
336
+ if (!pending) return;
337
+ this.pendingAuth.delete(name);
338
+ clearTimeout(pending.timeout);
339
+ pending.loopback.close();
340
+ }
341
+
342
+ /** Force a reconnect of every enabled server (used by /mcp reconnect). */
343
+ async refreshAll(signal?: AbortSignal): Promise<void> {
344
+ await Promise.all(
345
+ [...this.states.values()]
346
+ .filter((state) => state.status !== "disabled")
347
+ .map((state) => {
348
+ state.discovered = false;
349
+ return this.connect(state.server.name, signal, true).catch(() => undefined);
350
+ }),
351
+ );
352
+ }
353
+
354
+ /**
355
+ * Make sure every enabled server has been listed at least once. Servers that
356
+ * already have cached tools are left alone, so discovery does not tear down
357
+ * healthy connections.
358
+ */
359
+ async ensureAllTools(signal?: AbortSignal): Promise<void> {
360
+ const pending = [...this.states.values()].filter(
361
+ (state) => state.status !== "disabled" && state.tools.length === 0 && !state.discovered,
362
+ );
363
+ await Promise.all(
364
+ pending.map(async (state) => {
365
+ // Mark first: a server that fails or exposes no tools must not be retried
366
+ // on every subsequent search/list/describe call.
367
+ state.discovered = true;
368
+ await this.connect(state.server.name, signal).catch(() => undefined);
369
+ }),
370
+ );
371
+ }
372
+
373
+ /** Human-readable reasons why discovery produced no tools. */
374
+ discoveryProblems(): string[] {
375
+ return this.listServers()
376
+ .filter((row) => row.status === "failed" || row.status === "needs-auth")
377
+ .map((row) => `${row.name}: ${row.status}${row.error ? ` — ${row.error}` : ""}`);
378
+ }
379
+
380
+ async callTool(prefixedOrName: string, args: unknown, signal?: AbortSignal): Promise<string> {
381
+ const tool = this.findTool(prefixedOrName);
382
+ const name = tool?.name ?? prefixedOrName;
383
+ const serverName = tool?.server ?? this.guessServer(prefixedOrName);
384
+ if (!serverName) {
385
+ throw new PiEssentialsError(
386
+ `Unknown MCP tool "${prefixedOrName}". Use mcp({ action: "search", query: "..." }) first.`,
387
+ "MCP_UNKNOWN_TOOL",
388
+ );
389
+ }
390
+ await this.connect(serverName, signal);
391
+ const state = this.requireServer(serverName);
392
+ if (!state.live) throw new PiEssentialsError(`MCP server "${serverName}" is not connected.`, "MCP_NOT_CONNECTED");
393
+ const timeoutMs = this.requestTimeoutFor(state);
394
+ const combined = timeoutSignal(timeoutMs, signal);
395
+ try {
396
+ const result = await state.live.client.callTool({ name, arguments: parseMcpArgs(args) }, undefined, {
397
+ timeout: timeoutMs,
398
+ signal: combined,
399
+ });
400
+ state.live.lastUsed = Date.now();
401
+ this.scheduleIdle(state);
402
+ const failure = mcpResultError(result);
403
+ if (failure) throw failure;
404
+ const text = stringifyToolResult(result);
405
+ return capText(text, MAX_TOOL_RESULT_CHARS).text;
406
+ } catch (error) {
407
+ if (error instanceof UnauthorizedError) {
408
+ state.status = "needs-auth";
409
+ throw new PiEssentialsError(
410
+ `MCP server "${serverName}" requires OAuth. Run mcp({ action: "auth", server: "${serverName}" }) or /mcp auth ${serverName}.`,
411
+ "MCP_NEEDS_AUTH",
412
+ );
413
+ }
414
+ throw this.wrapCallError(serverName, error);
415
+ }
416
+ }
417
+
418
+ async auth(name: string, redirectUrl?: string, signal?: AbortSignal): Promise<string> {
419
+ const state = this.requireServer(name);
420
+ if (!state.server.definition.url) {
421
+ throw new PiEssentialsError(`Server "${name}" is stdio and does not use OAuth.`, "MCP_NO_OAUTH");
422
+ }
423
+ await this.disconnectState(state);
424
+
425
+ if (redirectUrl) {
426
+ return this.finishExistingAuth(state, redirectUrl, signal);
427
+ }
428
+
429
+ const loopback = await startLoopbackCallback();
430
+ try {
431
+ const provider = this.createOAuthProvider(state, loopback.redirectUri);
432
+ await this.connectWithProvider(state, provider, signal, true);
433
+ const authorize = provider.takeRedirectUrl();
434
+ if (!authorize && state.live) {
435
+ await this.markConnected(state, signal);
436
+ state.discovered = false;
437
+ return `Connected to "${name}" (existing credentials worked).`;
438
+ }
439
+ if (!authorize) {
440
+ throw new PiEssentialsError(`OAuth did not produce an authorization URL for "${name}".`, "MCP_OAUTH_FAILED");
441
+ }
442
+ const opened = await maybeOpenUrl(authorize.toString());
443
+ const callback = await loopback.waitForCallback(5 * 60_000);
444
+ await this.finishAuthOnProvider(state, provider, callback, signal);
445
+ state.discovered = false;
446
+ return opened
447
+ ? `Authenticated with "${name}".`
448
+ : `Authenticated with "${name}". Authorization URL was: ${authorize.toString()}`;
449
+ } finally {
450
+ loopback.close();
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Non-blocking counterpart to `auth()`: returns the authorization URL right
456
+ * away instead of waiting up to 5 minutes for the browser callback. A
457
+ * background listener still completes the flow automatically if the
458
+ * callback does arrive; otherwise call `authComplete` with the pasted
459
+ * redirect URL (works from a different call, session, or even process,
460
+ * since the PKCE verifier is read back from the credential store) or the
461
+ * bare `code` (only within this same process, since that needs the exact
462
+ * redirect URI this attempt advertised).
463
+ */
464
+ async authStart(name: string, signal?: AbortSignal): Promise<{ authorizeUrl?: string; message: string }> {
465
+ const state = this.requireServer(name);
466
+ if (!state.server.definition.url) {
467
+ throw new PiEssentialsError(`Server "${name}" is stdio and does not use OAuth.`, "MCP_NO_OAUTH");
468
+ }
469
+ await this.disconnectState(state);
470
+ this.cancelPendingAuth(name);
471
+
472
+ const loopback = await startLoopbackCallback();
473
+ const provider = this.createOAuthProvider(state, loopback.redirectUri);
474
+ let authorize: URL | undefined;
475
+ try {
476
+ await this.connectWithProvider(state, provider, signal, true);
477
+ authorize = provider.takeRedirectUrl();
478
+ } catch (error) {
479
+ loopback.close();
480
+ throw error;
481
+ }
482
+
483
+ if (!authorize && state.live) {
484
+ loopback.close();
485
+ await this.markConnected(state, signal);
486
+ state.discovered = false;
487
+ return { message: `Connected to "${name}" (existing credentials worked).` };
488
+ }
489
+ if (!authorize) {
490
+ loopback.close();
491
+ throw new PiEssentialsError(`OAuth did not produce an authorization URL for "${name}".`, "MCP_OAUTH_FAILED");
492
+ }
493
+
494
+ const opened = await maybeOpenUrl(authorize.toString());
495
+ const timeout = setTimeout(() => this.cancelPendingAuth(name), 5 * 60_000);
496
+ timeout.unref?.();
497
+ this.pendingAuth.set(name, { loopback, timeout, redirectUri: loopback.redirectUri });
498
+
499
+ loopback
500
+ .waitForCallback(5 * 60_000)
501
+ .then(async (callback) => {
502
+ // Superseded by a cancel, a manual authComplete, or a fresh authStart for the same server.
503
+ if (this.pendingAuth.get(name)?.loopback !== loopback) return;
504
+ this.pendingAuth.delete(name);
505
+ clearTimeout(timeout);
506
+ try {
507
+ await this.finishAuthOnProvider(state, provider, callback, signal);
508
+ state.discovered = false;
509
+ this.notifyAuth(name, `Authenticated with "${name}".`);
510
+ } catch (error) {
511
+ this.notifyAuth(name, `OAuth for "${name}" failed: ${errorMessage(error)}`);
512
+ } finally {
513
+ loopback.close();
514
+ }
515
+ })
516
+ .catch(() => {
517
+ if (this.pendingAuth.get(name)?.loopback === loopback) this.cancelPendingAuth(name);
518
+ else loopback.close();
519
+ });
520
+
521
+ const authorizeUrl = authorize.toString();
522
+ return {
523
+ authorizeUrl,
524
+ message: opened
525
+ ? `Opened a browser to authenticate "${name}". Waiting for the callback in the background; ` +
526
+ `if it does not complete, call authComplete with the redirect URL.`
527
+ : `Open this URL to authenticate "${name}": ${authorizeUrl}\n` +
528
+ `Waiting for the callback in the background; if it does not complete, call authComplete with the redirect URL.`,
529
+ };
530
+ }
531
+
532
+ /** Finish an auth-start flow with a pasted redirect URL, or (same-process only) a bare code. */
533
+ async authComplete(name: string, input: { redirectUrl?: string; code?: string }, signal?: AbortSignal): Promise<string> {
534
+ const state = this.requireServer(name);
535
+ if (!state.server.definition.url) {
536
+ throw new PiEssentialsError(`Server "${name}" is stdio and does not use OAuth.`, "MCP_NO_OAUTH");
537
+ }
538
+ const pending = this.pendingAuth.get(name);
539
+ this.cancelPendingAuth(name);
540
+
541
+ if (input.redirectUrl) {
542
+ await this.disconnectState(state);
543
+ return this.finishExistingAuth(state, input.redirectUrl, signal);
544
+ }
545
+ if (input.code) {
546
+ const redirectUri = pending?.redirectUri ?? state.server.definition.oauth?.redirectUri;
547
+ if (!redirectUri) {
548
+ throw new PiEssentialsError(
549
+ `No pending authStart redirect URI for "${name}" and none is configured; pass the full redirectUrl instead.`,
550
+ "MCP_OAUTH_FAILED",
551
+ );
552
+ }
553
+ await this.disconnectState(state);
554
+ const provider = this.createOAuthProvider(state, redirectUri);
555
+ const callback = new URL(redirectUri);
556
+ callback.searchParams.set("code", input.code);
557
+ await this.finishAuthOnProvider(state, provider, callback, signal);
558
+ state.discovered = false;
559
+ return `Authenticated with "${name}".`;
560
+ }
561
+ throw new PiEssentialsError("authComplete requires redirectUrl or code.", "MCP_BAD_ARGS");
562
+ }
563
+
564
+ /** Clear stored OAuth credentials for a server and disconnect it. */
565
+ async logout(name: string): Promise<string> {
566
+ const state = this.requireServer(name);
567
+ if (!state.server.definition.url) {
568
+ throw new PiEssentialsError(`Server "${name}" is stdio and does not use OAuth.`, "MCP_NO_OAUTH");
569
+ }
570
+ this.cancelPendingAuth(name);
571
+ await this.disconnectState(state);
572
+ const provider = this.createOAuthProvider(state);
573
+ await provider.clearTokens();
574
+ if (state.status !== "disabled") state.status = "idle";
575
+ state.tools = [];
576
+ state.discovered = false;
577
+ return `Cleared stored OAuth credentials for "${name}".`;
578
+ }
579
+
580
+ private notifyAuth(server: string, message: string): void {
581
+ for (const listener of this.authListeners) listener(server, message);
582
+ }
583
+
584
+ formatStatus(): string {
585
+ const rows = this.listServers();
586
+ if (rows.length === 0) {
587
+ return "No MCP servers configured. Add servers to .mcp.json or ~/.pi/agent/mcp.json.";
588
+ }
589
+ const width = (pick: (row: ServerSnapshot) => string) => Math.max(...rows.map((row) => pick(row).length));
590
+ const nameWidth = Math.max(6, width((row) => row.name));
591
+ const statusWidth = Math.max(6, width((row) => row.status));
592
+ const header = `${"SERVER".padEnd(nameWidth)} ${"STATUS".padEnd(statusWidth)} TRANSPORT TOOLS SOURCE`;
593
+ const body = rows.map((row) => {
594
+ const extra = row.error ? `\n${" ".repeat(nameWidth + 2)}└─ ${row.error}` : "";
595
+ return (
596
+ `${row.name.padEnd(nameWidth)} ${row.status.padEnd(statusWidth)} ${row.transport.padEnd(9)} ` +
597
+ `${String(row.toolCount).padStart(5)} ${row.source}${extra}`
598
+ );
599
+ });
600
+ const lazy = rows.some((row) => row.status === "idle" && row.toolCount === 0);
601
+ const hint = lazy ? "\n\nLazy servers list their tools on first use; run /mcp reconnect to connect now." : "";
602
+ return `${header}\n${body.join("\n")}${hint}`;
603
+ }
604
+
605
+ private async finishExistingAuth(state: ServerState, redirectUrl: string, signal?: AbortSignal): Promise<string> {
606
+ const url = new URL(redirectUrl);
607
+ const redirectUri = `${url.protocol}//${url.host}${url.pathname}`;
608
+ const provider = this.createOAuthProvider(state, redirectUri);
609
+ // Do not run connectWithProvider here: it would trigger a fresh OAuth
610
+ // discovery/registration attempt and mint a new PKCE code_verifier, silently
611
+ // invalidating the one the authorization code in `redirectUrl` was issued
612
+ // against. finishAuthOnProvider reads the verifier persisted by the earlier
613
+ // auth() call that produced this redirect, exchanges the code, and connects.
614
+ await this.finishAuthOnProvider(state, provider, url, signal);
615
+ state.discovered = false;
616
+ return `Authenticated with "${state.server.name}".`;
617
+ }
618
+
619
+ private async finishAuthOnProvider(
620
+ state: ServerState,
621
+ provider: FileOAuthProvider,
622
+ callbackUrl: URL,
623
+ signal?: AbortSignal,
624
+ ): Promise<void> {
625
+ const denied = callbackUrl.searchParams.get("error");
626
+ if (denied) {
627
+ const detail = callbackUrl.searchParams.get("error_description") ?? denied;
628
+ throw new PiEssentialsError(`OAuth was denied by the provider: ${detail}`, "MCP_OAUTH_FAILED");
629
+ }
630
+ const code = callbackUrl.searchParams.get("code");
631
+ if (!code) throw new PiEssentialsError("OAuth callback did not include an authorization code.", "MCP_OAUTH_FAILED");
632
+ const transport = await this.createHttpTransport(state, provider);
633
+ if (!("finishAuth" in transport) || typeof transport.finishAuth !== "function") {
634
+ throw new PiEssentialsError("This transport does not support OAuth code exchange.", "MCP_OAUTH_FAILED");
635
+ }
636
+ await transport.finishAuth(code);
637
+ await transport.close?.();
638
+ await this.connectWithProvider(state, provider, signal, false);
639
+ await this.markConnected(state, signal);
640
+ }
641
+
642
+ /**
643
+ * `connectWithProvider` only sets `state.live`; it does not update status or
644
+ * tools the way `connectInternal` does. Both OAuth completion paths call it
645
+ * directly, so without this, a freshly authenticated server keeps reporting
646
+ * its pre-auth status (typically "needs-auth" with 0 tools) forever: `connect()`
647
+ * short-circuits on an existing `state.live` before `connectInternal` ever runs.
648
+ */
649
+ private async markConnected(state: ServerState, signal?: AbortSignal): Promise<void> {
650
+ if (!state.live) return;
651
+ await this.refreshTools(state, signal);
652
+ state.status = "connected";
653
+ state.error = undefined;
654
+ this.scheduleIdle(state);
655
+ }
656
+
657
+ private async connectInternal(state: ServerState, signal?: AbortSignal): Promise<void> {
658
+ if (this.closed) throw new PiEssentialsError("MCP manager has been shut down.", "MCP_SHUTDOWN");
659
+ await this.disconnectState(state);
660
+ state.status = "connecting";
661
+ state.error = undefined;
662
+ try {
663
+ const def = state.server.definition;
664
+ if (def.command) {
665
+ await this.connectStdio(state, signal);
666
+ } else if (def.url) {
667
+ const provider = def.auth === "oauth" ? this.createOAuthProvider(state) : undefined;
668
+ await this.connectWithProvider(state, provider, signal, false);
669
+ } else {
670
+ throw new PiEssentialsError(`Server "${state.server.name}" has neither command nor url.`, "MCP_BAD_CONFIG");
671
+ }
672
+ await this.refreshTools(state, signal);
673
+ state.status = "connected";
674
+ this.scheduleIdle(state);
675
+ } catch (error) {
676
+ if (error instanceof UnauthorizedError) {
677
+ state.status = "needs-auth";
678
+ state.error = "OAuth required";
679
+ throw new PiEssentialsError(
680
+ `MCP server "${state.server.name}" requires OAuth. Run mcp({ action: "auth", server: "${state.server.name}" }).`,
681
+ "MCP_NEEDS_AUTH",
682
+ );
683
+ }
684
+ state.status = "failed";
685
+ state.error = errorMessage(error);
686
+ throw this.wrapCallError(state.server.name, error);
687
+ }
688
+ }
689
+
690
+ private async connectStdio(state: ServerState, signal?: AbortSignal): Promise<void> {
691
+ const def = state.server.definition;
692
+ const command = interpolateEnvValue(def.command ?? "", process.env);
693
+ if (!command) throw new PiEssentialsError(`Server "${state.server.name}" has an empty command.`, "MCP_BAD_CONFIG");
694
+ const args = (def.args ?? []).map((arg) => interpolateEnvValue(arg, process.env));
695
+ const { values: extraEnv, missing } = interpolateRecord(def.env);
696
+ if (missing.length > 0) {
697
+ throw new PiEssentialsError(
698
+ `Server "${state.server.name}" references missing environment variables: ${missing.join(", ")}`,
699
+ "MCP_BAD_CONFIG",
700
+ );
701
+ }
702
+ const cwd = def.cwd ? interpolateEnvValue(def.cwd, process.env) : this.cwd;
703
+ const transport = new StdioClientTransport({
704
+ command,
705
+ args,
706
+ cwd,
707
+ env: { ...envRecord(process.env), ...extraEnv },
708
+ stderr: "pipe",
709
+ });
710
+ const client = new Client({ name: "pi-essentials", version: "0.1.0" });
711
+ const combined = timeoutSignal(CONNECT_TIMEOUT_MS, signal);
712
+ await this.connectClient(client, transport, combined);
713
+ state.live = { client, transport, lastUsed: Date.now() };
714
+ }
715
+
716
+ private createOAuthProvider(state: ServerState, redirectUri?: string): FileOAuthProvider {
717
+ const def = state.server.definition;
718
+ const url = this.resolvedUrl(state);
719
+ const redirect = redirectUri ?? def.oauth?.redirectUri ?? "http://127.0.0.1/callback";
720
+ const staticClient = def.oauth?.clientId
721
+ ? { client_id: def.oauth.clientId, client_secret: def.oauth.clientSecret }
722
+ : undefined;
723
+ return new FileOAuthProvider(
724
+ state.server.name,
725
+ url.toString(),
726
+ redirect,
727
+ {
728
+ client_name: "pi-essentials",
729
+ redirect_uris: [redirect],
730
+ grant_types: ["authorization_code", "refresh_token"],
731
+ response_types: ["code"],
732
+ token_endpoint_auth_method: def.oauth?.clientSecret ? "client_secret_post" : "none",
733
+ scope: def.oauth?.scope,
734
+ },
735
+ staticClient,
736
+ );
737
+ }
738
+
739
+ private async connectWithProvider(
740
+ state: ServerState,
741
+ provider: FileOAuthProvider | undefined,
742
+ signal: AbortSignal | undefined,
743
+ allowUnauthorized: boolean,
744
+ ): Promise<void> {
745
+ const transport = await this.createHttpTransport(state, provider);
746
+ const client = new Client({ name: "pi-essentials", version: "0.1.0" });
747
+ const combined = timeoutSignal(CONNECT_TIMEOUT_MS, signal);
748
+ try {
749
+ await this.connectClient(client, transport, combined);
750
+ state.live = { client, transport, lastUsed: Date.now() };
751
+ } catch (error) {
752
+ if (error instanceof UnauthorizedError && allowUnauthorized) {
753
+ await transport.close?.().catch(() => undefined);
754
+ await client.close().catch(() => undefined);
755
+ return;
756
+ }
757
+ if (needsSseFallback(error) && !(transport instanceof SSEClientTransport)) {
758
+ await transport.close?.().catch(() => undefined);
759
+ await client.close().catch(() => undefined);
760
+ const sse = await this.createSseTransport(state, provider);
761
+ const sseClient = new Client({ name: "pi-essentials", version: "0.1.0" });
762
+ try {
763
+ await this.connectClient(sseClient, sse, timeoutSignal(CONNECT_TIMEOUT_MS, signal));
764
+ state.live = { client: sseClient, transport: sse, lastUsed: Date.now() };
765
+ return;
766
+ } catch (sseError) {
767
+ await sse.close?.().catch(() => undefined);
768
+ await sseClient.close().catch(() => undefined);
769
+ throw sseError;
770
+ }
771
+ }
772
+ await transport.close?.().catch(() => undefined);
773
+ await client.close().catch(() => undefined);
774
+ throw error;
775
+ }
776
+ }
777
+
778
+ private async createHttpTransport(state: ServerState, provider?: FileOAuthProvider): Promise<StreamableHTTPClientTransport> {
779
+ const url = this.resolvedUrl(state);
780
+ return new StreamableHTTPClientTransport(url, {
781
+ requestInit: { headers: this.httpHeaders(state) },
782
+ authProvider: provider,
783
+ });
784
+ }
785
+
786
+ private async createSseTransport(state: ServerState, provider?: FileOAuthProvider): Promise<SSEClientTransport> {
787
+ const url = this.resolvedUrl(state);
788
+ return new SSEClientTransport(url, {
789
+ requestInit: { headers: this.httpHeaders(state) },
790
+ authProvider: provider,
791
+ });
792
+ }
793
+
794
+ private resolvedUrl(state: ServerState): URL {
795
+ const missing: string[] = [];
796
+ const raw = interpolateEnvValue(state.server.definition.url ?? "", process.env, missing);
797
+ if (missing.length > 0 || !raw) {
798
+ throw new PiEssentialsError(
799
+ `Server "${state.server.name}" URL is missing environment variables: ${missing.join(", ") || "(empty)"}`,
800
+ "MCP_BAD_CONFIG",
801
+ );
802
+ }
803
+ try {
804
+ return new URL(raw);
805
+ } catch {
806
+ throw new PiEssentialsError(`Server "${state.server.name}" has an invalid URL.`, "MCP_BAD_CONFIG");
807
+ }
808
+ }
809
+
810
+ private httpHeaders(state: ServerState): Record<string, string> {
811
+ const { values, missing } = interpolateRecord(state.server.definition.headers);
812
+ if (missing.length > 0) {
813
+ throw new PiEssentialsError(
814
+ `Server "${state.server.name}" headers reference missing environment variables: ${missing.join(", ")}`,
815
+ "MCP_BAD_CONFIG",
816
+ );
817
+ }
818
+ const headers: Record<string, string> = { ...values };
819
+ const def = state.server.definition;
820
+ const bearer =
821
+ (def.bearerToken ? interpolateEnvValue(def.bearerToken) : undefined) ||
822
+ (def.bearerTokenEnv ? process.env[def.bearerTokenEnv] : undefined);
823
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
824
+ return headers;
825
+ }
826
+
827
+ private async connectClient(client: Client, transport: Transport, signal: AbortSignal): Promise<void> {
828
+ const abort = new Promise<never>((_resolve, reject) => {
829
+ const onAbort = () => reject(new PiEssentialsError("MCP connection timed out or was cancelled.", "MCP_TIMEOUT", true));
830
+ if (signal.aborted) onAbort();
831
+ else signal.addEventListener("abort", onAbort, { once: true });
832
+ });
833
+ await Promise.race([client.connect(transport), abort]);
834
+ }
835
+
836
+ private async refreshTools(state: ServerState, signal?: AbortSignal): Promise<void> {
837
+ if (!state.live) return;
838
+ const timeoutMs = this.requestTimeoutFor(state);
839
+ const tools: CachedTool[] = [];
840
+ let cursor: string | undefined;
841
+ do {
842
+ const listed = await state.live.client.listTools(
843
+ cursor ? { cursor } : undefined,
844
+ { timeout: timeoutMs, signal: timeoutSignal(timeoutMs, signal) },
845
+ );
846
+ for (const tool of listed.tools ?? []) {
847
+ const prefixedName = prefixName(state.server.name, tool.name, state.server.definition.toolPrefix);
848
+ if (!allowedTool(state.server, tool.name, prefixedName)) continue;
849
+ tools.push({
850
+ server: state.server.name,
851
+ name: tool.name,
852
+ prefixedName,
853
+ description: tool.description ?? "",
854
+ inputSchema: tool.inputSchema,
855
+ });
856
+ }
857
+ cursor = listed.nextCursor;
858
+ } while (cursor);
859
+ state.tools = tools;
860
+ }
861
+
862
+ private scheduleIdle(state: ServerState): void {
863
+ if (state.live?.idleTimer) clearTimeout(state.live.idleTimer);
864
+ if (!state.live) return;
865
+ if ((state.server.definition.lifecycle ?? "lazy") === "keep-alive") return;
866
+ state.live.idleTimer = setTimeout(() => {
867
+ void this.disconnectState(state);
868
+ }, this.idleTimeoutFor(state));
869
+ state.live.idleTimer.unref?.();
870
+ }
871
+
872
+ private async disconnectState(state: ServerState): Promise<void> {
873
+ const live = state.live;
874
+ state.live = undefined;
875
+ // "disabled", "failed" and "needs-auth" describe the server, not the socket,
876
+ // so only a live/connecting server falls back to "idle".
877
+ if (state.status === "connected" || state.status === "connecting") state.status = "idle";
878
+ if (!live) return;
879
+ if (live.idleTimer) clearTimeout(live.idleTimer);
880
+ try {
881
+ await live.client.close();
882
+ } catch {
883
+ // ignore
884
+ }
885
+ try {
886
+ await live.transport.close?.();
887
+ } catch {
888
+ // ignore
889
+ }
890
+ }
891
+
892
+ private requireServer(name: string): ServerState {
893
+ const state = this.states.get(name);
894
+ if (!state) {
895
+ const available = [...this.states.keys()].join(", ") || "none";
896
+ throw new PiEssentialsError(`Unknown MCP server "${name}". Available: ${available}`, "MCP_UNKNOWN_SERVER");
897
+ }
898
+ return state;
899
+ }
900
+
901
+ private guessServer(toolName: string): string | undefined {
902
+ let best: { name: string; length: number } | undefined;
903
+ for (const state of this.states.values()) {
904
+ const prefix = `${state.server.name.replace(/[^A-Za-z0-9_]+/g, "_")}_`;
905
+ if (!toolName.startsWith(prefix)) continue;
906
+ // "a_b" and "a" both prefix "a_b_c"; the longer match is the real server.
907
+ if (!best || prefix.length > best.length) best = { name: state.server.name, length: prefix.length };
908
+ }
909
+ return best?.name;
910
+ }
911
+
912
+ private wrapCallError(server: string, error: unknown): PiEssentialsError {
913
+ if (error instanceof PiEssentialsError) return error;
914
+ if (isAbortError(error)) {
915
+ return new PiEssentialsError(`MCP request to "${server}" timed out or was cancelled.`, "MCP_TIMEOUT", true);
916
+ }
917
+ const message = errorMessage(error);
918
+ if (/not valid JSON|Unexpected token|parse/i.test(message)) {
919
+ return new PiEssentialsError(`MCP server "${server}" returned a malformed response.`, "MCP_BAD_RESPONSE");
920
+ }
921
+ return new PiEssentialsError(`MCP server "${server}" failed: ${message}`, "MCP_ERROR", true);
922
+ }
923
+ }
924
+
925
+ export function parseMcpArgs(args: unknown): Record<string, unknown> {
926
+ if (args === undefined || args === null) return {};
927
+ if (typeof args === "string") {
928
+ const trimmed = args.trim();
929
+ if (!trimmed) return {};
930
+ try {
931
+ const parsed = JSON.parse(trimmed) as unknown;
932
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
933
+ throw new PiEssentialsError("MCP args JSON must be an object.", "MCP_BAD_ARGS");
934
+ } catch (error) {
935
+ if (error instanceof PiEssentialsError) throw error;
936
+ throw new PiEssentialsError("MCP args string is not valid JSON.", "MCP_BAD_ARGS");
937
+ }
938
+ }
939
+ if (typeof args === "object" && !Array.isArray(args)) return args as Record<string, unknown>;
940
+ throw new PiEssentialsError("MCP args must be an object or JSON object string.", "MCP_BAD_ARGS");
941
+ }