@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
package/src/mcp/oauth.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import type {
|
|
4
|
+
OAuthClientInformation,
|
|
5
|
+
OAuthClientInformationFull,
|
|
6
|
+
OAuthClientMetadata,
|
|
7
|
+
OAuthTokens,
|
|
8
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
9
|
+
import { accountId, createFileStore, resolveCredentialStore } from "./credential-store.ts";
|
|
10
|
+
|
|
11
|
+
interface StoredRecord {
|
|
12
|
+
serverUrl: string;
|
|
13
|
+
tokens?: OAuthTokens;
|
|
14
|
+
clientInformation?: OAuthClientInformationFull | OAuthClientInformation;
|
|
15
|
+
codeVerifier?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function storageKey(serverName: string, serverUrl: string): string {
|
|
19
|
+
return `${serverName}|${serverUrl}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared across FileOAuthProvider instances for the process lifetime, so a
|
|
23
|
+
* second instance created for the same server (as the two-step auth-complete
|
|
24
|
+
* flow does) sees what an earlier instance just persisted. `null` means
|
|
25
|
+
* "confirmed absent", distinct from "not yet looked up". */
|
|
26
|
+
const recordCache = new Map<string, StoredRecord | null>();
|
|
27
|
+
|
|
28
|
+
function parseRecord(raw: string | undefined, expectedUrl: string): StoredRecord | undefined {
|
|
29
|
+
if (raw === undefined) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw) as StoredRecord;
|
|
32
|
+
// A record whose URL no longer matches belongs to a server that moved; treat it as absent.
|
|
33
|
+
return parsed && parsed.serverUrl === expectedUrl ? parsed : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readRecord(serverName: string, serverUrl: string): Promise<StoredRecord | undefined> {
|
|
40
|
+
const rawKey = storageKey(serverName, serverUrl);
|
|
41
|
+
if (recordCache.has(rawKey)) return recordCache.get(rawKey) ?? undefined;
|
|
42
|
+
|
|
43
|
+
const { store } = await resolveCredentialStore();
|
|
44
|
+
const account = store.backend === "keyring" ? accountId(rawKey) : rawKey;
|
|
45
|
+
let raw = await store.get(account);
|
|
46
|
+
|
|
47
|
+
if (raw === undefined && store.backend === "keyring") {
|
|
48
|
+
// One-way import of a plaintext entry from before the credential store existed.
|
|
49
|
+
const legacy = createFileStore();
|
|
50
|
+
const legacyRaw = await legacy.get(rawKey);
|
|
51
|
+
if (legacyRaw !== undefined) {
|
|
52
|
+
await store.set(account, legacyRaw);
|
|
53
|
+
await legacy.delete(rawKey);
|
|
54
|
+
raw = legacyRaw;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const record = parseRecord(raw, serverUrl);
|
|
59
|
+
recordCache.set(rawKey, record ?? null);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeRecord(serverName: string, serverUrl: string, record: StoredRecord): Promise<void> {
|
|
64
|
+
const rawKey = storageKey(serverName, serverUrl);
|
|
65
|
+
const { store } = await resolveCredentialStore();
|
|
66
|
+
const account = store.backend === "keyring" ? accountId(rawKey) : rawKey;
|
|
67
|
+
await store.set(account, JSON.stringify(record));
|
|
68
|
+
recordCache.set(rawKey, record);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function deleteRecord(serverName: string, serverUrl: string): Promise<void> {
|
|
72
|
+
const rawKey = storageKey(serverName, serverUrl);
|
|
73
|
+
const { store } = await resolveCredentialStore();
|
|
74
|
+
const account = store.backend === "keyring" ? accountId(rawKey) : rawKey;
|
|
75
|
+
await store.delete(account);
|
|
76
|
+
// A server not yet migrated to the keyring may still have a legacy plaintext
|
|
77
|
+
// entry; clear that too so logout actually removes every stored credential.
|
|
78
|
+
if (store.backend === "keyring") await createFileStore().delete(rawKey);
|
|
79
|
+
recordCache.set(rawKey, null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Test-only: forget cached records so a test can observe a fresh read from the backing store. */
|
|
83
|
+
export function resetOAuthCacheForTests(): void {
|
|
84
|
+
recordCache.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class FileOAuthProvider implements OAuthClientProvider {
|
|
88
|
+
private pendingRedirect: URL | undefined;
|
|
89
|
+
private localVerifier: string | undefined;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
readonly serverName: string,
|
|
93
|
+
readonly serverUrl: string,
|
|
94
|
+
readonly redirectUrl: string,
|
|
95
|
+
readonly clientMetadata: OAuthClientMetadata,
|
|
96
|
+
private readonly staticClient?: OAuthClientInformation,
|
|
97
|
+
private readonly onRedirect?: (url: URL) => void,
|
|
98
|
+
) {}
|
|
99
|
+
|
|
100
|
+
private async read(): Promise<StoredRecord> {
|
|
101
|
+
const record = await readRecord(this.serverName, this.serverUrl);
|
|
102
|
+
return record ?? { serverUrl: this.serverUrl };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async write(patch: Partial<StoredRecord>): Promise<void> {
|
|
106
|
+
const current = await this.read();
|
|
107
|
+
await writeRecord(this.serverName, this.serverUrl, { ...current, ...patch, serverUrl: this.serverUrl });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
|
111
|
+
return this.staticClient ?? (await this.read()).clientInformation;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
|
115
|
+
await this.write({ clientInformation: info });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async tokens(): Promise<OAuthTokens | undefined> {
|
|
119
|
+
return (await this.read()).tokens;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
123
|
+
await this.write({ tokens, codeVerifier: undefined });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
redirectToAuthorization(authorizationUrl: URL): void {
|
|
127
|
+
this.pendingRedirect = authorizationUrl;
|
|
128
|
+
this.onRedirect?.(authorizationUrl);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
takeRedirectUrl(): URL | undefined {
|
|
132
|
+
const url = this.pendingRedirect;
|
|
133
|
+
this.pendingRedirect = undefined;
|
|
134
|
+
return url;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
138
|
+
this.localVerifier = codeVerifier;
|
|
139
|
+
await this.write({ codeVerifier });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async codeVerifier(): Promise<string> {
|
|
143
|
+
const stored = this.localVerifier ?? (await this.read()).codeVerifier;
|
|
144
|
+
if (!stored) throw new Error("No PKCE code verifier is stored for this OAuth flow.");
|
|
145
|
+
return stored;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async clearTokens(): Promise<void> {
|
|
149
|
+
await deleteRecord(this.serverName, this.serverUrl);
|
|
150
|
+
this.localVerifier = undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface LoopbackCallback {
|
|
155
|
+
redirectUri: string;
|
|
156
|
+
waitForCallback: (timeoutMs: number) => Promise<URL>;
|
|
157
|
+
close: () => void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const CALLBACK_PAGE = (message: string) =>
|
|
161
|
+
`<!doctype html><meta charset="utf-8"><title>Pi</title>` +
|
|
162
|
+
`<body style="font-family:system-ui;padding:2rem"><p>${message}</p></body>`;
|
|
163
|
+
|
|
164
|
+
export function startLoopbackCallback(preferredPort = 0): Promise<LoopbackCallback> {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
const server = http.createServer();
|
|
167
|
+
const sockets = new Set<import("node:net").Socket>();
|
|
168
|
+
const waiters: Array<(url: URL) => void> = [];
|
|
169
|
+
let received: URL | undefined;
|
|
170
|
+
|
|
171
|
+
// Keep-alive sockets would otherwise block server.close().
|
|
172
|
+
server.on("connection", (socket) => {
|
|
173
|
+
sockets.add(socket);
|
|
174
|
+
socket.on("close", () => sockets.delete(socket));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.on("request", (req, res) => {
|
|
178
|
+
let url: URL;
|
|
179
|
+
try {
|
|
180
|
+
url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
181
|
+
} catch {
|
|
182
|
+
res.statusCode = 400;
|
|
183
|
+
res.end("Bad request");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (url.pathname !== "/callback") {
|
|
187
|
+
res.statusCode = 404;
|
|
188
|
+
res.end("Not found");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const oauthError = url.searchParams.get("error");
|
|
192
|
+
res.statusCode = 200;
|
|
193
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
194
|
+
res.end(
|
|
195
|
+
CALLBACK_PAGE(
|
|
196
|
+
oauthError
|
|
197
|
+
? `Authorization failed: ${escapeHtml(url.searchParams.get("error_description") ?? oauthError)}`
|
|
198
|
+
: "Authentication complete. You can return to Pi.",
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
received = url;
|
|
202
|
+
for (const waiter of waiters.splice(0)) waiter(url);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
server.once("error", reject);
|
|
206
|
+
server.listen(preferredPort, "127.0.0.1", () => {
|
|
207
|
+
const address = server.address();
|
|
208
|
+
if (!address || typeof address === "string") {
|
|
209
|
+
reject(new Error("Failed to bind the OAuth callback server on 127.0.0.1"));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
resolve({
|
|
213
|
+
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
214
|
+
waitForCallback: (timeoutMs: number) =>
|
|
215
|
+
new Promise<URL>((resWait, rejWait) => {
|
|
216
|
+
if (received) {
|
|
217
|
+
resWait(received);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const timer = setTimeout(
|
|
221
|
+
() => rejWait(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the OAuth callback.`)),
|
|
222
|
+
timeoutMs,
|
|
223
|
+
);
|
|
224
|
+
timer.unref?.();
|
|
225
|
+
waiters.push((url) => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
resWait(url);
|
|
228
|
+
});
|
|
229
|
+
}),
|
|
230
|
+
close: () => {
|
|
231
|
+
for (const socket of sockets) socket.destroy();
|
|
232
|
+
sockets.clear();
|
|
233
|
+
server.close();
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function escapeHtml(value: string): string {
|
|
241
|
+
return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function maybeOpenUrl(url: string): Promise<boolean> {
|
|
245
|
+
try {
|
|
246
|
+
const { execFile } = await import("node:child_process");
|
|
247
|
+
const { promisify } = await import("node:util");
|
|
248
|
+
const execFileAsync = promisify(execFile);
|
|
249
|
+
if (process.platform === "darwin") {
|
|
250
|
+
await execFileAsync("open", [url]);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (process.platform === "win32") {
|
|
254
|
+
await execFileAsync("cmd", ["/c", "start", "", url]);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
await execFileAsync("xdg-open", [url]);
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { errorMessage, PiEssentialsError, toolFailure, toolText } from "../errors.ts";
|
|
5
|
+
import type { McpManager } from "./manager.ts";
|
|
6
|
+
import { renderMcpCall, renderMcpResult } from "./render.ts";
|
|
7
|
+
import type { CachedTool } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Cap on tools echoed back to the model so a large fleet cannot flood context. */
|
|
10
|
+
const MAX_SEARCH_MATCHES = 20;
|
|
11
|
+
const MAX_LIST_TOOLS = 200;
|
|
12
|
+
|
|
13
|
+
const McpParams = Type.Object({
|
|
14
|
+
action: StringEnum(
|
|
15
|
+
[
|
|
16
|
+
"search",
|
|
17
|
+
"describe",
|
|
18
|
+
"call",
|
|
19
|
+
"list",
|
|
20
|
+
"status",
|
|
21
|
+
"enable",
|
|
22
|
+
"disable",
|
|
23
|
+
"auth",
|
|
24
|
+
"auth-start",
|
|
25
|
+
"auth-complete",
|
|
26
|
+
"logout",
|
|
27
|
+
"disconnect",
|
|
28
|
+
] as const,
|
|
29
|
+
{ description: "MCP action to perform" },
|
|
30
|
+
),
|
|
31
|
+
query: Type.Optional(Type.String({ description: "Search query (action=search)" })),
|
|
32
|
+
tool: Type.Optional(Type.String({ description: "Prefixed or original tool name (describe/call)" })),
|
|
33
|
+
args: Type.Optional(Type.Any({ description: "JSON object or JSON string of tool arguments (call)" })),
|
|
34
|
+
server: Type.Optional(
|
|
35
|
+
Type.String({ description: "Server name (enable/disable/auth/auth-start/auth-complete/logout/disconnect)" }),
|
|
36
|
+
),
|
|
37
|
+
redirectUrl: Type.Optional(Type.String({ description: "OAuth callback URL to complete auth (auth or auth-complete)" })),
|
|
38
|
+
code: Type.Optional(
|
|
39
|
+
Type.String({ description: "OAuth authorization code, as an alternative to redirectUrl (action=auth-complete)" }),
|
|
40
|
+
),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function formatTool(tool: CachedTool): string {
|
|
44
|
+
const schema = tool.inputSchema ? `\n Schema: ${JSON.stringify(tool.inputSchema)}` : "";
|
|
45
|
+
return `${tool.prefixedName}\n Server: ${tool.server} (${tool.name})\n ${tool.description || "(no description)"}${schema}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function noToolsMessage(manager: McpManager): string {
|
|
49
|
+
const servers = manager.listServers();
|
|
50
|
+
if (servers.length === 0) {
|
|
51
|
+
return "No MCP servers are configured. Add servers to .mcp.json or ~/.pi/agent/mcp.json.";
|
|
52
|
+
}
|
|
53
|
+
const problems = manager.discoveryProblems();
|
|
54
|
+
const detail = problems.length > 0 ? `\n${problems.join("\n")}` : "";
|
|
55
|
+
return `No MCP tools available.${detail}\nRun mcp({ action: "status" }) or /mcp for details.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerMcpTool(
|
|
59
|
+
pi: ExtensionAPI,
|
|
60
|
+
manager: McpManager,
|
|
61
|
+
onStatusChange?: (ctx: ExtensionContext) => void,
|
|
62
|
+
): void {
|
|
63
|
+
pi.registerTool({
|
|
64
|
+
name: "mcp",
|
|
65
|
+
label: "MCP",
|
|
66
|
+
description:
|
|
67
|
+
"Discover and call MCP server tools without loading every tool schema into context. Search or list tools, describe one, then call it. Use auth (or auth-start/auth-complete) when a server reports it needs OAuth.",
|
|
68
|
+
promptSnippet: "Discover and call MCP tools on demand through a single proxy",
|
|
69
|
+
promptGuidelines: [
|
|
70
|
+
"Use mcp to search/list/describe/call MCP tools instead of assuming a dedicated tool exists for each MCP action.",
|
|
71
|
+
"Call mcp with action=search or action=list before action=call so you use the prefixed tool name.",
|
|
72
|
+
"If mcp reports OAuth is required, call mcp with action=auth for that server before retrying. Prefer auth-start (returns the URL immediately, completes in the background) over auth, which blocks up to 5 minutes.",
|
|
73
|
+
"If a headless session can't reach the automatic callback, finish with action=auth-complete and either redirectUrl (the full pasted callback URL) or code.",
|
|
74
|
+
],
|
|
75
|
+
parameters: McpParams,
|
|
76
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
77
|
+
try {
|
|
78
|
+
switch (params.action) {
|
|
79
|
+
case "status":
|
|
80
|
+
return toolText(manager.formatStatus(), { action: "status", servers: manager.listServers() });
|
|
81
|
+
|
|
82
|
+
case "list": {
|
|
83
|
+
onUpdate?.(toolText("Listing MCP tools..."));
|
|
84
|
+
await manager.ensureAllTools(signal);
|
|
85
|
+
const tools = manager.allCachedTools();
|
|
86
|
+
if (tools.length === 0) return toolText(noToolsMessage(manager), { action: "list", tools: [] });
|
|
87
|
+
const shown = tools.slice(0, MAX_LIST_TOOLS);
|
|
88
|
+
const overflow =
|
|
89
|
+
tools.length > shown.length
|
|
90
|
+
? `\n\n(${tools.length - shown.length} more; use action=search to narrow.)`
|
|
91
|
+
: "";
|
|
92
|
+
return toolText(`${shown.map((t) => `${t.prefixedName} — ${t.description}`).join("\n")}${overflow}`, {
|
|
93
|
+
action: "list",
|
|
94
|
+
tools: shown,
|
|
95
|
+
total: tools.length,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case "search": {
|
|
100
|
+
const query = params.query?.trim();
|
|
101
|
+
if (!query) toolFailure("query is required for search.", "MCP_BAD_ARGS");
|
|
102
|
+
onUpdate?.(toolText(`Searching MCP tools for "${query}"...`));
|
|
103
|
+
await manager.ensureAllTools(signal);
|
|
104
|
+
const matches = manager.searchTools(query);
|
|
105
|
+
if (matches.length === 0) {
|
|
106
|
+
const total = manager.allCachedTools().length;
|
|
107
|
+
return toolText(
|
|
108
|
+
total === 0
|
|
109
|
+
? noToolsMessage(manager)
|
|
110
|
+
: `No MCP tools matched "${query}" out of ${total} available. Try action=list.`,
|
|
111
|
+
{ action: "search", matches: [] },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const shown = matches.slice(0, MAX_SEARCH_MATCHES);
|
|
115
|
+
const overflow = matches.length > shown.length ? `\n\n(${matches.length - shown.length} more matches.)` : "";
|
|
116
|
+
return toolText(`${shown.map(formatTool).join("\n\n")}${overflow}`, {
|
|
117
|
+
action: "search",
|
|
118
|
+
matches: shown,
|
|
119
|
+
total: matches.length,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "describe": {
|
|
124
|
+
const name = params.tool?.trim();
|
|
125
|
+
if (!name) toolFailure("tool is required for describe.", "MCP_BAD_ARGS");
|
|
126
|
+
await manager.ensureAllTools(signal);
|
|
127
|
+
const tool = manager.findTool(name);
|
|
128
|
+
if (!tool) {
|
|
129
|
+
const near = manager.searchTools(name).slice(0, 5);
|
|
130
|
+
const hint = near.length > 0 ? ` Did you mean: ${near.map((t) => t.prefixedName).join(", ")}?` : "";
|
|
131
|
+
toolFailure(`Unknown MCP tool "${name}".${hint}`, "MCP_UNKNOWN_TOOL");
|
|
132
|
+
}
|
|
133
|
+
return toolText(formatTool(tool), { action: "describe", tool });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "call": {
|
|
137
|
+
const name = params.tool?.trim();
|
|
138
|
+
if (!name) toolFailure("tool is required for call.", "MCP_BAD_ARGS");
|
|
139
|
+
onUpdate?.(toolText(`Calling MCP tool ${name}...`));
|
|
140
|
+
if (!manager.findTool(name)) await manager.ensureAllTools(signal);
|
|
141
|
+
const text = await manager.callTool(name, params.args, signal);
|
|
142
|
+
return toolText(text, { action: "call", tool: name });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "enable": {
|
|
146
|
+
const server = params.server?.trim();
|
|
147
|
+
if (!server) toolFailure("server is required for enable.", "MCP_BAD_ARGS");
|
|
148
|
+
await manager.setServerDisabled(server, false);
|
|
149
|
+
return toolText(`Enabled MCP server "${server}".`, { action: "enable", server });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case "disable": {
|
|
153
|
+
const server = params.server?.trim();
|
|
154
|
+
if (!server) toolFailure("server is required for disable.", "MCP_BAD_ARGS");
|
|
155
|
+
await manager.setServerDisabled(server, true);
|
|
156
|
+
return toolText(`Disabled MCP server "${server}".`, { action: "disable", server });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "auth": {
|
|
160
|
+
const server = params.server?.trim();
|
|
161
|
+
if (!server) toolFailure("server is required for auth.", "MCP_BAD_ARGS");
|
|
162
|
+
onUpdate?.(toolText(`Starting OAuth for ${server}...`));
|
|
163
|
+
const text = await manager.auth(server, params.redirectUrl, signal);
|
|
164
|
+
return toolText(text, { action: "auth", server });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case "auth-start": {
|
|
168
|
+
const server = params.server?.trim();
|
|
169
|
+
if (!server) toolFailure("server is required for auth-start.", "MCP_BAD_ARGS");
|
|
170
|
+
const { authorizeUrl, message } = await manager.authStart(server, signal);
|
|
171
|
+
return toolText(message, { action: "auth-start", server, authorizeUrl });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case "auth-complete": {
|
|
175
|
+
const server = params.server?.trim();
|
|
176
|
+
if (!server) toolFailure("server is required for auth-complete.", "MCP_BAD_ARGS");
|
|
177
|
+
if (!params.redirectUrl && !params.code) {
|
|
178
|
+
toolFailure("auth-complete needs redirectUrl or code.", "MCP_BAD_ARGS");
|
|
179
|
+
}
|
|
180
|
+
const text = await manager.authComplete(server, { redirectUrl: params.redirectUrl, code: params.code }, signal);
|
|
181
|
+
return toolText(text, { action: "auth-complete", server });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "logout": {
|
|
185
|
+
const server = params.server?.trim();
|
|
186
|
+
if (!server) toolFailure("server is required for logout.", "MCP_BAD_ARGS");
|
|
187
|
+
const text = await manager.logout(server);
|
|
188
|
+
return toolText(text, { action: "logout", server });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case "disconnect": {
|
|
192
|
+
const server = params.server?.trim() || undefined;
|
|
193
|
+
await manager.disconnect(server);
|
|
194
|
+
return toolText(server ? `Disconnected ${server}.` : "Disconnected all MCP servers.", {
|
|
195
|
+
action: "disconnect",
|
|
196
|
+
server,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
default:
|
|
201
|
+
toolFailure(`Unknown action: ${String(params.action)}`, "MCP_BAD_ARGS");
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const message = error instanceof PiEssentialsError ? error.message : errorMessage(error);
|
|
205
|
+
toolFailure(`${params.action}: ${message}`, error instanceof PiEssentialsError ? error.code : "MCP_ERROR");
|
|
206
|
+
} finally {
|
|
207
|
+
onStatusChange?.(ctx);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
renderCall: renderMcpCall,
|
|
211
|
+
renderResult: renderMcpResult,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
body,
|
|
5
|
+
failLine,
|
|
6
|
+
firstText,
|
|
7
|
+
formatBytes,
|
|
8
|
+
GLYPH,
|
|
9
|
+
meta,
|
|
10
|
+
okLine,
|
|
11
|
+
oneLine,
|
|
12
|
+
safeRender,
|
|
13
|
+
type RenderableResult,
|
|
14
|
+
type RenderSlot,
|
|
15
|
+
} from "../ui/render.ts";
|
|
16
|
+
import type { CachedTool, ServerSnapshot } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
interface McpDetails {
|
|
19
|
+
action?: string;
|
|
20
|
+
tools?: CachedTool[];
|
|
21
|
+
matches?: CachedTool[];
|
|
22
|
+
tool?: CachedTool | string;
|
|
23
|
+
servers?: ServerSnapshot[];
|
|
24
|
+
server?: string;
|
|
25
|
+
total?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface McpArgs {
|
|
29
|
+
action?: string;
|
|
30
|
+
query?: string;
|
|
31
|
+
tool?: string;
|
|
32
|
+
server?: string;
|
|
33
|
+
args?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One-line preview of the arguments a proxied MCP call was given. */
|
|
37
|
+
function previewArgs(value: unknown): string | undefined {
|
|
38
|
+
if (value === undefined || value === null) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
41
|
+
if (!text || text === "{}") return undefined;
|
|
42
|
+
return oneLine(text, 48);
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderMcpCall(args: McpArgs, theme: Theme, context: RenderSlot): Text {
|
|
49
|
+
return safeRender(
|
|
50
|
+
() => {
|
|
51
|
+
const action = args?.action ?? "";
|
|
52
|
+
const subject =
|
|
53
|
+
action === "search"
|
|
54
|
+
? args?.query && `"${args.query}"`
|
|
55
|
+
: action === "call" || action === "describe"
|
|
56
|
+
? args?.tool
|
|
57
|
+
: args?.server;
|
|
58
|
+
let line = theme.fg("toolTitle", theme.bold("mcp"));
|
|
59
|
+
if (action) line += ` ${theme.fg("accent", action)}`;
|
|
60
|
+
if (subject) line += ` ${theme.fg("text", oneLine(String(subject), 52))}`;
|
|
61
|
+
return line + meta(theme, [action === "call" ? previewArgs(args?.args) : undefined]);
|
|
62
|
+
},
|
|
63
|
+
"mcp",
|
|
64
|
+
context,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderMcpResult(
|
|
69
|
+
result: RenderableResult<McpDetails | undefined>,
|
|
70
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
71
|
+
theme: Theme,
|
|
72
|
+
context: RenderSlot,
|
|
73
|
+
): Text {
|
|
74
|
+
return safeRender(
|
|
75
|
+
() => {
|
|
76
|
+
const text = firstText(result);
|
|
77
|
+
if (options.isPartial) return theme.fg("muted", `${GLYPH.sep} ${oneLine(text, 60) || "working…"}`);
|
|
78
|
+
if (context.isError) return failLine(theme, oneLine(text || "mcp call failed", 96));
|
|
79
|
+
|
|
80
|
+
const details = result?.details ?? {};
|
|
81
|
+
|
|
82
|
+
if (details.action === "status" && details.servers) {
|
|
83
|
+
const rows = details.servers.map((server) => statusRow(theme, server));
|
|
84
|
+
const connected = details.servers.filter((s) => s.status === "connected").length;
|
|
85
|
+
return (
|
|
86
|
+
okLine(theme, theme.fg("text", `${details.servers.length} servers`)) +
|
|
87
|
+
meta(theme, [`${connected} connected`]) +
|
|
88
|
+
body(theme, rows, options.expanded, { limit: 6, noun: "server" })
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (details.action === "search" || details.action === "list") {
|
|
93
|
+
const tools = details.matches ?? details.tools ?? [];
|
|
94
|
+
if (tools.length === 0) return `${theme.fg("warning", GLYPH.pending)} ${theme.fg("muted", oneLine(text, 80))}`;
|
|
95
|
+
const rows = tools.map(
|
|
96
|
+
(tool) =>
|
|
97
|
+
`${theme.fg("accent", tool.prefixedName)}${theme.fg("dim", ` ${GLYPH.sep} ${oneLine(tool.description || "no description", 56)}`)}`,
|
|
98
|
+
);
|
|
99
|
+
return (
|
|
100
|
+
okLine(theme, theme.fg("text", `${details.total ?? tools.length} tools`)) +
|
|
101
|
+
body(theme, rows, options.expanded, { limit: 4, noun: "tool" })
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (details.action === "describe") {
|
|
106
|
+
const tool = typeof details.tool === "object" ? details.tool : undefined;
|
|
107
|
+
return (
|
|
108
|
+
okLine(theme, theme.fg("accent", tool?.prefixedName ?? "tool")) +
|
|
109
|
+
meta(theme, [tool?.server]) +
|
|
110
|
+
body(theme, text.split("\n").slice(1).map((line) => theme.fg("toolOutput", oneLine(line, 100))), options.expanded, {
|
|
111
|
+
limit: 2,
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// call / auth / disconnect: show the payload size and a preview.
|
|
117
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
118
|
+
const header =
|
|
119
|
+
okLine(theme, theme.fg("text", typeof details.tool === "string" ? details.tool : (details.action ?? "done"))) +
|
|
120
|
+
meta(theme, [sizeNote(text)]);
|
|
121
|
+
return header + body(theme, lines.map((line) => theme.fg("toolOutput", oneLine(line, 100))), options.expanded, {
|
|
122
|
+
limit: 2,
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
oneLine(firstText(result), 120),
|
|
126
|
+
context,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Payload size is only worth showing once a result is genuinely large. */
|
|
131
|
+
function sizeNote(text: string): string | undefined {
|
|
132
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
133
|
+
return bytes >= 1024 ? formatBytes(bytes) : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function statusRow(theme: Theme, server: ServerSnapshot): string {
|
|
137
|
+
const color =
|
|
138
|
+
server.status === "connected"
|
|
139
|
+
? "success"
|
|
140
|
+
: server.status === "failed"
|
|
141
|
+
? "error"
|
|
142
|
+
: server.status === "needs-auth"
|
|
143
|
+
? "warning"
|
|
144
|
+
: "muted";
|
|
145
|
+
const glyph = server.status === "connected" ? GLYPH.ok : server.status === "failed" ? GLYPH.fail : GLYPH.pending;
|
|
146
|
+
return (
|
|
147
|
+
`${theme.fg(color, glyph)} ${theme.fg("text", server.name)}` +
|
|
148
|
+
theme.fg("dim", ` ${GLYPH.sep} ${server.status} ${GLYPH.sep} ${server.transport} ${GLYPH.sep} ${server.toolCount} tools`)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Footer status: `mcp 2/3` with a warning tint when a server needs attention. */
|
|
153
|
+
export function mcpStatusText(servers: ServerSnapshot[]): string | undefined {
|
|
154
|
+
const active = servers.filter((server) => server.status !== "disabled");
|
|
155
|
+
if (active.length === 0) return undefined;
|
|
156
|
+
const connected = active.filter((server) => server.status === "connected").length;
|
|
157
|
+
const needsAuth = active.filter((server) => server.status === "needs-auth").length;
|
|
158
|
+
const failed = active.filter((server) => server.status === "failed").length;
|
|
159
|
+
|
|
160
|
+
let text = `⚡ mcp ${connected}/${active.length}`;
|
|
161
|
+
if (needsAuth > 0) text += ` ${GLYPH.sep} ${needsAuth} need auth`;
|
|
162
|
+
if (failed > 0) text += ` ${GLYPH.sep} ${failed} failed`;
|
|
163
|
+
return text;
|
|
164
|
+
}
|