@fitchmultz/pi-mcp-adapter 6.0.0 → 7.0.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 (105) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -8
  3. package/dist/app-bridge.bundle.js +14 -14
  4. package/dist/config.js +41 -16
  5. package/dist/gateway-arguments.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lifecycle.d.ts +2 -2
  8. package/dist/lifecycle.js +2 -2
  9. package/dist/mcp-auth-flow.js +2 -2
  10. package/dist/mcp-auth.d.ts +1 -1
  11. package/dist/mcp-auth.js +3 -3
  12. package/dist/mcp-code.d.ts +6 -6
  13. package/dist/mcp-code.js +11 -10
  14. package/dist/mcp-oauth-provider.d.ts +5 -5
  15. package/dist/mcp-oauth-provider.js +5 -5
  16. package/dist/mcp-output-guard.d.ts +1 -1
  17. package/dist/mcp-panel.d.ts +2 -2
  18. package/dist/mcp-panel.js +2 -2
  19. package/dist/mcp-setup-panel.d.ts +5 -4
  20. package/dist/mcp-setup-panel.js +15 -7
  21. package/dist/mcp-trace.d.ts +1 -1
  22. package/dist/mcp-trace.js +1 -1
  23. package/dist/npx-resolver.js +8 -4
  24. package/dist/proxy-modes.d.ts +2 -2
  25. package/dist/proxy-modes.js +9 -9
  26. package/dist/sampling-handler.js +1 -0
  27. package/dist/search-ranking.js +6 -2
  28. package/dist/server-manager.d.ts +2 -2
  29. package/dist/server-manager.js +44 -27
  30. package/dist/session-recovery.d.ts +7 -4
  31. package/dist/session-recovery.js +76 -10
  32. package/dist/tool-metadata.d.ts +43 -43
  33. package/dist/tool-result-renderer.d.ts +3 -3
  34. package/dist/tool-result-renderer.js +3 -3
  35. package/dist/ui-resource-handler.d.ts +2 -2
  36. package/dist/ui-resource-handler.js +2 -2
  37. package/dist/ui-server.js +4 -3
  38. package/dist/ui-stream-types.d.ts +8 -8
  39. package/dist/unix-socket-transport.d.ts +1 -1
  40. package/dist/unix-socket-transport.js +1 -1
  41. package/dist/utils.d.ts +1 -0
  42. package/dist/utils.js +14 -0
  43. package/package.json +34 -98
  44. package/abort.ts +0 -34
  45. package/agent-dir.ts +0 -24
  46. package/app-bridge.bundle.js +0 -109
  47. package/banner.png +0 -0
  48. package/checkpoint.ts +0 -55
  49. package/commands.ts +0 -622
  50. package/config.ts +0 -1204
  51. package/consent-manager.ts +0 -25
  52. package/direct-tools.ts +0 -75
  53. package/elicitation-handler.ts +0 -349
  54. package/error-signal.ts +0 -21
  55. package/gateway-arguments.ts +0 -73
  56. package/glimpse-ui.ts +0 -80
  57. package/host-html-template.ts +0 -456
  58. package/index.ts +0 -767
  59. package/init.ts +0 -613
  60. package/legacy-migration.ts +0 -47
  61. package/lifecycle.ts +0 -165
  62. package/logger.ts +0 -127
  63. package/mcp-auth-flow.ts +0 -975
  64. package/mcp-auth.ts +0 -793
  65. package/mcp-callback-server.ts +0 -450
  66. package/mcp-code.ts +0 -412
  67. package/mcp-keyring-helper.cjs +0 -89
  68. package/mcp-oauth-provider.ts +0 -774
  69. package/mcp-output-guard.ts +0 -530
  70. package/mcp-panel.ts +0 -907
  71. package/mcp-script-worker.mjs +0 -131
  72. package/mcp-setup-panel.ts +0 -666
  73. package/mcp-status.ts +0 -110
  74. package/mcp-trace.ts +0 -295
  75. package/metadata-cache.ts +0 -271
  76. package/npx-resolver.ts +0 -488
  77. package/onboarding-state.ts +0 -58
  78. package/panel-keys.ts +0 -37
  79. package/prompts.ts +0 -337
  80. package/proxy-modes.ts +0 -1109
  81. package/resource-tools.ts +0 -52
  82. package/runtime-owner.ts +0 -123
  83. package/sampling-handler.ts +0 -276
  84. package/scripts/build-app-bridge.mjs +0 -15
  85. package/scripts/build.mjs +0 -132
  86. package/scripts/prepare.mjs +0 -103
  87. package/search-ranking.ts +0 -132
  88. package/server-manager.ts +0 -1320
  89. package/session-recovery.ts +0 -316
  90. package/state.ts +0 -64
  91. package/tool-approval.ts +0 -78
  92. package/tool-loader.ts +0 -153
  93. package/tool-metadata.ts +0 -240
  94. package/tool-registrar.ts +0 -74
  95. package/tool-result-renderer.ts +0 -294
  96. package/tsconfig.build.json +0 -13
  97. package/tsconfig.json +0 -16
  98. package/types.ts +0 -785
  99. package/ui-resource-handler.ts +0 -249
  100. package/ui-server.ts +0 -808
  101. package/ui-session.ts +0 -568
  102. package/ui-stream-types.ts +0 -89
  103. package/ui-tool-visibility.ts +0 -25
  104. package/unix-socket-transport.ts +0 -85
  105. package/utils.ts +0 -307
