@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.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@ian-pascoe/pi-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "A complete Model Context Protocol Host for Pi",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "pi",
10
+ "pi-extension",
11
+ "pi-package"
12
+ ],
13
+ "homepage": "https://github.com/ian-pascoe/pi-extensions#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/ian-pascoe/pi-extensions/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Ian Pascoe <ian.g.pascoe@gmail.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/ian-pascoe/pi-extensions.git",
22
+ "directory": "packages/pi-mcp"
23
+ },
24
+ "bin": {
25
+ "pi-mcp": "dist/pi-mcp-cli.js"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "dist",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "type": "module",
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "scripts": {
39
+ "build:cli": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && esbuild src/pi-mcp-cli.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/pi-mcp-cli.js --external:@modelcontextprotocol/client --external:@modelcontextprotocol/client/*",
40
+ "prepack": "pnpm build:cli",
41
+ "test": "vitest run --config ../../vitest.config.ts --root .",
42
+ "typecheck": "tsc --noEmit -p tsconfig.json"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/client": "^2.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@modelcontextprotocol/server": "^2.0.0",
49
+ "esbuild": "0.27.7"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-ai": "*",
53
+ "@earendil-works/pi-coding-agent": "*",
54
+ "typebox": "*"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.19.0"
58
+ },
59
+ "pi": {
60
+ "extensions": [
61
+ "./src/index.ts"
62
+ ]
63
+ }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./pi-mcp-extension.js";
2
+ export { createPiMcpExtension } from "./pi-mcp-extension.js";
@@ -0,0 +1,393 @@
1
+ // oxlint-disable anti-slop/no-conditional-empty-object-spread -- Exact optional properties require omitting absent credential fields from the persisted wire document.
2
+ // oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters -- This file owns the strict authentication JSON parser boundary; every accepted primitive and object field is refined here.
3
+ import { createHash } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { Type, type Static } from "typebox";
7
+ import { Value } from "typebox/value";
8
+ import {
9
+ forceReplaceLockedMcpJsonDocument,
10
+ McpStoreError,
11
+ type McpStoreJsonObject,
12
+ type McpStoreResult,
13
+ mutateLockedMcpJsonDocument,
14
+ } from "./mcp-settings-store.js";
15
+
16
+ const AUTH_DOCUMENT_VERSION = 1;
17
+ const AUTH_FILE_MODE = 0o600;
18
+ const SHA_256_HEX = /^[a-f0-9]{64}$/;
19
+
20
+ const McpAuthJsonValueSchema = Type.Cyclic(
21
+ {
22
+ McpAuthJsonValue: Type.Union([
23
+ Type.Null(),
24
+ Type.Boolean(),
25
+ Type.Number(),
26
+ Type.String(),
27
+ Type.Array(Type.Ref("McpAuthJsonValue")),
28
+ Type.Record(Type.String(), Type.Ref("McpAuthJsonValue"), { additionalProperties: false }),
29
+ ]),
30
+ },
31
+ "McpAuthJsonValue",
32
+ );
33
+ const McpAuthJsonObjectSchema = Type.Unsafe<McpStoreJsonObject>(
34
+ Type.Record(Type.String(), McpAuthJsonValueSchema, { additionalProperties: false }),
35
+ );
36
+ const McpStoredOAuthTokensSchema = Type.Object(
37
+ {
38
+ accessToken: Type.String(),
39
+ expiresAt: Type.Optional(Type.Number()),
40
+ refreshToken: Type.Optional(Type.String()),
41
+ scope: Type.Optional(Type.String()),
42
+ tokenType: Type.String(),
43
+ },
44
+ { additionalProperties: false },
45
+ );
46
+ const McpStoredOAuthClientInformationSchema = Type.Object(
47
+ {
48
+ clientId: Type.String(),
49
+ clientIdIssuedAt: Type.Optional(Type.Number()),
50
+ clientSecret: Type.Optional(Type.String()),
51
+ clientSecretExpiresAt: Type.Optional(Type.Number()),
52
+ metadataDocumentUrl: Type.Optional(Type.String()),
53
+ },
54
+ { additionalProperties: false },
55
+ );
56
+ const McpStoredOAuthAuthorizationStateSchema = Type.Object(
57
+ {
58
+ codeVerifier: Type.Optional(Type.String()),
59
+ state: Type.Optional(Type.String()),
60
+ },
61
+ { additionalProperties: false },
62
+ );
63
+ const McpStoredOAuthDiscoverySchema = Type.Object(
64
+ {
65
+ authorizationServerMetadata: Type.Optional(McpAuthJsonObjectSchema),
66
+ authorizationServerUrl: Type.Optional(Type.String()),
67
+ protectedResourceMetadata: Type.Optional(McpAuthJsonObjectSchema),
68
+ resourceMetadataUrl: Type.Optional(Type.String()),
69
+ },
70
+ { additionalProperties: false },
71
+ );
72
+ const McpAuthEntryProperties = {
73
+ authorization: Type.Optional(McpStoredOAuthAuthorizationStateSchema),
74
+ clientInformation: Type.Optional(McpStoredOAuthClientInformationSchema),
75
+ discovery: Type.Optional(McpStoredOAuthDiscoverySchema),
76
+ tokens: Type.Optional(McpStoredOAuthTokensSchema),
77
+ };
78
+ const McpAuthEntrySchema = Type.Object(McpAuthEntryProperties, { additionalProperties: false });
79
+ const McpAuthEntryPatchSchema = Type.Object(
80
+ {
81
+ authorization: Type.Optional(Type.Union([McpStoredOAuthAuthorizationStateSchema, Type.Null()])),
82
+ clientInformation: Type.Optional(
83
+ Type.Union([McpStoredOAuthClientInformationSchema, Type.Null()]),
84
+ ),
85
+ discovery: Type.Optional(Type.Union([McpStoredOAuthDiscoverySchema, Type.Null()])),
86
+ tokens: Type.Optional(Type.Union([McpStoredOAuthTokensSchema, Type.Null()])),
87
+ },
88
+ { additionalProperties: false },
89
+ );
90
+ const McpAuthStoredEntrySchema = Type.Object(
91
+ {
92
+ ...McpAuthEntryProperties,
93
+ clientIdentityHash: Type.String({ pattern: SHA_256_HEX.source }),
94
+ serverUrlHash: Type.String({ pattern: SHA_256_HEX.source }),
95
+ },
96
+ { additionalProperties: false },
97
+ );
98
+ const McpAuthDocumentSchema = Type.Object(
99
+ {
100
+ entries: Type.Record(Type.String({ pattern: SHA_256_HEX.source }), McpAuthStoredEntrySchema, {
101
+ additionalProperties: false,
102
+ }),
103
+ version: Type.Literal(AUTH_DOCUMENT_VERSION),
104
+ },
105
+ { additionalProperties: false },
106
+ );
107
+
108
+ /** URL and OAuth client identity that jointly own one stored credential entry. */
109
+ export interface McpAuthBinding {
110
+ readonly clientIdentity: string;
111
+ readonly serverUrl: string;
112
+ }
113
+
114
+ /** OAuth tokens persisted exactly across refreshes and process restarts. */
115
+ export type McpStoredOAuthTokens = Readonly<Static<typeof McpStoredOAuthTokensSchema>>;
116
+
117
+ /** OAuth client registration or configured client identity persisted for reuse. */
118
+ export type McpStoredOAuthClientInformation = Readonly<
119
+ Static<typeof McpStoredOAuthClientInformationSchema>
120
+ >;
121
+
122
+ /** PKCE verifier and authorization state persisted between remote callback steps. */
123
+ export type McpStoredOAuthAuthorizationState = Readonly<
124
+ Static<typeof McpStoredOAuthAuthorizationStateSchema>
125
+ >;
126
+
127
+ /** OAuth discovery documents retained without interpreting RFC extension fields. */
128
+ export type McpStoredOAuthDiscovery = Readonly<Static<typeof McpStoredOAuthDiscoverySchema>>;
129
+
130
+ /** Complete authentication data associated with one URL/client binding. */
131
+ export type McpAuthEntry = Readonly<Static<typeof McpAuthEntrySchema>>;
132
+
133
+ /** Top-level authentication fields changed under one locked read-modify-write. */
134
+ export type McpAuthEntryPatch = Readonly<Static<typeof McpAuthEntryPatchSchema>>;
135
+
136
+ type McpAuthStoredEntry = Readonly<Static<typeof McpAuthStoredEntrySchema>>;
137
+ type McpAuthDocument = Readonly<Static<typeof McpAuthDocumentSchema>>;
138
+
139
+ type MutableMcpAuthStoredEntry = {
140
+ -readonly [Field in keyof McpAuthStoredEntry]?: McpAuthStoredEntry[Field];
141
+ } & Pick<McpAuthStoredEntry, "clientIdentityHash" | "serverUrlHash">;
142
+
143
+ function ok<Value>(value: Value): McpStoreResult<Value> {
144
+ return { ok: true, value };
145
+ }
146
+
147
+ function err<Value>(error: McpStoreError): McpStoreResult<Value> {
148
+ return { error, ok: false };
149
+ }
150
+
151
+ function isNodeErrorCode(cause: unknown, code: string): boolean {
152
+ return cause instanceof Error && "code" in cause && cause.code === code;
153
+ }
154
+
155
+ function sha256(value: string): string {
156
+ return createHash("sha256").update(value).digest("hex");
157
+ }
158
+
159
+ function parseMcpAuthBinding(binding: McpAuthBinding):
160
+ | {
161
+ readonly clientIdentityHash: string;
162
+ readonly key: string;
163
+ readonly serverUrlHash: string;
164
+ }
165
+ | undefined {
166
+ if (
167
+ typeof binding !== "object" ||
168
+ binding === null ||
169
+ typeof binding.clientIdentity !== "string" ||
170
+ binding.clientIdentity.length === 0 ||
171
+ typeof binding.serverUrl !== "string"
172
+ ) {
173
+ return undefined;
174
+ }
175
+ let normalizedUrl: string;
176
+ try {
177
+ const url = new URL(binding.serverUrl);
178
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
179
+ normalizedUrl = url.href;
180
+ } catch {
181
+ return undefined;
182
+ }
183
+ const serverUrlHash = sha256(normalizedUrl);
184
+ const clientIdentityHash = sha256(binding.clientIdentity);
185
+ return {
186
+ clientIdentityHash,
187
+ key: sha256(`${serverUrlHash}\0${clientIdentityHash}`),
188
+ serverUrlHash,
189
+ };
190
+ }
191
+
192
+ function parseAuthEntryPatch(value: McpAuthEntryPatch): McpAuthEntryPatch | undefined {
193
+ if (!Value.Check(McpAuthEntryPatchSchema, value)) return undefined;
194
+ try {
195
+ const json: unknown = JSON.parse(JSON.stringify(value));
196
+ return Value.Check(McpAuthEntryPatchSchema, json) ? json : undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ function parseAuthDocument(value: unknown): McpAuthDocument | undefined {
203
+ if (!Value.Check(McpAuthDocumentSchema, value)) return undefined;
204
+ for (const [key, entry] of Object.entries(value.entries)) {
205
+ if (sha256(`${entry.serverUrlHash}\0${entry.clientIdentityHash}`) !== key) return undefined;
206
+ }
207
+ return value;
208
+ }
209
+
210
+ function authDocumentJson(document: McpAuthDocument): McpStoreJsonObject {
211
+ const json: unknown = JSON.parse(JSON.stringify(document));
212
+ if (!Value.Check(McpAuthJsonObjectSchema, json)) {
213
+ throw new Error("Authentication document serialization produced non-JSON data");
214
+ }
215
+ return json;
216
+ }
217
+
218
+ function publicEntry(entry: McpAuthStoredEntry): McpAuthEntry {
219
+ return {
220
+ ...(entry.authorization === undefined
221
+ ? {}
222
+ : { authorization: structuredClone(entry.authorization) }),
223
+ ...(entry.clientInformation === undefined
224
+ ? {}
225
+ : { clientInformation: structuredClone(entry.clientInformation) }),
226
+ ...(entry.discovery === undefined ? {} : { discovery: structuredClone(entry.discovery) }),
227
+ ...(entry.tokens === undefined ? {} : { tokens: structuredClone(entry.tokens) }),
228
+ };
229
+ }
230
+
231
+ function applyPatch(current: McpAuthStoredEntry, patch: McpAuthEntryPatch): McpAuthStoredEntry {
232
+ const next: MutableMcpAuthStoredEntry = {
233
+ ...current,
234
+ clientIdentityHash: current.clientIdentityHash,
235
+ serverUrlHash: current.serverUrlHash,
236
+ };
237
+ if (patch.authorization === null) delete next.authorization;
238
+ if (patch.authorization !== undefined && patch.authorization !== null) {
239
+ next.authorization = { ...current.authorization, ...structuredClone(patch.authorization) };
240
+ }
241
+ if (patch.clientInformation === null) delete next.clientInformation;
242
+ if (patch.clientInformation !== undefined && patch.clientInformation !== null) {
243
+ next.clientInformation = {
244
+ ...current.clientInformation,
245
+ ...structuredClone(patch.clientInformation),
246
+ };
247
+ }
248
+ if (patch.discovery === null) delete next.discovery;
249
+ if (patch.discovery !== undefined && patch.discovery !== null) {
250
+ next.discovery = { ...current.discovery, ...structuredClone(patch.discovery) };
251
+ }
252
+ if (patch.tokens === null) delete next.tokens;
253
+ if (patch.tokens !== undefined && patch.tokens !== null) {
254
+ next.tokens = { ...current.tokens, ...structuredClone(patch.tokens) };
255
+ }
256
+ return next;
257
+ }
258
+
259
+ /** Strict, mode-0600 persistence for OAuth state shared by URL aliases. */
260
+ export class McpAuthStore {
261
+ /** Absolute path to the versioned authentication document. */
262
+ readonly path: string;
263
+
264
+ /** Bind authentication storage to Pi's agent directory. */
265
+ constructor(agentDirectory: string) {
266
+ this.path = join(agentDirectory, "mcp-auth.json");
267
+ }
268
+
269
+ /** Read one credential entry only when its resolved URL and client identity still match. */
270
+ async readEntry(binding: McpAuthBinding): Promise<McpStoreResult<McpAuthEntry | undefined>> {
271
+ const normalized = parseMcpAuthBinding(binding);
272
+ if (normalized === undefined) {
273
+ return err(new McpStoreError("invalid_mutation", "resolve auth binding", this.path));
274
+ }
275
+ let text: string;
276
+ try {
277
+ text = await readFile(this.path, "utf8");
278
+ } catch (cause) {
279
+ if (isNodeErrorCode(cause, "ENOENT")) return ok(undefined);
280
+ return err(new McpStoreError("io_failure", "read auth document", this.path, cause));
281
+ }
282
+ let parsedValue: unknown;
283
+ try {
284
+ parsedValue = JSON.parse(text);
285
+ } catch {
286
+ return err(new McpStoreError("invalid_document", "parse auth document", this.path));
287
+ }
288
+ const document = parseAuthDocument(parsedValue);
289
+ if (document === undefined) {
290
+ return err(new McpStoreError("invalid_document", "parse auth document", this.path));
291
+ }
292
+ const entry = document.entries[normalized.key];
293
+ if (
294
+ entry === undefined ||
295
+ entry.serverUrlHash !== normalized.serverUrlHash ||
296
+ entry.clientIdentityHash !== normalized.clientIdentityHash
297
+ ) {
298
+ return ok(undefined);
299
+ }
300
+ return ok(publicEntry(entry));
301
+ }
302
+
303
+ /** Merge authentication fields under the file lock without losing concurrent refresh data. */
304
+ async updateEntry(
305
+ binding: McpAuthBinding,
306
+ patch: McpAuthEntryPatch,
307
+ ): Promise<McpStoreResult<McpAuthEntry>> {
308
+ const normalized = parseMcpAuthBinding(binding);
309
+ if (normalized === undefined) {
310
+ return err(new McpStoreError("invalid_mutation", "resolve auth binding", this.path));
311
+ }
312
+ const parsedPatch = parseAuthEntryPatch(patch);
313
+ if (parsedPatch === undefined) {
314
+ return err(new McpStoreError("invalid_mutation", "parse auth entry patch", this.path));
315
+ }
316
+ let updated: McpAuthStoredEntry | undefined;
317
+ const mutation = await mutateLockedMcpJsonDocument(
318
+ this.path,
319
+ (current) => {
320
+ const parsed =
321
+ current === undefined
322
+ ? ({ entries: {}, version: AUTH_DOCUMENT_VERSION } satisfies McpAuthDocument)
323
+ : parseAuthDocument(current);
324
+ if (parsed === undefined) throw new Error("Authentication document is malformed");
325
+ const existing = parsed.entries[normalized.key] ?? {
326
+ clientIdentityHash: normalized.clientIdentityHash,
327
+ serverUrlHash: normalized.serverUrlHash,
328
+ };
329
+ updated = applyPatch(existing, parsedPatch);
330
+ return authDocumentJson({
331
+ entries: { ...parsed.entries, [normalized.key]: updated },
332
+ version: AUTH_DOCUMENT_VERSION,
333
+ });
334
+ },
335
+ { forceMode: AUTH_FILE_MODE },
336
+ );
337
+ if (!mutation.ok) {
338
+ if (mutation.error.code === "invalid_mutation") {
339
+ return err(
340
+ new McpStoreError(
341
+ "invalid_document",
342
+ "update auth document",
343
+ this.path,
344
+ mutation.error.cause,
345
+ ),
346
+ );
347
+ }
348
+ return mutation;
349
+ }
350
+ if (updated === undefined) {
351
+ return err(new McpStoreError("invalid_mutation", "update auth document", this.path));
352
+ }
353
+ return ok(publicEntry(updated));
354
+ }
355
+
356
+ /** Remove one URL/client credential entry while preserving every other binding. */
357
+ async removeEntry(
358
+ binding: McpAuthBinding,
359
+ ): Promise<McpStoreResult<{ readonly changed: boolean }>> {
360
+ const normalized = parseMcpAuthBinding(binding);
361
+ if (normalized === undefined) {
362
+ return err(new McpStoreError("invalid_mutation", "resolve auth binding", this.path));
363
+ }
364
+ const mutation = await mutateLockedMcpJsonDocument(
365
+ this.path,
366
+ (current) => {
367
+ if (current === undefined) return undefined;
368
+ const parsed = parseAuthDocument(current);
369
+ if (parsed === undefined) throw new Error("Authentication document is malformed");
370
+ if (!(normalized.key in parsed.entries)) return undefined;
371
+ const entries = { ...parsed.entries };
372
+ delete entries[normalized.key];
373
+ return authDocumentJson({ entries, version: AUTH_DOCUMENT_VERSION });
374
+ },
375
+ { forceMode: AUTH_FILE_MODE },
376
+ );
377
+ if (!mutation.ok && mutation.error.code === "invalid_mutation") {
378
+ return err(
379
+ new McpStoreError("invalid_document", "remove auth entry", this.path, mutation.error.cause),
380
+ );
381
+ }
382
+ return mutation;
383
+ }
384
+
385
+ /** Explicitly replace even malformed authentication bytes with an empty versioned store. */
386
+ forceReset(): Promise<McpStoreResult<void>> {
387
+ return forceReplaceLockedMcpJsonDocument(
388
+ this.path,
389
+ authDocumentJson({ entries: {}, version: AUTH_DOCUMENT_VERSION }),
390
+ { forceMode: AUTH_FILE_MODE },
391
+ );
392
+ }
393
+ }