@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,455 @@
1
+ // oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters -- This file owns the JSON.parse boundary; recursive primitive checks establish the JSON document contract before mutation.
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const LOCK_RETRY_DELAY_MS = 20;
7
+ const LOCK_RETRIES = 99;
8
+ const LOCK_STALE_MS = 10_000;
9
+ const DEFAULT_NEW_FILE_MODE = 0o600;
10
+
11
+ /** A JSON value accepted by the MCP settings and authentication stores. */
12
+ export type McpStoreJsonValue =
13
+ | null
14
+ | boolean
15
+ | number
16
+ | string
17
+ | readonly McpStoreJsonValue[]
18
+ | McpStoreJsonObject;
19
+
20
+ /** A JSON object accepted by the MCP settings and authentication stores. */
21
+ export interface McpStoreJsonObject {
22
+ readonly [key: string]: McpStoreJsonValue;
23
+ }
24
+
25
+ /** Identifies the Pi settings layer changed by a persistent MCP command. */
26
+ export type McpSettingsScope = "global" | "project";
27
+
28
+ /** Expected persistence failure returned without exposing document contents. */
29
+ export class McpStoreError extends Error {
30
+ readonly _tag = "McpStoreError" as const;
31
+
32
+ constructor(
33
+ readonly code:
34
+ | "invalid_document"
35
+ | "invalid_mutation"
36
+ | "io_failure"
37
+ | "lock_timeout"
38
+ | "project_untrusted",
39
+ readonly operation: string,
40
+ readonly path: string,
41
+ override readonly cause?: unknown,
42
+ ) {
43
+ super(`MCP store ${operation} failed (${code})`);
44
+ }
45
+ }
46
+
47
+ /** Explicit result returned by MCP persistence operations. */
48
+ export type McpStoreResult<Value> =
49
+ | { readonly ok: true; readonly value: Value }
50
+ | { readonly ok: false; readonly error: McpStoreError };
51
+
52
+ /** One trust-filtered Pi settings document with its provenance. */
53
+ export interface McpSettingsLayerDocument {
54
+ readonly document: McpStoreJsonObject;
55
+ readonly path: string;
56
+ readonly scope: McpSettingsScope;
57
+ }
58
+
59
+ /** Global and optional trusted-project settings documents. */
60
+ export interface McpSettingsLayers {
61
+ readonly global: McpSettingsLayerDocument;
62
+ readonly project?: McpSettingsLayerDocument;
63
+ }
64
+
65
+ /** Observable outcome of one Server Definition mutation. */
66
+ export interface McpSettingsMutationResult {
67
+ readonly changed: boolean;
68
+ readonly path: string;
69
+ readonly scope: McpSettingsScope;
70
+ }
71
+
72
+ /** Inputs that determine Pi's global/project settings paths and trust gate. */
73
+ export interface McpSettingsStoreOptions {
74
+ readonly agentDirectory: string;
75
+ readonly cwd: string;
76
+ readonly projectTrusted: boolean;
77
+ }
78
+
79
+ interface AtomicJsonMutationOptions {
80
+ readonly fallbackMode?: number;
81
+ readonly forceMode?: number;
82
+ }
83
+
84
+ function ok<Value>(value: Value): McpStoreResult<Value> {
85
+ return { ok: true, value };
86
+ }
87
+
88
+ function err<Value>(error: McpStoreError): McpStoreResult<Value> {
89
+ return { error, ok: false };
90
+ }
91
+
92
+ function isNodeErrorCode(cause: unknown, code: string): boolean {
93
+ return cause instanceof Error && "code" in cause && cause.code === code;
94
+ }
95
+
96
+ function checkMcpStoreJsonValue(value: unknown, ancestors: Set<object>): boolean {
97
+ if (
98
+ value === null ||
99
+ typeof value === "boolean" ||
100
+ typeof value === "string" ||
101
+ (typeof value === "number" && Number.isFinite(value))
102
+ ) {
103
+ return true;
104
+ }
105
+ if (typeof value !== "object" || ancestors.has(value)) return false;
106
+ if (!Array.isArray(value)) {
107
+ const prototype = Object.getPrototypeOf(value);
108
+ if (
109
+ (prototype !== Object.prototype && prototype !== null) ||
110
+ Object.getOwnPropertySymbols(value).length > 0
111
+ ) {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ ancestors.add(value);
117
+ try {
118
+ return (Array.isArray(value) ? value : Object.values(value)).every((item) =>
119
+ checkMcpStoreJsonValue(item, ancestors),
120
+ );
121
+ } catch {
122
+ return false;
123
+ } finally {
124
+ ancestors.delete(value);
125
+ }
126
+ }
127
+
128
+ function isMcpStoreJsonValue(value: unknown): value is McpStoreJsonValue {
129
+ return checkMcpStoreJsonValue(value, new Set());
130
+ }
131
+
132
+ function isMcpStoreJsonObject(value: unknown): value is McpStoreJsonObject {
133
+ return isMcpStoreJsonValue(value) && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ function sleep(milliseconds: number): Promise<void> {
137
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
138
+ }
139
+
140
+ async function acquireMcpStoreLock(lockPath: string): Promise<McpStoreResult<string>> {
141
+ for (let attempt = 0; attempt <= LOCK_RETRIES; attempt += 1) {
142
+ const owner = randomUUID();
143
+ let created = false;
144
+ try {
145
+ const handle = await open(lockPath, "wx", 0o600);
146
+ created = true;
147
+ try {
148
+ await handle.writeFile(owner, "utf8");
149
+ } catch (cause) {
150
+ await rm(lockPath, { force: true }).catch(() => undefined);
151
+ throw cause;
152
+ } finally {
153
+ await handle.close();
154
+ }
155
+ return ok(owner);
156
+ } catch (cause) {
157
+ if (!isNodeErrorCode(cause, "EEXIST")) {
158
+ if (created) await rm(lockPath, { force: true }).catch(() => undefined);
159
+ return err(new McpStoreError("io_failure", "acquire lock", lockPath, cause));
160
+ }
161
+ try {
162
+ const lock = await stat(lockPath);
163
+ if (lock.mtimeMs < Date.now() - LOCK_STALE_MS) {
164
+ await rm(lockPath, { force: true });
165
+ continue;
166
+ }
167
+ } catch (statCause) {
168
+ if (isNodeErrorCode(statCause, "ENOENT")) continue;
169
+ return err(new McpStoreError("io_failure", "inspect lock", lockPath, statCause));
170
+ }
171
+ if (attempt === LOCK_RETRIES) {
172
+ return err(new McpStoreError("lock_timeout", "acquire lock", lockPath, cause));
173
+ }
174
+ await sleep(LOCK_RETRY_DELAY_MS);
175
+ }
176
+ }
177
+ return err(new McpStoreError("lock_timeout", "acquire lock", lockPath));
178
+ }
179
+
180
+ async function releaseMcpStoreLock(lockPath: string, owner: string): Promise<void> {
181
+ try {
182
+ if ((await readFile(lockPath, "utf8")) === owner) await rm(lockPath);
183
+ } catch {
184
+ return;
185
+ }
186
+ }
187
+
188
+ async function readJsonObject(
189
+ path: string,
190
+ operation: string,
191
+ ): Promise<McpStoreResult<McpStoreJsonObject | undefined>> {
192
+ let text: string;
193
+ try {
194
+ text = await readFile(path, "utf8");
195
+ } catch (cause) {
196
+ if (isNodeErrorCode(cause, "ENOENT")) return ok(undefined);
197
+ return err(new McpStoreError("io_failure", operation, path, cause));
198
+ }
199
+
200
+ try {
201
+ const parsed: unknown = JSON.parse(text);
202
+ if (!isMcpStoreJsonObject(parsed)) {
203
+ return err(new McpStoreError("invalid_document", operation, path));
204
+ }
205
+ return ok(parsed);
206
+ } catch {
207
+ return err(new McpStoreError("invalid_document", operation, path));
208
+ }
209
+ }
210
+
211
+ async function writeAtomicJsonObject(
212
+ path: string,
213
+ document: McpStoreJsonObject,
214
+ options: AtomicJsonMutationOptions,
215
+ ): Promise<McpStoreResult<void>> {
216
+ const temporaryPath = join(dirname(path), `.${randomUUID()}.pi-mcp.tmp`);
217
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
218
+ try {
219
+ let mode = options.forceMode;
220
+ if (mode === undefined) {
221
+ try {
222
+ mode = (await stat(path)).mode & 0o777;
223
+ } catch (cause) {
224
+ if (!isNodeErrorCode(cause, "ENOENT")) throw cause;
225
+ mode = options.fallbackMode ?? DEFAULT_NEW_FILE_MODE;
226
+ }
227
+ }
228
+ handle = await open(temporaryPath, "wx", mode);
229
+ await handle.writeFile(`${JSON.stringify(document, undefined, 2)}\n`, "utf8");
230
+ await handle.sync();
231
+ await handle.close();
232
+ handle = undefined;
233
+ await chmod(temporaryPath, mode);
234
+ await rename(temporaryPath, path);
235
+ return ok(undefined);
236
+ } catch (cause) {
237
+ return err(new McpStoreError("io_failure", "write atomic document", path, cause));
238
+ } finally {
239
+ await handle?.close().catch(() => undefined);
240
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Lock, parse, mutate, and atomically replace one JSON document.
246
+ * Returning undefined from `mutate` leaves the original bytes untouched.
247
+ */
248
+ export async function mutateLockedMcpJsonDocument(
249
+ path: string,
250
+ mutate: (current: McpStoreJsonObject | undefined) => McpStoreJsonObject | undefined,
251
+ options: AtomicJsonMutationOptions = {},
252
+ ): Promise<McpStoreResult<{ readonly changed: boolean }>> {
253
+ try {
254
+ await mkdir(dirname(path), { mode: 0o700, recursive: true });
255
+ } catch (cause) {
256
+ return err(new McpStoreError("io_failure", "create parent directory", path, cause));
257
+ }
258
+
259
+ const lockPath = `${path}.pi-mcp.lock`;
260
+ const acquired = await acquireMcpStoreLock(lockPath);
261
+ if (!acquired.ok) return acquired;
262
+
263
+ try {
264
+ const current = await readJsonObject(path, "read document for mutation");
265
+ if (!current.ok) return current;
266
+ let next: McpStoreJsonObject | undefined;
267
+ try {
268
+ next = mutate(current.value);
269
+ } catch {
270
+ return err(new McpStoreError("invalid_mutation", "apply document mutation", path));
271
+ }
272
+ if (next === undefined) return ok({ changed: false });
273
+ if (!isMcpStoreJsonObject(next)) {
274
+ return err(new McpStoreError("invalid_mutation", "apply document mutation", path));
275
+ }
276
+ const written = await writeAtomicJsonObject(path, next, options);
277
+ return written.ok ? ok({ changed: true }) : written;
278
+ } finally {
279
+ await releaseMcpStoreLock(lockPath, acquired.value);
280
+ }
281
+ }
282
+
283
+ /** Replace a malformed or valid JSON document under the same atomic lock. */
284
+ export async function forceReplaceLockedMcpJsonDocument(
285
+ path: string,
286
+ replacement: McpStoreJsonObject,
287
+ options: AtomicJsonMutationOptions = {},
288
+ ): Promise<McpStoreResult<void>> {
289
+ if (!isMcpStoreJsonObject(replacement)) {
290
+ return err(new McpStoreError("invalid_mutation", "replace document", path));
291
+ }
292
+ try {
293
+ await mkdir(dirname(path), { mode: 0o700, recursive: true });
294
+ } catch (cause) {
295
+ return err(new McpStoreError("io_failure", "create parent directory", path, cause));
296
+ }
297
+ const lockPath = `${path}.pi-mcp.lock`;
298
+ const acquired = await acquireMcpStoreLock(lockPath);
299
+ if (!acquired.ok) return acquired;
300
+ try {
301
+ return await writeAtomicJsonObject(path, replacement, options);
302
+ } finally {
303
+ await releaseMcpStoreLock(lockPath, acquired.value);
304
+ }
305
+ }
306
+
307
+ function cloneJsonObject(document: McpStoreJsonObject): McpStoreJsonObject {
308
+ return structuredClone(document);
309
+ }
310
+
311
+ function settingsMcpObject(document: McpStoreJsonObject): McpStoreJsonObject {
312
+ const mcp = document.mcp;
313
+ if (mcp === undefined) return {};
314
+ if (!isMcpStoreJsonObject(mcp)) throw new Error("mcp must be an object");
315
+ return cloneJsonObject(mcp);
316
+ }
317
+
318
+ function settingsServersObject(mcp: McpStoreJsonObject): McpStoreJsonObject {
319
+ const servers = mcp.servers;
320
+ if (servers === undefined) return {};
321
+ if (!isMcpStoreJsonObject(servers)) throw new Error("mcp.servers must be an object");
322
+ return cloneJsonObject(servers);
323
+ }
324
+
325
+ function isDisabledMask(value: McpStoreJsonValue | undefined): boolean {
326
+ return isMcpStoreJsonObject(value) && Object.keys(value).length === 1 && value.enabled === false;
327
+ }
328
+
329
+ /** Owns trust-aware global and project MCP settings mutations. */
330
+ export class McpSettingsStore {
331
+ /** Absolute path to Pi's global settings document. */
332
+ readonly globalSettingsPath: string;
333
+ /** Absolute path to the project's trust-gated settings document. */
334
+ readonly projectSettingsPath: string;
335
+
336
+ /** Bind settings paths and the current project-trust decision. */
337
+ constructor(private readonly options: McpSettingsStoreOptions) {
338
+ this.globalSettingsPath = join(options.agentDirectory, "settings.json");
339
+ this.projectSettingsPath = join(options.cwd, ".pi", "settings.json");
340
+ }
341
+
342
+ /** Read global and trusted-project documents without merging their provenance. */
343
+ async readLayers(): Promise<McpStoreResult<McpSettingsLayers>> {
344
+ const global = await readJsonObject(this.globalSettingsPath, "read global settings");
345
+ if (!global.ok) return global;
346
+ const globalLayer: McpSettingsLayerDocument = {
347
+ document: global.value ?? {},
348
+ path: this.globalSettingsPath,
349
+ scope: "global",
350
+ };
351
+ if (!this.options.projectTrusted) return ok({ global: globalLayer });
352
+
353
+ const project = await readJsonObject(this.projectSettingsPath, "read project settings");
354
+ if (!project.ok) return project;
355
+ return ok({
356
+ global: globalLayer,
357
+ project: {
358
+ document: project.value ?? {},
359
+ path: this.projectSettingsPath,
360
+ scope: "project",
361
+ },
362
+ });
363
+ }
364
+
365
+ /** Add or completely replace one Server Definition in the selected settings layer. */
366
+ setServerDefinition(
367
+ scope: McpSettingsScope,
368
+ serverName: string,
369
+ definition: McpStoreJsonObject,
370
+ ): Promise<McpStoreResult<McpSettingsMutationResult>> {
371
+ return this.mutateServer(scope, serverName, (servers) => {
372
+ servers[serverName] = cloneJsonObject(definition);
373
+ return true;
374
+ });
375
+ }
376
+
377
+ /** Remove one layer-owned Server Definition; a project removal may reveal the global definition. */
378
+ removeServerDefinition(
379
+ scope: McpSettingsScope,
380
+ serverName: string,
381
+ ): Promise<McpStoreResult<McpSettingsMutationResult>> {
382
+ return this.mutateServer(scope, serverName, (servers) => {
383
+ if (!(serverName in servers)) return false;
384
+ delete servers[serverName];
385
+ return true;
386
+ });
387
+ }
388
+
389
+ /** Disable a complete definition, or write a project mask for an inherited definition. */
390
+ disableServerDefinition(
391
+ scope: McpSettingsScope,
392
+ serverName: string,
393
+ inherited: boolean,
394
+ ): Promise<McpStoreResult<McpSettingsMutationResult>> {
395
+ return this.mutateServer(scope, serverName, (servers) => {
396
+ const current = servers[serverName];
397
+ if (current === undefined) {
398
+ if (!inherited || scope !== "project") throw new Error("Server Definition is absent");
399
+ servers[serverName] = { enabled: false };
400
+ return true;
401
+ }
402
+ if (!isMcpStoreJsonObject(current)) throw new Error("Server Definition must be an object");
403
+ if (current.enabled === false) return false;
404
+ servers[serverName] = { ...current, enabled: false };
405
+ return true;
406
+ });
407
+ }
408
+
409
+ /** Enable a complete definition, or remove a project mask to reveal its inherited definition. */
410
+ enableServerDefinition(
411
+ scope: McpSettingsScope,
412
+ serverName: string,
413
+ ): Promise<McpStoreResult<McpSettingsMutationResult>> {
414
+ return this.mutateServer(scope, serverName, (servers) => {
415
+ const current = servers[serverName];
416
+ if (current === undefined) return false;
417
+ if (isDisabledMask(current)) {
418
+ delete servers[serverName];
419
+ return true;
420
+ }
421
+ if (!isMcpStoreJsonObject(current)) throw new Error("Server Definition must be an object");
422
+ if (current.enabled === true) return false;
423
+ servers[serverName] = { ...current, enabled: true };
424
+ return true;
425
+ });
426
+ }
427
+
428
+ private async mutateServer(
429
+ scope: McpSettingsScope,
430
+ serverName: string,
431
+ mutate: (servers: Record<string, McpStoreJsonValue>) => boolean,
432
+ ): Promise<McpStoreResult<McpSettingsMutationResult>> {
433
+ const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
434
+ if (scope === "project" && !this.options.projectTrusted) {
435
+ return err(new McpStoreError("project_untrusted", "mutate project settings", path));
436
+ }
437
+ if (serverName.length === 0) {
438
+ return err(new McpStoreError("invalid_mutation", "mutate Server Definition", path));
439
+ }
440
+
441
+ let operationChanged = false;
442
+ const mutation = await mutateLockedMcpJsonDocument(path, (current) => {
443
+ const document = { ...current };
444
+ const mcp = { ...settingsMcpObject(document) };
445
+ const servers = { ...settingsServersObject(mcp) };
446
+ operationChanged = mutate(servers);
447
+ if (!operationChanged) return undefined;
448
+ mcp.servers = servers;
449
+ document.mcp = mcp;
450
+ return document;
451
+ });
452
+ if (!mutation.ok) return mutation;
453
+ return ok({ changed: operationChanged, path, scope });
454
+ }
455
+ }