package/mcp-auth.ts DELETED
@@ -1,793 +0,0 @@
1
- /**
2
- * MCP Auth Storage Module
3
- *
4
- * Handles secure storage of OAuth credentials, tokens, client information,
5
- * and legacy PKCE state for MCP servers.
6
- *
7
- * Persistent OAuth entries are stored in the operating system credential store.
8
- * Legacy plaintext entries are imported from $FITCH_MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json
9
- * when set, otherwise <Pi agent dir>/fitch-mcp-adapter/mcp-oauth/sha256-<server-hash>/tokens.json,
10
- * then the plaintext file is removed.
11
- */
12
-
13
- import { spawnSync } from 'child_process';
14
- import { createHash } from 'crypto';
15
- import { createRequire } from 'module';
16
- import { readFileSync, existsSync, rmSync } from 'fs';
17
- import { dirname, join } from 'path';
18
- import { fileURLToPath } from 'url';
19
- import { getAdapterPath, getAgentPath } from './agent-dir.ts';
20
- import { resolveConfiguredOAuthDir } from './config.ts';
21
-
22
- const require = createRequire(import.meta.url);
23
- const AUTH_SECRET_SERVICE = 'fitch-mcp-adapter.oauth';
24
- const LEGACY_AUTH_SECRET_SERVICE = 'pi-mcp-adapter.oauth';
25
- const TEST_AUTH_STORE_ENV = 'PI_MCP_ADAPTER_TEST_AUTH_STORE';
26
- const AUTH_SECRET_CHUNK_SIZE = 1800;
27
- const KEYRING_RECOVERY_DISABLED_ENV = 'PI_MCP_ADAPTER_DISABLE_KEYRING_RECOVERY';
28
- const KEYRING_RECOVERY_KEYCTL_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_KEYCTL';
29
- const KEYRING_RECOVERY_NODE_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_NODE';
30
- const KEYRING_RECOVERY_HELPER_ENV = 'PI_MCP_ADAPTER_KEYRING_RECOVERY_HELPER';
31
- const TEST_LINUX_KEYRING_RECOVERY_ENV = 'PI_MCP_ADAPTER_TEST_LINUX_KEYRING_RECOVERY';
32
- const KEYRING_RECOVERY_TIMEOUT_MS = 10_000;
33
- const AUTH_CHUNK_MANIFEST_KEY = '__piMcpAdapterOAuthChunked';
34
-
35
- /** OAuth token storage format */
36
- export interface StoredTokens {
37
- accessToken: string;
38
- refreshToken?: string;
39
- expiresAt?: number; // Unix timestamp in seconds
40
- scope?: string;
41
- /** SEP-2352 authorization-server issuer binding */
42
- issuer?: string;
43
- }
44
-
45
- /** OAuth client information from dynamic or static registration */
46
- export interface StoredClientInfo {
47
- clientId: string;
48
- clientSecret?: string;
49
- clientIdIssuedAt?: number;
50
- clientSecretExpiresAt?: number;
51
- redirectUris?: string[];
52
- /** Positively identified native CIMD registration; permits loopback port changes. */
53
- registrationType?: "cimd";
54
- /** SEP-2352 authorization-server issuer binding */
55
- issuer?: string;
56
- /**
57
- * True when this entry is a secretless SEP-2352 issuer stub persisted for a
58
- * config-pre-registered client (written by the config-clientId path of
59
- * saveClientInformation). Such a stub is only usable when paired with the
60
- * config that supplies the client secret; it must never be served as
61
- * standalone client information.
62
- */
63
- configPreRegistered?: boolean;
64
- }
65
-
66
- /** Complete auth entry for a server */
67
- export interface AuthEntry {
68
- tokens?: StoredTokens;
69
- clientInfo?: StoredClientInfo;
70
- codeVerifier?: string;
71
- oauthState?: string;
72
- serverUrl?: string; // Track the URL these credentials are for
73
- }
74
-
75
- export interface AuthStorageOptions {
76
- /** Legacy plaintext import directory. Persistent secrets no longer use this as their store. */
77
- baseDir?: string;
78
- }
79
-
80
- export class OAuthCredentialStoreError extends Error {
81
- readonly code = 'OAUTH_CREDENTIAL_STORE_UNAVAILABLE';
82
-
83
- constructor(
84
- message: string,
85
- readonly operation: 'read' | 'write' | 'remove',
86
- cause: unknown,
87
- ) {
88
- super(isAndroidKeyringLoadFailure(cause)
89
- ? `${message}. Android/Termux: @napi-rs/keyring could not load a native binding. Persistent OAuth requires a supported platform with an OS credential store; no plaintext fallback is used.`
90
- : message, { cause });
91
- this.name = 'OAuthCredentialStoreError';
92
- }
93
- }
94
-
95
- export type OAuthCredentialStatus =
96
- | { status: 'present'; entry: AuthEntry }
97
- | { status: 'absent' }
98
- | { status: 'unavailable'; message: string };
99
-
100
- function causeChainContains(error: unknown, pattern: RegExp): boolean {
101
- const seen = new Set<unknown>();
102
- let current = error;
103
- while ((typeof current === 'object' && current !== null) || typeof current === 'function') {
104
- if (seen.has(current)) break;
105
- seen.add(current);
106
- const candidate = current as { name?: unknown; message?: unknown; code?: unknown; cause?: unknown };
107
- if ([candidate.name, candidate.message, candidate.code].some(value => typeof value === 'string' && pattern.test(value))) {
108
- return true;
109
- }
110
- current = candidate.cause;
111
- }
112
- return false;
113
- }
114
-
115
- function isAndroidKeyringLoadFailure(error: unknown): boolean {
116
- return process.platform === 'android' && causeChainContains(error, /^Failed to load @napi-rs\/keyring;/);
117
- }
118
-
119
- export function formatOAuthCredentialStoreUnavailable(error: OAuthCredentialStoreError): string {
120
- if (isAndroidKeyringLoadFailure(error)) return error.message;
121
- if (process.platform === 'linux' && causeChainContains(error, /key\s*(?:has been\s*)?revoked|keyrevoked/i)) {
122
- return 'OAuth credential store unavailable: the Linux session keyring may be revoked. Start Pi from a fresh login/keyring session and retry.';
123
- }
124
- return 'OAuth credential store unavailable. Configure or unlock the OS credential store and retry.';
125
- }
126
-
127
- interface KeyringEntry {
128
- getPassword(): string | null;
129
- setPassword(password: string): void;
130
- deleteCredential(): boolean;
131
- }
132
-
133
- type KeyringEntryConstructor = new (service: string, account: string) => KeyringEntry;
134
- type KeyringModule = { Entry: KeyringEntryConstructor };
135
- type KeyringRequire = ((id: string) => unknown) & { resolve(id: string): string };
136
-
137
- interface AuthSecretStore {
138
- read(account: string): string | undefined;
139
- write(account: string, payload: string): void;
140
- remove(account: string): void;
141
- }
142
-
143
- interface AuthEntryChunkManifest {
144
- [AUTH_CHUNK_MANIFEST_KEY]: 1;
145
- chunkCount: number;
146
- chunkDigest: string;
147
- }
148
-
149
- let KeyringEntryClass: KeyringEntryConstructor | undefined;
150
- const memoryAuthEntries = new Map<string, string>();
151
-
152
- function memoryAuthSecretStore(service: string): AuthSecretStore {
153
- return {
154
- read(account) {
155
- return memoryAuthEntries.get(`${service}\0${account}`);
156
- },
157
- write(account, payload) {
158
- memoryAuthEntries.set(`${service}\0${account}`, payload);
159
- },
160
- remove(account) {
161
- memoryAuthEntries.delete(`${service}\0${account}`);
162
- },
163
- };
164
- }
165
-
166
- function keyringAuthSecretStore(service: string): AuthSecretStore {
167
- return {
168
- read(account) {
169
- return getKeyringEntry(account, service).getPassword() ?? undefined;
170
- },
171
- write(account, payload) {
172
- getKeyringEntry(account, service).setPassword(payload);
173
- },
174
- remove(account) {
175
- getKeyringEntry(account, service).deleteCredential();
176
- },
177
- };
178
- }
179
-
180
- const unavailableAuthSecretStore: AuthSecretStore = {
181
- read() {
182
- throw new Error('simulated secure credential store unavailable');
183
- },
184
- write() {
185
- throw new Error('simulated secure credential store unavailable');
186
- },
187
- remove() {
188
- throw new Error('simulated secure credential store unavailable');
189
- },
190
- };
191
-
192
- function createKeyRevokedTestError(): Error {
193
- return new Error("Couldn't access platform storage: KeyRevoked", { cause: new Error('KeyRevoked') });
194
- }
195
-
196
- const keyRevokedAuthSecretStore: AuthSecretStore = {
197
- read() {
198
- throw createKeyRevokedTestError();
199
- },
200
- write() {
201
- throw createKeyRevokedTestError();
202
- },
203
- remove() {
204
- throw createKeyRevokedTestError();
205
- },
206
- };
207
-
208
- function getAuthSecretStore(service = AUTH_SECRET_SERVICE): AuthSecretStore {
209
- if (process.env[TEST_AUTH_STORE_ENV] === 'memory') return memoryAuthSecretStore(service);
210
- if (process.env[TEST_AUTH_STORE_ENV] === 'unavailable') return unavailableAuthSecretStore;
211
- if (process.env[TEST_AUTH_STORE_ENV] === 'keyrevoked') return keyRevokedAuthSecretStore;
212
- return keyringAuthSecretStore(service);
213
- }
214
-
215
- function getKeyringEntry(account: string, service: string): KeyringEntry {
216
- try {
217
- KeyringEntryClass ??= loadKeyringEntryClass();
218
- return new KeyringEntryClass(service, account);
219
- } catch (error) {
220
- throw new Error('OAuth secure credential storage is unavailable. Configure the OS credential store and retry authentication.', { cause: error });
221
- }
222
- }
223
-
224
- function loadKeyringEntryClass(keyringRequire: KeyringRequire = require, platform: NodeJS.Platform = process.platform, arch: NodeJS.Architecture = process.arch): KeyringEntryConstructor {
225
- try {
226
- return (keyringRequire('@napi-rs/keyring') as KeyringModule).Entry;
227
- } catch (loaderError) {
228
- try {
229
- return loadKeyringNativeBindingFallback(keyringRequire, platform, arch).Entry;
230
- } catch (fallbackError) {
231
- throw new Error(`Failed to load @napi-rs/keyring; absolute-path native binding fallback also failed: ${formatErrorMessage(fallbackError)}`, {
232
- cause: loaderError,
233
- });
234
- }
235
- }
236
- }
237
-
238
- function loadKeyringNativeBindingFallback(keyringRequire: KeyringRequire, platform: NodeJS.Platform, arch: NodeJS.Architecture): KeyringModule {
239
- const targets = getKeyringNativeBindingTargets(platform, arch);
240
- if (targets.length === 0) {
241
- throw new Error(`Unsupported @napi-rs/keyring native binding target: ${platform}-${arch}`);
242
- }
243
-
244
- let lastError: unknown;
245
- for (const target of targets) {
246
- try {
247
- const packageJsonPath = keyringRequire.resolve(`${target.packageName}/package.json`);
248
- return keyringRequire(join(dirname(packageJsonPath), target.bindingFile)) as KeyringModule;
249
- } catch (error) {
250
- lastError = error;
251
- }
252
- }
253
-
254
- throw lastError instanceof Error ? lastError : new Error(String(lastError));
255
- }
256
-
257
- function getKeyringNativeBindingTargets(platform: NodeJS.Platform, arch: NodeJS.Architecture): { packageName: string; bindingFile: string }[] {
258
- return getKeyringNativeBindingSuffixes(platform, arch).map(suffix => ({
259
- packageName: `@napi-rs/keyring-${suffix}`,
260
- bindingFile: `keyring.${suffix}.node`,
261
- }));
262
- }
263
-
264
- function getKeyringNativeBindingSuffixes(platform: NodeJS.Platform, arch: NodeJS.Architecture): string[] {
265
- if (platform === 'darwin') {
266
- if (arch === 'arm64') return ['darwin-arm64'];
267
- if (arch === 'x64') return ['darwin-x64'];
268
- }
269
- if (platform === 'win32') {
270
- if (arch === 'arm64') return ['win32-arm64-msvc'];
271
- if (arch === 'x64') return ['win32-x64-msvc'];
272
- if (arch === 'ia32') return ['win32-ia32-msvc'];
273
- }
274
- if (platform === 'linux') {
275
- if (arch === 'arm64') return ['linux-arm64-gnu', 'linux-arm64-musl'];
276
- if (arch === 'arm') return ['linux-arm-gnueabihf'];
277
- if (arch === 'riscv64') return ['linux-riscv64-gnu'];
278
- if (arch === 'x64') return ['linux-x64-gnu', 'linux-x64-musl'];
279
- }
280
- if (platform === 'freebsd' && arch === 'x64') return ['freebsd-x64'];
281
- return [];
282
- }
283
-
284
- function formatErrorMessage(error: unknown): string {
285
- return error instanceof Error ? error.message : String(error);
286
- }
287
-
288
- type KeyringRecoveryOperation = 'read' | 'write' | 'remove';
289
-
290
- type KeyringRecoveryResponse =
291
- | { ok: true; found?: boolean; value?: string }
292
- | { ok: false; error?: string };
293
-
294
- function isLinuxKeyringRecoveryEnabled(): boolean {
295
- if (process.env[KEYRING_RECOVERY_DISABLED_ENV] === '1') return false;
296
- return process.platform === 'linux' || process.env[TEST_LINUX_KEYRING_RECOVERY_ENV] === '1';
297
- }
298
-
299
- function shouldAttemptLinuxKeyringRecovery(error: unknown): boolean {
300
- return isLinuxKeyringRecoveryEnabled()
301
- && causeChainContains(error, /key\s*(?:has been\s*)?revoked|keyrevoked/i);
302
- }
303
-
304
- function runLinuxKeyringRecoveryOperation(operation: KeyringRecoveryOperation, account: string, payload: string | undefined, service: string): KeyringRecoveryResponse {
305
- const keyctl = process.env[KEYRING_RECOVERY_KEYCTL_ENV]?.trim() || 'keyctl';
306
- const node = process.env[KEYRING_RECOVERY_NODE_ENV]?.trim() || 'node';
307
- const helper = process.env[KEYRING_RECOVERY_HELPER_ENV]?.trim()
308
- || fileURLToPath(new URL('./mcp-keyring-helper.cjs', import.meta.url));
309
- const request = JSON.stringify({ operation, service, account, payload });
310
- const result = spawnSync(keyctl, ['session', '-', node, helper], {
311
- input: `${request}\n`,
312
- encoding: 'utf8',
313
- maxBuffer: 1024 * 1024,
314
- timeout: KEYRING_RECOVERY_TIMEOUT_MS,
315
- windowsHide: true,
316
- });
317
-
318
- if (result.error) {
319
- throw new Error(`Linux keyring recovery helper could not start: ${result.error.message}`, { cause: result.error });
320
- }
321
- if (result.status !== 0) {
322
- throw new Error(`Linux keyring recovery helper failed with exit code ${result.status ?? 'unknown'}`);
323
- }
324
-
325
- let response: unknown;
326
- try {
327
- response = JSON.parse(result.stdout.trim()) as unknown;
328
- } catch (error) {
329
- throw new Error('Linux keyring recovery helper returned invalid JSON', { cause: error });
330
- }
331
- if (typeof response !== 'object' || response === null || typeof (response as { ok?: unknown }).ok !== 'boolean') {
332
- throw new Error('Linux keyring recovery helper returned an invalid response');
333
- }
334
- const typedResponse = response as KeyringRecoveryResponse;
335
- if (typedResponse.ok === false) {
336
- throw new Error(typedResponse.error || 'Linux keyring recovery helper failed');
337
- }
338
- if (operation === 'read' && typedResponse.found === true && typeof typedResponse.value !== 'string') {
339
- throw new Error('Linux keyring recovery helper returned an invalid read response');
340
- }
341
- return typedResponse;
342
- }
343
-
344
- function linuxKeyringRecoveryAuthSecretStore(service = AUTH_SECRET_SERVICE): AuthSecretStore {
345
- return {
346
- read(account) {
347
- const response = runLinuxKeyringRecoveryOperation('read', account, undefined, service);
348
- return response.ok && response.found === true ? response.value : undefined;
349
- },
350
- write(account, payload) {
351
- runLinuxKeyringRecoveryOperation('write', account, payload, service);
352
- },
353
- remove(account) {
354
- runLinuxKeyringRecoveryOperation('remove', account, undefined, service);
355
- },
356
- };
357
- }
358
-
359
- export function getAuthStorageOptions(oauthDir: unknown, cwd = process.cwd()): AuthStorageOptions {
360
- const baseDir = resolveConfiguredOAuthDir(oauthDir, cwd);
361
- return baseDir ? { baseDir: join(baseDir, 'fitch-mcp-adapter') } : {};
362
- }
363
-
364
- export function getAuthBaseDir(options: AuthStorageOptions = {}): string {
365
- const override = process.env.FITCH_MCP_OAUTH_DIR?.trim();
366
- if (override) return override;
367
- return options.baseDir ?? getAdapterPath('mcp-oauth');
368
- }
369
-
370
- /**
371
- * Get the legacy server-specific directory path.
372
- */
373
- function getServerDir(serverName: string, options?: AuthStorageOptions): string {
374
- if (typeof serverName !== 'string') {
375
- throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`);
376
- }
377
- const storageKey = getAuthEntryAccount(serverName);
378
- return join(getAuthBaseDir(options), storageKey);
379
- }
380
-
381
- function getAuthEntryAccount(serverName: string): string {
382
- if (typeof serverName !== 'string') {
383
- throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`);
384
- }
385
- return `sha256-${createHash('sha256').update(serverName, 'utf8').digest('hex')}`;
386
- }
387
-
388
- /**
389
- * Get the legacy plaintext tokens file path for a server.
390
- */
391
- export function getAuthEntryFilePath(serverName: string, options?: AuthStorageOptions): string {
392
- return join(getServerDir(serverName, options), 'tokens.json');
393
- }
394
-
395
- function parseJsonPayload(serverName: string, payload: string, source: string): unknown {
396
- try {
397
- return JSON.parse(payload) as unknown;
398
- } catch (error) {
399
- throw new Error(`Failed to parse OAuth credentials for ${serverName} from ${source}`, { cause: error });
400
- }
401
- }
402
-
403
- function parseAuthEntryPayload(serverName: string, payload: string, source: string): AuthEntry {
404
- return parseJsonPayload(serverName, payload, source) as AuthEntry;
405
- }
406
-
407
- function isAuthEntryChunkManifest(value: unknown): value is AuthEntryChunkManifest {
408
- if (typeof value !== 'object' || value === null) return false;
409
- const manifest = value as Partial<AuthEntryChunkManifest>;
410
- return manifest[AUTH_CHUNK_MANIFEST_KEY] === 1
411
- && typeof manifest.chunkCount === 'number'
412
- && Number.isInteger(manifest.chunkCount)
413
- && manifest.chunkCount > 0
414
- && typeof manifest.chunkDigest === 'string'
415
- && /^[a-f0-9]{16}$/.test(manifest.chunkDigest);
416
- }
417
-
418
- function getAuthEntryChunkAccount(account: string, manifest: AuthEntryChunkManifest, index: number): string {
419
- return `${account}.chunk.${manifest.chunkDigest}.${index}`;
420
- }
421
-
422
- function getAuthEntryChunkAccounts(account: string, manifest: AuthEntryChunkManifest): string[] {
423
- return Array.from({ length: manifest.chunkCount }, (_, index) => getAuthEntryChunkAccount(account, manifest, index));
424
- }
425
-
426
- function readChunkManifestFromPayload(serverName: string, payload: string, source: string): AuthEntryChunkManifest | undefined {
427
- const parsed = parseJsonPayload(serverName, payload, source);
428
- return isAuthEntryChunkManifest(parsed) ? parsed : undefined;
429
- }
430
-
431
- function readExistingChunkManifest(store: AuthSecretStore, serverName: string, account: string): AuthEntryChunkManifest | undefined {
432
- try {
433
- const payload = store.read(account);
434
- return payload === undefined ? undefined : readChunkManifestFromPayload(serverName, payload, 'OS secure credential store');
435
- } catch {
436
- return undefined;
437
- }
438
- }
439
-
440
- function removeChunkPayloads(store: AuthSecretStore, account: string, manifest: AuthEntryChunkManifest): void {
441
- for (const chunkAccount of getAuthEntryChunkAccounts(account, manifest)) {
442
- store.remove(chunkAccount);
443
- }
444
- }
445
-
446
- function tryRemoveChunkPayloads(store: AuthSecretStore, account: string, manifest: AuthEntryChunkManifest | undefined): void {
447
- if (!manifest) return;
448
- try {
449
- removeChunkPayloads(store, account, manifest);
450
- } catch {
451
- // Stale chunk cleanup must not hide a successful credential write.
452
- }
453
- }
454
-
455
- function createChunkManifest(payload: string): AuthEntryChunkManifest {
456
- return {
457
- [AUTH_CHUNK_MANIFEST_KEY]: 1,
458
- chunkCount: Math.ceil(payload.length / AUTH_SECRET_CHUNK_SIZE),
459
- chunkDigest: createHash('sha256').update(payload, 'utf8').digest('hex').slice(0, 16),
460
- };
461
- }
462
-
463
- function readChunkedAuthEntry(store: AuthSecretStore, serverName: string, account: string, manifest: AuthEntryChunkManifest): AuthEntry {
464
- const chunks = getAuthEntryChunkAccounts(account, manifest).map((chunkAccount) => {
465
- try {
466
- const chunk = store.read(chunkAccount);
467
- if (chunk === undefined) {
468
- throw new Error(`Missing OAuth credential chunk ${chunkAccount} for ${serverName}`);
469
- }
470
- return chunk;
471
- } catch (error) {
472
- throw new OAuthCredentialStoreError(
473
- `Failed to read OAuth credentials for ${serverName} from the OS secure credential store`,
474
- 'read',
475
- error,
476
- );
477
- }
478
- });
479
- return parseAuthEntryPayload(serverName, chunks.join(''), 'OS secure credential store chunks');
480
- }
481
-
482
- function readLegacyAuthEntry(serverName: string, options?: AuthStorageOptions): AuthEntry | undefined {
483
- const filePath = getAuthEntryFilePath(serverName, options);
484
- if (!existsSync(filePath)) return undefined;
485
- const data = readFileSync(filePath, 'utf-8');
486
- return parseAuthEntryPayload(serverName, data, filePath);
487
- }
488
-
489
- function removeLegacyAuthEntry(serverName: string, options?: AuthStorageOptions): void {
490
- const filePath = getAuthEntryFilePath(serverName, options);
491
- if (!existsSync(filePath)) return;
492
- try {
493
- rmSync(filePath, { force: true });
494
- } catch (error) {
495
- throw new Error(`Failed to remove legacy plaintext OAuth credentials for ${serverName} at ${filePath}`, { cause: error });
496
- }
497
-
498
- const dir = getServerDir(serverName, options);
499
- try {
500
- rmSync(dir, { recursive: true });
501
- } catch {
502
- // Directory may contain future non-secret metadata; the plaintext file was already removed.
503
- }
504
- }
505
-
506
- function writeSecureAuthEntryToStore(store: AuthSecretStore, serverName: string, entry: AuthEntry): void {
507
- const account = getAuthEntryAccount(serverName);
508
- const payload = JSON.stringify(entry);
509
- const previousManifest = readExistingChunkManifest(store, serverName, account);
510
- const manifest = payload.length > AUTH_SECRET_CHUNK_SIZE ? createChunkManifest(payload) : undefined;
511
-
512
- try {
513
- if (manifest) {
514
- for (let index = 0; index < manifest.chunkCount; index++) {
515
- const chunk = payload.slice(index * AUTH_SECRET_CHUNK_SIZE, (index + 1) * AUTH_SECRET_CHUNK_SIZE);
516
- store.write(getAuthEntryChunkAccount(account, manifest, index), chunk);
517
- }
518
- store.write(account, JSON.stringify(manifest));
519
- } else {
520
- // Compact: multiline secrets corrupt gnome-keyring plaintext (GKeyFile) collections.
521
- store.write(account, payload);
522
- }
523
- if (previousManifest?.chunkDigest !== manifest?.chunkDigest) {
524
- tryRemoveChunkPayloads(store, account, previousManifest);
525
- }
526
- } catch (error) {
527
- tryRemoveChunkPayloads(store, account, manifest);
528
- throw new OAuthCredentialStoreError(
529
- `Failed to write OAuth credentials for ${serverName} to the OS secure credential store`,
530
- 'write',
531
- error,
532
- );
533
- }
534
- }
535
-
536
- function writeSecureAuthEntry(serverName: string, entry: AuthEntry): void {
537
- try {
538
- writeSecureAuthEntryToStore(getAuthSecretStore(), serverName, entry);
539
- } catch (error) {
540
- if (!shouldAttemptLinuxKeyringRecovery(error)) throw error;
541
- writeSecureAuthEntryToStore(linuxKeyringRecoveryAuthSecretStore(), serverName, entry);
542
- }
543
- }
544
-
545
- function readSecureAuthEntryFromStore(store: AuthSecretStore, serverName: string): AuthEntry | undefined {
546
- const account = getAuthEntryAccount(serverName);
547
- let payload: string | undefined;
548
- try {
549
- payload = store.read(account);
550
- } catch (error) {
551
- throw new OAuthCredentialStoreError(
552
- `Failed to read OAuth credentials for ${serverName} from the OS secure credential store`,
553
- 'read',
554
- error,
555
- );
556
- }
557
-
558
- if (payload === undefined) return undefined;
559
- const manifest = readChunkManifestFromPayload(serverName, payload, 'OS secure credential store');
560
- return manifest
561
- ? readChunkedAuthEntry(store, serverName, account, manifest)
562
- : parseAuthEntryPayload(serverName, payload, 'OS secure credential store');
563
- }
564
-
565
- function readSecureAuthEntry(serverName: string, service = AUTH_SECRET_SERVICE): AuthEntry | undefined {
566
- try {
567
- return readSecureAuthEntryFromStore(getAuthSecretStore(service), serverName);
568
- } catch (error) {
569
- if (!shouldAttemptLinuxKeyringRecovery(error)) throw error;
570
- return readSecureAuthEntryFromStore(linuxKeyringRecoveryAuthSecretStore(service), serverName);
571
- }
572
- }
573
-
574
- function readAuthEntry(
575
- serverName: string,
576
- options?: AuthStorageOptions,
577
- behavior: { migrateLegacy?: boolean } = {},
578
- ): AuthEntry | undefined {
579
- const entry = readSecureAuthEntry(serverName);
580
- if (entry !== undefined) {
581
- if (behavior.migrateLegacy !== false) removeLegacyAuthEntry(serverName, options);
582
- return entry;
583
- }
584
- const legacyEntry = readLegacyAuthEntry(serverName, options);
585
- if (!legacyEntry || behavior.migrateLegacy === false) return legacyEntry;
586
- writeSecureAuthEntry(serverName, legacyEntry);
587
- removeLegacyAuthEntry(serverName, options);
588
- return legacyEntry;
589
- }
590
-
591
- /** Explicit copy-only upgrade. Normal reads and logout never access the old service. */
592
- export function migrateLegacyAuthEntry(
593
- serverName: string,
594
- legacyDirectory = process.env.MCP_OAUTH_DIR?.trim() || getAgentPath('mcp-oauth'),
595
- ): 'copied' | 'existing' | 'absent' {
596
- if (readSecureAuthEntry(serverName) !== undefined) return 'existing';
597
- let entry = readSecureAuthEntry(serverName, LEGACY_AUTH_SECRET_SERVICE);
598
- if (entry === undefined) {
599
- const path = join(legacyDirectory, getAuthEntryAccount(serverName), 'tokens.json');
600
- if (!existsSync(path)) return 'absent';
601
- entry = parseAuthEntryPayload(serverName, readFileSync(path, 'utf8'), path);
602
- }
603
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
604
- throw new Error(`Invalid legacy OAuth entry for ${serverName}`);
605
- }
606
- writeSecureAuthEntry(serverName, entry);
607
- if (JSON.stringify(readSecureAuthEntry(serverName)) !== JSON.stringify(entry)) {
608
- throw new Error(`Failed to verify migrated OAuth credentials for ${serverName}`);
609
- }
610
- return 'copied';
611
- }
612
-
613
- /**
614
- * Get auth entry for a server.
615
- */
616
- export function getAuthEntry(serverName: string, options?: AuthStorageOptions): AuthEntry | undefined {
617
- return readAuthEntry(serverName, options);
618
- }
619
-
620
- /**
621
- * Get auth entry and validate it's for the correct URL.
622
- * Returns undefined if URL has changed (credentials are invalid).
623
- */
624
- export function getAuthForUrl(serverName: string, serverUrl: string, options?: AuthStorageOptions): AuthEntry | undefined {
625
- const entry = getAuthEntry(serverName, options);
626
- if (!entry) return undefined;
627
-
628
- // If no serverUrl is stored, this is from an old version - consider it invalid
629
- if (!entry.serverUrl) return undefined;
630
-
631
- // If URL has changed, credentials are invalid
632
- if (entry.serverUrl !== serverUrl) return undefined;
633
-
634
- return entry;
635
- }
636
-
637
- /**
638
- * Inspect credentials for status-only UI paths without treating an unavailable
639
- * secure store as missing credentials. Authentication operations continue to
640
- * use getAuthForUrl() directly and therefore remain fail-closed.
641
- */
642
- export function inspectAuthForUrl(
643
- serverName: string,
644
- serverUrl: string,
645
- options?: AuthStorageOptions,
646
- ): OAuthCredentialStatus {
647
- try {
648
- const entry = readAuthEntry(serverName, options, { migrateLegacy: false });
649
- if (!entry?.serverUrl || entry.serverUrl !== serverUrl) return { status: 'absent' };
650
- return { status: 'present', entry };
651
- } catch (error) {
652
- if (!(error instanceof OAuthCredentialStoreError)) throw error;
653
- return { status: 'unavailable', message: formatOAuthCredentialStoreUnavailable(error) };
654
- }
655
- }
656
-
657
- /**
658
- * Save auth entry for a server.
659
- */
660
- export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string, options?: AuthStorageOptions): void {
661
- // Always update serverUrl if provided
662
- if (serverUrl) {
663
- entry.serverUrl = serverUrl;
664
- }
665
- writeSecureAuthEntry(serverName, entry);
666
- removeLegacyAuthEntry(serverName, options);
667
- }
668
-
669
- /**
670
- * Remove auth entry for a server.
671
- */
672
- function removeAuthEntryFromStore(store: AuthSecretStore, serverName: string): void {
673
- const account = getAuthEntryAccount(serverName);
674
- try {
675
- const payload = store.read(account);
676
- const manifest = payload === undefined ? undefined : readChunkManifestFromPayload(serverName, payload, 'OS secure credential store');
677
- if (manifest) removeChunkPayloads(store, account, manifest);
678
- store.remove(account);
679
- } catch (error) {
680
- throw new OAuthCredentialStoreError(
681
- `Failed to remove OAuth credentials for ${serverName} from the OS secure credential store`,
682
- 'remove',
683
- error,
684
- );
685
- }
686
- }
687
-
688
- export function removeAuthEntry(serverName: string, options?: AuthStorageOptions): void {
689
- try {
690
- removeAuthEntryFromStore(getAuthSecretStore(), serverName);
691
- } catch (error) {
692
- if (!shouldAttemptLinuxKeyringRecovery(error)) throw error;
693
- removeAuthEntryFromStore(linuxKeyringRecoveryAuthSecretStore(), serverName);
694
- }
695
- removeLegacyAuthEntry(serverName, options);
696
- }
697
-
698
- /**
699
- * Update tokens for a server.
700
- */
701
- export function updateTokens(
702
- serverName: string,
703
- tokens: StoredTokens,
704
- serverUrl?: string,
705
- options?: AuthStorageOptions
706
- ): void {
707
- const entry = getAuthEntry(serverName, options) ?? {};
708
- if (serverUrl && entry.serverUrl !== serverUrl) {
709
- delete entry.clientInfo;
710
- delete entry.codeVerifier;
711
- delete entry.oauthState;
712
- }
713
- entry.tokens = tokens;
714
- saveAuthEntry(serverName, entry, serverUrl, options);
715
- }
716
-
717
- /**
718
- * Update client info for a server.
719
- */
720
- export function updateClientInfo(
721
- serverName: string,
722
- clientInfo: StoredClientInfo,
723
- serverUrl?: string,
724
- options?: AuthStorageOptions
725
- ): void {
726
- const entry = getAuthEntry(serverName, options) ?? {};
727
- if (serverUrl && entry.serverUrl !== serverUrl) {
728
- delete entry.tokens;
729
- delete entry.codeVerifier;
730
- delete entry.oauthState;
731
- }
732
- entry.clientInfo = clientInfo;
733
- saveAuthEntry(serverName, entry, serverUrl, options);
734
- }
735
-
736
- /**
737
- * Clear code verifier for a server.
738
- */
739
- export function clearCodeVerifier(serverName: string, options?: AuthStorageOptions): void {
740
- const entry = getAuthEntry(serverName, options);
741
- if (entry) {
742
- delete entry.codeVerifier;
743
- saveAuthEntry(serverName, entry, undefined, options);
744
- }
745
- }
746
-
747
- /**
748
- * Get OAuth state for a server.
749
- */
750
- export function getOAuthState(serverName: string, options?: AuthStorageOptions): string | undefined {
751
- const entry = getAuthEntry(serverName, options);
752
- return entry?.oauthState;
753
- }
754
-
755
- /**
756
- * Clear OAuth state for a server.
757
- */
758
- export function clearOAuthState(serverName: string, options?: AuthStorageOptions): void {
759
- const entry = getAuthEntry(serverName, options);
760
- if (entry) {
761
- delete entry.oauthState;
762
- saveAuthEntry(serverName, entry, undefined, options);
763
- }
764
- }
765
-
766
- /**
767
- * Clear all credentials for a server.
768
- */
769
- export function clearAllCredentials(serverName: string, options?: AuthStorageOptions): void {
770
- removeAuthEntry(serverName, options);
771
- }
772
-
773
- /**
774
- * Clear only client info for a server.
775
- */
776
- export function clearClientInfo(serverName: string, options?: AuthStorageOptions): void {
777
- const entry = getAuthEntry(serverName, options);
778
- if (entry) {
779
- delete entry.clientInfo;
780
- saveAuthEntry(serverName, entry, undefined, options);
781
- }
782
- }
783
-
784
- /**
785
- * Clear only tokens for a server.
786
- */
787
- export function clearTokens(serverName: string, options?: AuthStorageOptions): void {
788
- const entry = getAuthEntry(serverName, options);
789
- if (entry) {
790
- delete entry.tokens;
791
- saveAuthEntry(serverName, entry, undefined, options);
792
- }
793
- }