@ian-pascoe/pi-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,507 @@
1
+ #!/usr/bin/env node
2
+
3
+ // oxlint-disable anti-slop/no-conditional-empty-object-spread -- Exact optional command data requires omitting absent fields at the shared command boundary.
4
+ // oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters -- This entrypoint owns trust-store parsing before values reach typed settings adapters.
5
+ import { readFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { pathToFileURL } from "node:url";
10
+ import {
11
+ runMcpCommandTokens,
12
+ type McpCommandAdapterResult,
13
+ type McpCommandAdapters,
14
+ type McpCommandJsonValue,
15
+ } from "./mcp-command.js";
16
+ import { McpAuthStore } from "./mcp-auth-store.js";
17
+ import {
18
+ authenticateMcpOAuth,
19
+ DEFAULT_MCP_OAUTH_REDIRECT_URL,
20
+ McpOAuthProvider,
21
+ } from "./mcp-oauth.js";
22
+ import { McpServerClient } from "./mcp-server-client.js";
23
+ import { McpSettingsStore, type McpSettingsScope } from "./mcp-settings-store.js";
24
+ import {
25
+ resolveMcpSettings,
26
+ type McpServerDefinition,
27
+ type ResolvedMcpSettings,
28
+ } from "./pi-mcp-settings.js";
29
+
30
+ const HELP = `Usage: pi-mcp <command> [options]
31
+
32
+ Commands:
33
+ list List effective MCP Server Definitions without connecting
34
+ add Add or replace a Server Definition
35
+ remove Remove a Server Definition
36
+ enable Enable a Server Definition
37
+ disable Disable a Server Definition
38
+ auth Authenticate an OAuth Server Definition
39
+ logout Remove stored OAuth credentials
40
+ test Test one server, or every enabled server with --all
41
+
42
+ Options:
43
+ -h, --help Show this help message
44
+ -a, --approve Trust project settings for this invocation
45
+ -na, --no-approve Ignore project settings for this invocation
46
+ `;
47
+
48
+ /** Terminal and construction effects used by the standalone MCP command adapter. */
49
+ export interface PiMcpCliOptions {
50
+ /** Build persistent, authentication, and temporary-test command adapters. */
51
+ readonly createAdapters?: (projectTrusted?: boolean) => Promise<McpCommandAdapters>;
52
+ /** Write command failures and usage text. */
53
+ readonly writeStderr?: (text: string) => void;
54
+ /** Write successful command output and help text. */
55
+ readonly writeStdout?: (text: string) => void;
56
+ }
57
+
58
+ function commandSuccess(message: string, data?: McpCommandJsonValue): McpCommandAdapterResult {
59
+ return { ...(data === undefined ? {} : { data }), message, ok: true };
60
+ }
61
+
62
+ function commandFailure(
63
+ category: "authentication" | "connection" | "runtime" | "settings",
64
+ message: string,
65
+ ): McpCommandAdapterResult {
66
+ return { category, message, ok: false };
67
+ }
68
+
69
+ async function readSavedProjectTrust(agentDirectory: string, cwd: string): Promise<boolean> {
70
+ let document: unknown;
71
+ try {
72
+ document = JSON.parse(await readFile(join(agentDirectory, "trust.json"), "utf8"));
73
+ } catch (cause) {
74
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return false;
75
+ return false;
76
+ }
77
+ if (document === null || typeof document !== "object" || Array.isArray(document)) return false;
78
+ let path = resolve(cwd);
79
+ while (true) {
80
+ const decision = Object.hasOwn(document, path)
81
+ ? Object.getOwnPropertyDescriptor(document, path)?.value
82
+ : undefined;
83
+ if (decision === true || decision === false) return decision;
84
+ const parent = dirname(path);
85
+ if (parent === path) return false;
86
+ path = parent;
87
+ }
88
+ }
89
+
90
+ async function readPiMcpCliOAuthPaste(signal: AbortSignal): Promise<string> {
91
+ const lineReader = createInterface({ input: process.stdin, terminal: false });
92
+ try {
93
+ return (await lineReader.question("", { signal })).trim();
94
+ } finally {
95
+ lineReader.close();
96
+ }
97
+ }
98
+
99
+ function authClientIdentity(definition: McpServerDefinition): string {
100
+ return definition.transport === "stdio" || definition.auth?.type !== "oauth"
101
+ ? "@ian-pascoe/pi-mcp"
102
+ : (definition.auth.clientId ?? "@ian-pascoe/pi-mcp");
103
+ }
104
+
105
+ interface StandaloneMcpState {
106
+ readonly authStore: McpAuthStore;
107
+ readonly settingsStore: McpSettingsStore;
108
+ }
109
+
110
+ async function readStandaloneSettings(state: StandaloneMcpState): Promise<{
111
+ readonly settings?: ResolvedMcpSettings;
112
+ readonly failure?: McpCommandAdapterResult;
113
+ }> {
114
+ const layers = await state.settingsStore.readLayers();
115
+ if (!layers.ok) return { failure: commandFailure("settings", layers.error.message) };
116
+ const settings = resolveMcpSettings({
117
+ getGlobalSettings: () => layers.value.global.document,
118
+ getProjectSettings: () => layers.value.project?.document ?? {},
119
+ });
120
+ if (!settings.valid) {
121
+ return {
122
+ failure: commandFailure("settings", settings.errors.map((error) => error.message).join("; ")),
123
+ };
124
+ }
125
+ return { settings };
126
+ }
127
+
128
+ async function listStandaloneServers(state: StandaloneMcpState): Promise<McpCommandAdapterResult> {
129
+ const resolved = await readStandaloneSettings(state);
130
+ if (resolved.failure !== undefined) return resolved.failure;
131
+ const settings = resolved.settings;
132
+ if (settings === undefined) return commandFailure("runtime", "settings resolution failed");
133
+ const servers: McpCommandJsonValue[] = [];
134
+ const messages: string[] = [];
135
+ for (const definition of settings.servers.values()) {
136
+ let storedAuth = false;
137
+ if (definition.transport !== "stdio") {
138
+ const stored = await state.authStore.readEntry({
139
+ clientIdentity: authClientIdentity(definition),
140
+ serverUrl: definition.url,
141
+ });
142
+ if (!stored.ok) return commandFailure("authentication", stored.error.message);
143
+ storedAuth = stored.value !== undefined;
144
+ }
145
+ servers.push({
146
+ auth: definition.transport === "stdio" ? "none" : (definition.auth?.type ?? "anonymous"),
147
+ enabled: definition.enabled,
148
+ name: definition.id,
149
+ provenance: definition.provenance,
150
+ storedAuth,
151
+ transport: definition.transport,
152
+ });
153
+ messages.push(
154
+ `${definition.id} (${definition.provenance}, ${definition.enabled ? "enabled" : "disabled"})`,
155
+ );
156
+ }
157
+ for (const mask of settings.masks.values()) {
158
+ servers.push({
159
+ enabled: false,
160
+ inherited: mask.inherited,
161
+ masked: true,
162
+ name: mask.id,
163
+ provenance: mask.provenance,
164
+ });
165
+ messages.push(`${mask.id} (${mask.provenance}, disabled mask)`);
166
+ }
167
+ const message =
168
+ messages.length === 0 ? "No MCP Server Definitions configured" : messages.join("\n");
169
+ return commandSuccess(message, { servers });
170
+ }
171
+
172
+ function settingsMutationResult(
173
+ result: Awaited<ReturnType<McpSettingsStore["removeServerDefinition"]>>,
174
+ action: string,
175
+ ): McpCommandAdapterResult {
176
+ return result.ok
177
+ ? commandSuccess(
178
+ `${action} ${result.value.changed ? "updated" : "unchanged"}: ${result.value.path}`,
179
+ )
180
+ : commandFailure("settings", result.error.message);
181
+ }
182
+
183
+ async function enabledServerDefinitions(
184
+ state: StandaloneMcpState,
185
+ selected: string | undefined,
186
+ ): Promise<
187
+ | { readonly ok: true; readonly definitions: readonly McpServerDefinition[] }
188
+ | { readonly ok: false; readonly failure: McpCommandAdapterResult }
189
+ > {
190
+ const resolved = await readStandaloneSettings(state);
191
+ if (resolved.failure !== undefined) return { failure: resolved.failure, ok: false };
192
+ const settings = resolved.settings;
193
+ if (settings === undefined) {
194
+ return { failure: commandFailure("runtime", "settings resolution failed"), ok: false };
195
+ }
196
+ if (selected === undefined) {
197
+ return {
198
+ definitions: [...settings.servers.values()].filter(({ enabled }) => enabled),
199
+ ok: true,
200
+ };
201
+ }
202
+ const definition = settings.servers.get(selected);
203
+ return definition === undefined
204
+ ? { failure: commandFailure("settings", `unknown MCP Server ${selected}`), ok: false }
205
+ : { definitions: [definition], ok: true };
206
+ }
207
+
208
+ async function testServerDefinition(
209
+ definition: McpServerDefinition,
210
+ settings: ResolvedMcpSettings,
211
+ cwd: string,
212
+ authStore: McpAuthStore,
213
+ ): Promise<McpCommandAdapterResult> {
214
+ let client: McpServerClient | undefined;
215
+ try {
216
+ const oauth =
217
+ definition.transport !== "stdio" && definition.auth?.type === "oauth"
218
+ ? definition.auth
219
+ : undefined;
220
+ const authProvider =
221
+ definition.transport === "stdio" ||
222
+ definition.auth?.type === "none" ||
223
+ definition.auth?.type === "bearer"
224
+ ? undefined
225
+ : new McpOAuthProvider({
226
+ authStore,
227
+ clientIdentity: authClientIdentity(definition),
228
+ ...(oauth?.clientId === undefined ? {} : { clientId: oauth.clientId }),
229
+ ...(oauth?.clientSecret === undefined ? {} : { clientSecret: oauth.clientSecret }),
230
+ onAuthorizationUrl: () => undefined,
231
+ redirectUrl: oauth?.redirectUri ?? DEFAULT_MCP_OAUTH_REDIRECT_URL,
232
+ scopes: oauth?.scopes ?? [],
233
+ serverUrl: definition.url,
234
+ });
235
+ const connectOptions = {
236
+ clientInfo: { name: "pi-mcp", version: "0.1.0" },
237
+ connectTimeoutMs: settings.connectTimeoutMs,
238
+ definition,
239
+ piCwd: cwd,
240
+ requestTimeoutMs: settings.requestTimeoutMs,
241
+ serverId: definition.id,
242
+ };
243
+ client = await McpServerClient.connect(
244
+ authProvider === undefined ? connectOptions : { ...connectOptions, authProvider },
245
+ );
246
+ return commandSuccess(
247
+ `${definition.id}: connected (${client.negotiatedProtocolVersion ?? "unknown protocol"})`,
248
+ {
249
+ connected: true,
250
+ protocolVersion: client.negotiatedProtocolVersion ?? null,
251
+ server: definition.id,
252
+ },
253
+ );
254
+ } catch {
255
+ return commandFailure("connection", `${definition.id}: connection failed`);
256
+ } finally {
257
+ await client?.close();
258
+ }
259
+ }
260
+
261
+ async function removeStandaloneServer(
262
+ state: StandaloneMcpState,
263
+ options: Parameters<McpCommandAdapters["settings"]["remove"]>[0],
264
+ ): Promise<McpCommandAdapterResult> {
265
+ if (options.logout) {
266
+ const resolved = await readStandaloneSettings(state);
267
+ if (resolved.failure !== undefined) return resolved.failure;
268
+ const definition = resolved.settings?.servers.get(options.name);
269
+ if (definition === undefined) {
270
+ return commandFailure("settings", `unknown MCP Server ${options.name}`);
271
+ }
272
+ if (definition.transport !== "stdio") {
273
+ const removed = await state.authStore.removeEntry({
274
+ clientIdentity: authClientIdentity(definition),
275
+ serverUrl: definition.url,
276
+ });
277
+ if (!removed.ok) return commandFailure("authentication", removed.error.message);
278
+ }
279
+ }
280
+ return settingsMutationResult(
281
+ await state.settingsStore.removeServerDefinition(options.scope, options.name),
282
+ `MCP Server ${options.name}`,
283
+ );
284
+ }
285
+
286
+ /** Paths and trust state used to compose standalone-compatible command adapters. */
287
+ export interface StandaloneMcpCommandAdapterOptions {
288
+ /** Pi's global agent directory containing settings and authentication state. */
289
+ readonly agentDirectory?: string;
290
+ /** Working directory that owns optional project MCP settings. */
291
+ readonly cwd?: string;
292
+ /** Whether project-local settings and mutations are trusted for this invocation. */
293
+ readonly projectTrusted?: boolean;
294
+ /** Read a pasted OAuth callback when command flags did not supply one. */
295
+ readonly waitForOAuthPaste?: (signal: AbortSignal) => Promise<string>;
296
+ /** Print the OAuth authorization URL before any browser opener runs. */
297
+ readonly writeAuthorizationUrl?: (url: string) => void | Promise<void>;
298
+ }
299
+
300
+ /** Build plain-Node persistent and test adapters without importing the Pi runtime. */
301
+ export async function createStandaloneMcpCommandAdapters(
302
+ options?: StandaloneMcpCommandAdapterOptions,
303
+ ): Promise<McpCommandAdapters> {
304
+ const agentDirectory =
305
+ options?.agentDirectory ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
306
+ const cwd = options?.cwd ?? process.cwd();
307
+ const projectTrusted =
308
+ options?.projectTrusted ?? (await readSavedProjectTrust(agentDirectory, cwd));
309
+ const state: StandaloneMcpState = {
310
+ authStore: new McpAuthStore(agentDirectory),
311
+ settingsStore: new McpSettingsStore({ agentDirectory, cwd, projectTrusted }),
312
+ };
313
+ const scope = (value: "global" | "project"): McpSettingsScope => value;
314
+ return {
315
+ auth: {
316
+ authenticate: async (command) => {
317
+ const selected = await enabledServerDefinitions(state, command.server);
318
+ if (!selected.ok) return selected.failure;
319
+ const definition = selected.definitions[0];
320
+ if (
321
+ definition === undefined ||
322
+ definition.transport === "stdio" ||
323
+ definition.auth?.type === "none" ||
324
+ definition.auth?.type === "bearer"
325
+ ) {
326
+ return commandFailure(
327
+ "authentication",
328
+ `MCP Server ${command.server} is not configured for OAuth`,
329
+ );
330
+ }
331
+ const suppliedPaste =
332
+ command.callback ??
333
+ (command.code === undefined || command.state === undefined
334
+ ? undefined
335
+ : `${command.code} ${command.state}`);
336
+ const oauth = definition.auth?.type === "oauth" ? definition.auth : undefined;
337
+ const result = await authenticateMcpOAuth({
338
+ authStore: state.authStore,
339
+ clientIdentity: authClientIdentity(definition),
340
+ ...(oauth?.clientId === undefined ? {} : { clientId: oauth.clientId }),
341
+ ...(oauth?.clientSecret === undefined ? {} : { clientSecret: oauth.clientSecret }),
342
+ noOpen: command.noOpen,
343
+ ...(oauth?.redirectUri === undefined ? {} : { redirectUrl: oauth.redirectUri }),
344
+ scopes: oauth?.scopes ?? [],
345
+ serverId: definition.id,
346
+ serverUrl: definition.url,
347
+ waitForPaste:
348
+ suppliedPaste === undefined
349
+ ? (options?.waitForOAuthPaste ?? readPiMcpCliOAuthPaste)
350
+ : async () => suppliedPaste,
351
+ writeAuthorizationUrl:
352
+ options?.writeAuthorizationUrl ?? ((url) => void process.stdout.write(`${url}\n`)),
353
+ });
354
+ return result.ok
355
+ ? commandSuccess(`${command.server}: authenticated`)
356
+ : commandFailure("authentication", result.error.message);
357
+ },
358
+ logout: async (options) => {
359
+ if (options.all && options.force) {
360
+ const reset = await state.authStore.forceReset();
361
+ return reset.ok
362
+ ? commandSuccess("MCP authentication store reset")
363
+ : commandFailure("authentication", reset.error.message);
364
+ }
365
+ const selected = await enabledServerDefinitions(state, options.server);
366
+ if (!selected.ok) return selected.failure;
367
+ const definition = selected.definitions[0];
368
+ if (definition === undefined || definition.transport === "stdio") {
369
+ return commandFailure(
370
+ "authentication",
371
+ `MCP Server ${options.server} has no remote credentials`,
372
+ );
373
+ }
374
+ const removed = await state.authStore.removeEntry({
375
+ clientIdentity: authClientIdentity(definition),
376
+ serverUrl: definition.url,
377
+ });
378
+ return removed.ok
379
+ ? commandSuccess(`${options.server}: logged out`)
380
+ : commandFailure("authentication", removed.error.message);
381
+ },
382
+ },
383
+ live: undefined,
384
+ settings: {
385
+ add: async (options) =>
386
+ settingsMutationResult(
387
+ await state.settingsStore.setServerDefinition(
388
+ scope(options.scope),
389
+ options.name,
390
+ options.definition,
391
+ ),
392
+ `MCP Server ${options.name}`,
393
+ ),
394
+ disable: async (options) => {
395
+ const resolved = await readStandaloneSettings(state);
396
+ if (resolved.failure !== undefined) return resolved.failure;
397
+ const inherited =
398
+ options.scope === "project" &&
399
+ resolved.settings?.servers.get(options.name)?.provenance === "global";
400
+ return settingsMutationResult(
401
+ await state.settingsStore.disableServerDefinition(
402
+ scope(options.scope),
403
+ options.name,
404
+ inherited,
405
+ ),
406
+ `MCP Server ${options.name}`,
407
+ );
408
+ },
409
+ enable: async (options) =>
410
+ settingsMutationResult(
411
+ await state.settingsStore.enableServerDefinition(scope(options.scope), options.name),
412
+ `MCP Server ${options.name}`,
413
+ ),
414
+ list: () => listStandaloneServers(state),
415
+ remove: async (options) => removeStandaloneServer(state, options),
416
+ },
417
+ test: {
418
+ test: async (options) => {
419
+ const resolved = await readStandaloneSettings(state);
420
+ if (resolved.failure !== undefined) return resolved.failure;
421
+ const settings = resolved.settings;
422
+ if (settings === undefined) return commandFailure("runtime", "settings resolution failed");
423
+ const selected = await enabledServerDefinitions(
424
+ state,
425
+ options.all ? undefined : options.server,
426
+ );
427
+ if (!selected.ok) return selected.failure;
428
+ const results: McpCommandJsonValue[] = [];
429
+ const messages: string[] = [];
430
+ for (const definition of selected.definitions) {
431
+ const result = await testServerDefinition(definition, settings, cwd, state.authStore);
432
+ if (!result.ok) return result;
433
+ messages.push(result.message);
434
+ results.push(result.data ?? null);
435
+ }
436
+ return commandSuccess(messages.join("\n") || "No enabled MCP Servers", { results });
437
+ },
438
+ },
439
+ };
440
+ }
441
+
442
+ interface PiMcpTrustOverride {
443
+ readonly args: readonly string[];
444
+ readonly projectTrusted?: boolean;
445
+ }
446
+
447
+ function parsePiMcpTrustOverride(args: readonly string[]): PiMcpTrustOverride | undefined {
448
+ const filtered: string[] = [];
449
+ let projectTrusted: boolean | undefined;
450
+ for (const argument of args) {
451
+ const decision =
452
+ argument === "--approve" || argument === "-a"
453
+ ? true
454
+ : argument === "--no-approve" || argument === "-na"
455
+ ? false
456
+ : undefined;
457
+ if (decision === undefined) {
458
+ filtered.push(argument);
459
+ continue;
460
+ }
461
+ if (projectTrusted !== undefined && projectTrusted !== decision) return undefined;
462
+ projectTrusted = decision;
463
+ }
464
+ return projectTrusted === undefined ? { args: filtered } : { args: filtered, projectTrusted };
465
+ }
466
+
467
+ /** Run the standalone CLI and return a stable process exit code. */
468
+ export async function runPiMcpCli(
469
+ args: readonly string[],
470
+ options: PiMcpCliOptions = {},
471
+ ): Promise<number> {
472
+ const writeStdout = options.writeStdout ?? ((text: string) => process.stdout.write(text));
473
+ const writeStderr = options.writeStderr ?? ((text: string) => process.stderr.write(text));
474
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
475
+ writeStdout(HELP);
476
+ return 0;
477
+ }
478
+ const parsedTrust = parsePiMcpTrustOverride(args);
479
+ if (parsedTrust === undefined) {
480
+ writeStderr("Pi MCP: --approve and --no-approve cannot be combined\n");
481
+ return 2;
482
+ }
483
+ const adapters =
484
+ options.createAdapters === undefined
485
+ ? await createStandaloneMcpCommandAdapters(
486
+ parsedTrust.projectTrusted === undefined
487
+ ? undefined
488
+ : { projectTrusted: parsedTrust.projectTrusted },
489
+ )
490
+ : await options.createAdapters(parsedTrust.projectTrusted);
491
+ const result = await runMcpCommandTokens(parsedTrust.args, "standalone", adapters);
492
+ (result.ok ? writeStdout : writeStderr)(result.output);
493
+ return result.exitCode;
494
+ }
495
+
496
+ const entrypoint = process.argv[1];
497
+ if (entrypoint !== undefined && import.meta.url === pathToFileURL(entrypoint).href) {
498
+ void runPiMcpCli(process.argv.slice(2)).then(
499
+ (exitCode) => {
500
+ process.exitCode = exitCode;
501
+ },
502
+ () => {
503
+ process.stderr.write("Pi MCP: command failed unexpectedly\n");
504
+ process.exitCode = 6;
505
+ },
506
+ );
507
+ }