@jini-ai/mcp 0.3.4 → 0.4.1

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 (99) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +97 -22
  3. package/dist/agent-install/install.d.ts +20 -4
  4. package/dist/agent-install/install.js +11 -11
  5. package/dist/bin/serve.d.ts +25 -13
  6. package/dist/bin/serve.js +42 -21
  7. package/dist/client/client.d.ts +28 -8
  8. package/dist/client/client.js +25 -21
  9. package/dist/core/config.d.ts +57 -18
  10. package/dist/core/config.js +76 -48
  11. package/dist/core/index.d.ts +1 -2
  12. package/dist/core/index.js +1 -2
  13. package/dist/core/install-info.d.ts +4 -1
  14. package/dist/core/install-info.js +5 -5
  15. package/dist/core/tokens.d.ts +39 -13
  16. package/dist/core/tokens.js +54 -24
  17. package/dist/federation/adapter.http.d.ts +67 -0
  18. package/dist/federation/adapter.http.js +414 -0
  19. package/dist/federation/approvals/call-confirmation.d.ts +23 -0
  20. package/dist/federation/approvals/call-confirmation.js +124 -0
  21. package/dist/federation/approvals/index.d.ts +4 -0
  22. package/dist/federation/approvals/index.js +4 -0
  23. package/dist/federation/approvals/ports.d.ts +107 -0
  24. package/dist/federation/approvals/ports.js +2 -0
  25. package/dist/federation/approvals/revocation.d.ts +61 -0
  26. package/dist/federation/approvals/revocation.js +52 -0
  27. package/dist/federation/bootstrap.d.ts +100 -0
  28. package/dist/federation/bootstrap.js +182 -0
  29. package/dist/federation/config.d.ts +68 -0
  30. package/dist/federation/config.js +40 -0
  31. package/dist/federation/index.d.ts +14 -0
  32. package/dist/federation/index.js +14 -0
  33. package/dist/federation/mcp-protocol.d.ts +115 -0
  34. package/dist/federation/mcp-protocol.js +151 -0
  35. package/dist/federation/messages.d.ts +54 -0
  36. package/dist/federation/messages.js +35 -0
  37. package/dist/federation/ports.d.ts +287 -0
  38. package/dist/federation/ports.js +5 -0
  39. package/dist/federation/presets.d.ts +66 -0
  40. package/dist/federation/presets.js +30 -0
  41. package/dist/federation/refusal-notice.d.ts +198 -0
  42. package/dist/federation/refusal-notice.js +193 -0
  43. package/dist/federation/registrations.d.ts +171 -0
  44. package/dist/federation/registrations.js +181 -0
  45. package/dist/federation/reload.d.ts +112 -0
  46. package/dist/federation/reload.js +110 -0
  47. package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
  48. package/dist/federation/stdio/adapter.stdio.js +526 -0
  49. package/dist/federation/stdio/default-connect.d.ts +29 -0
  50. package/dist/federation/stdio/default-connect.js +92 -0
  51. package/dist/federation/stdio/index.d.ts +4 -0
  52. package/dist/federation/stdio/index.js +4 -0
  53. package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
  54. package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
  55. package/dist/federation/testing/adapter.memory.d.ts +177 -0
  56. package/dist/federation/testing/adapter.memory.js +189 -0
  57. package/dist/federation/testing/index.d.ts +3 -0
  58. package/dist/federation/testing/index.js +3 -0
  59. package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
  60. package/dist/federation/testing/tool-approvals.memory.js +38 -0
  61. package/dist/federation/tool-approvals.d.ts +97 -0
  62. package/dist/federation/tool-approvals.js +42 -0
  63. package/dist/federation/trust.d.ts +471 -0
  64. package/dist/federation/trust.js +707 -0
  65. package/dist/index.d.ts +175 -2
  66. package/dist/index.js +172 -2
  67. package/dist/server/daemon-client.d.ts +16 -4
  68. package/dist/server/daemon-client.js +41 -19
  69. package/dist/server/ports.d.ts +9 -0
  70. package/dist/server/ports.js +2 -0
  71. package/dist/server/resource-protocol.d.ts +14 -4
  72. package/dist/server/resource-protocol.js +6 -6
  73. package/dist/server/resources/active-resource.js +3 -3
  74. package/dist/server/sdk-transport.d.ts +11 -0
  75. package/dist/server/sdk-transport.js +31 -0
  76. package/dist/server/tool-protocol.d.ts +31 -9
  77. package/dist/server/tool-protocol.js +20 -18
  78. package/dist/server/tool-server.d.ts +60 -43
  79. package/dist/server/tool-server.js +51 -27
  80. package/dist/server/tools/component-catalog-tools.js +7 -7
  81. package/dist/server/tools/delegated-tool.d.ts +7 -4
  82. package/dist/server/tools/delegated-tool.js +18 -16
  83. package/dist/server/tools/run-tools.js +15 -15
  84. package/dist/server/tools/tool-catalog-tools.js +7 -7
  85. package/dist/tools/ask-choice/index.d.ts +123 -0
  86. package/dist/tools/ask-choice/index.js +98 -0
  87. package/dist/tools/ask-choice/pending.d.ts +11 -0
  88. package/dist/tools/ask-choice/pending.js +34 -0
  89. package/dist/tools/ask-choice/question.d.ts +16 -0
  90. package/dist/tools/ask-choice/question.js +60 -0
  91. package/dist/tools/ask-choice/schema.d.ts +91 -0
  92. package/dist/tools/ask-choice/schema.js +27 -0
  93. package/dist/tools/ask-choice/types.d.ts +106 -0
  94. package/dist/tools/ask-choice/types.js +2 -0
  95. package/package.json +43 -5
  96. package/dist/core/oauth.d.ts +0 -274
  97. package/dist/core/oauth.js +0 -667
  98. package/dist/core/secure-write.d.ts +0 -17
  99. package/dist/core/secure-write.js +0 -72
@@ -1,3 +1,4 @@
1
+ import { isLoopbackHostname } from '@jini-ai/platform/net';
1
2
  /**
2
3
  * @module @jini-ai/mcp/core/config
3
4
  * Config schema + on-disk store for the external MCP servers the daemon connects
@@ -22,8 +23,37 @@
22
23
  import { readFile } from 'node:fs/promises';
23
24
  import { realpathSync } from 'node:fs';
24
25
  import path from 'node:path';
25
- import { pathContains } from '@jini-ai/platform';
26
- import { writeSecretFileAtomic } from './secure-write.js';
26
+ import { pathContains } from '@jini-ai/core/primitives';
27
+ import { writeFileAtomicAsync } from '@jini-ai/platform/fs';
28
+ /** Shared atomic secret persistence (CR-006 / SEC-RB-002).
29
+ * The previous pattern in each of those stores wrote the temp file with
30
+ * default permissions and either never restricted it (config, OAuth client
31
+ * cache) or `chmod(0600)`'d it *after* the atomic rename and silently
32
+ * continued when the chmod failed (tokens) — both leave a window where the
33
+ * file exists world/group-readable, and the latter makes the "protection"
34
+ * advisory rather than enforced. The platform writer instead:
35
+ *
36
+ * 1. Creates the temp file *exclusively* (`wx`) with mode 0600 supplied to
37
+ * the same `open()`/`writeFile()` call that creates it — POSIX applies
38
+ * the requested mode atomically at creation time, so there is no
39
+ * window where the file exists with a broader mode. Creation-mode enforcement
40
+ * follows the existing prompt-file precedent; the shared writer also reapplies
41
+ * 0600 before checking the mode and flushing the file, never after rename.
42
+ * 2. On POSIX platforms, verifies the on-disk mode actually landed as
43
+ * owner-only before renaming into place, and fails closed — removing
44
+ * the temp file and throwing — if it did not (e.g. an unusual umask or
45
+ * a filesystem that doesn't honor `mode`).
46
+ * 3. Skips that verification on `win32`: Windows has no POSIX permission-
47
+ * bit model (`mode` there only toggles the read-only attribute), so
48
+ * there is nothing meaningful to enforce or fail closed on.
49
+ * 4. Flushes file content before rename and the parent directory after it. A directory
50
+ * flush can fail after replacement; it is reported rather than silently ignored.
51
+ *
52
+ */
53
+ const defaultFilesystem = {
54
+ readText: ({ filePath }) => readFile(filePath, 'utf8'),
55
+ writeSecretText: ({ filePath, contents }) => writeFileAtomicAsync({ filePath, content: contents }, { mode: 0o600, verifyOwnerOnly: true, createParent: true }),
56
+ };
27
57
  const VALID_TRANSPORTS = new Set([
28
58
  'stdio',
29
59
  'sse',
@@ -69,21 +99,8 @@ function sanitizeStringArray(raw) {
69
99
  const out = raw.filter((v) => typeof v === 'string');
70
100
  return out.length > 0 ? out : undefined;
71
101
  }
72
- function normalizeHost(hostname) {
73
- return hostname
74
- .replace(/^\[|\]$/g, '')
75
- .toLowerCase()
76
- .replace(/\.+$/g, '');
77
- }
78
- function isLoopbackHost(hostname) {
79
- const host = normalizeHost(hostname);
80
- if (host === 'localhost' || host === '::1')
81
- return true;
82
- // Note: IPv4-mapped IPv6 loopback (`::ffff:127.0.0.1`) is not special-cased —
83
- // the WHATWG URL parser compresses it to `::ffff:7f00:1` before it reaches
84
- // here, so a dotted-quad match is unreachable through `inferMcpAuthModeForUrl`.
85
- return /^127(?:\.\d{1,3}){3}$/.test(host);
86
- }
102
+ // The shared classifier receives the URL parser's canonical hostname; IPv4-mapped
103
+ // IPv6 is deliberately not a loopback exception for default authentication.
87
104
  /**
88
105
  * Infer the appropriate `McpAuthMode` for a given server URL.
89
106
  * Returns `'none'` for loopback targets (localhost, 127.x.x.x, ::1)
@@ -91,11 +108,11 @@ function isLoopbackHost(hostname) {
91
108
  * @param rawUrl The server URL string to inspect, if any.
92
109
  * @returns The inferred `McpAuthMode`.
93
110
  */
94
- export function inferMcpAuthModeForUrl(rawUrl) {
111
+ export function inferMcpAuthModeForUrl({ rawUrl }) {
95
112
  if (!rawUrl)
96
113
  return 'oauth';
97
114
  try {
98
- return isLoopbackHost(new URL(rawUrl).hostname) ? 'none' : 'oauth';
115
+ return isLoopbackHostname({ hostname: new URL(rawUrl).hostname }) ? 'none' : 'oauth';
99
116
  }
100
117
  catch {
101
118
  return 'oauth';
@@ -110,22 +127,28 @@ function sanitizeMcpAuthMode(raw) {
110
127
  // there is no stdio short-circuit here — the token merge just needs the
111
128
  // effective auth mode for a remote server.
112
129
  function effectiveMcpAuthMode(server) {
113
- return server.authMode ?? inferMcpAuthModeForUrl(server.url);
130
+ return server.authMode ?? inferMcpAuthModeForUrl({ rawUrl: server.url });
114
131
  }
115
132
  /**
116
133
  * Validate a single user-supplied entry. Drops invalid fields so a typo in
117
134
  * one server doesn't tank the whole config. Returns null when the entry is
118
135
  * unsalvageable (no id, or no transport-required fields).
119
136
  */
120
- export function sanitizeMcpServer(raw) {
137
+ export function sanitizeMcpServer({ raw }) {
121
138
  if (!isPlainObject(raw))
122
139
  return null;
123
140
  const id = typeof raw.id === 'string' ? raw.id.trim() : '';
124
141
  if (!SERVER_ID_PATTERN.test(id))
125
142
  return null;
126
- const transport = typeof raw.transport === 'string' ? raw.transport : 'stdio';
143
+ // Absence preserves the legacy stdio default; a supplied invalid discriminator must not
144
+ // silently select a different transport or authentication policy.
145
+ if (raw.transport !== undefined && typeof raw.transport !== 'string')
146
+ return null;
147
+ const transport = raw.transport === undefined ? 'stdio' : raw.transport;
127
148
  if (!VALID_TRANSPORTS.has(transport))
128
149
  return null;
150
+ if (raw.authMode !== undefined && sanitizeMcpAuthMode(raw.authMode) === undefined)
151
+ return null;
129
152
  const next = {
130
153
  id,
131
154
  transport,
@@ -160,7 +183,7 @@ export function sanitizeMcpServer(raw) {
160
183
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
161
184
  return null;
162
185
  next.url = parsed.toString();
163
- next.authMode = sanitizeMcpAuthMode(raw.authMode) ?? inferMcpAuthModeForUrl(next.url);
186
+ next.authMode = sanitizeMcpAuthMode(raw.authMode) ?? inferMcpAuthModeForUrl({ rawUrl: next.url });
164
187
  const headers = sanitizeStringMap(raw.headers);
165
188
  if (headers)
166
189
  next.headers = headers;
@@ -174,14 +197,14 @@ export function sanitizeMcpServer(raw) {
174
197
  * @param raw The raw parsed JSON to sanitize.
175
198
  * @returns A clean `McpConfig` guaranteed to have only valid, unique server entries.
176
199
  */
177
- export function sanitizeMcpConfig(raw) {
200
+ export function sanitizeMcpConfig({ raw }) {
178
201
  if (!isPlainObject(raw))
179
202
  return { servers: [] };
180
203
  const list = Array.isArray(raw.servers) ? raw.servers : [];
181
204
  const seen = new Set();
182
205
  const out = [];
183
206
  for (const entry of list) {
184
- const ok = sanitizeMcpServer(entry);
207
+ const ok = sanitizeMcpServer({ raw: entry });
185
208
  if (!ok)
186
209
  continue;
187
210
  if (seen.has(ok.id))
@@ -197,11 +220,11 @@ export function sanitizeMcpConfig(raw) {
197
220
  * @param dataDir The resolved runtime data directory.
198
221
  * @returns The sanitized `McpConfig`, never rejects on missing-file or corrupt-JSON.
199
222
  */
200
- export async function readMcpConfig(dataDir) {
223
+ export async function readMcpConfig({ dataDir }, { filesystem = defaultFilesystem } = {}) {
201
224
  try {
202
- const raw = await readFile(configFile(dataDir), 'utf8');
225
+ const raw = await filesystem.readText({ filePath: configFile(dataDir) });
203
226
  const parsed = JSON.parse(raw);
204
- return sanitizeMcpConfig(parsed);
227
+ return sanitizeMcpConfig({ raw: parsed });
205
228
  }
206
229
  catch (err) {
207
230
  const e = err;
@@ -222,15 +245,15 @@ const writeLocks = new Map();
222
245
  * being written. This file may carry environment variables and
223
246
  * `Authorization` header values (API keys, bearer tokens — see
224
247
  * `McpServerConfig.env`/`.headers`), so it is written with owner-only
225
- * (0600) permissions from the very first byte via `writeSecretFileAtomic`
248
+ * (0600) permissions from the very first byte via `writeFileAtomicAsync`
226
249
  * rather than a post-rename chmod (CR-006 / SEC-RB-002).
227
250
  * @param dataDir The resolved runtime data directory.
228
251
  * @param body The raw config body to sanitize and persist.
229
252
  * @returns The sanitized `McpConfig` that was written to disk.
230
253
  */
231
- export async function writeMcpConfig(dataDir, body) {
254
+ export async function writeMcpConfig({ dataDir, body }, { filesystem = defaultFilesystem } = {}) {
232
255
  const prev = writeLocks.get(dataDir) ?? Promise.resolve();
233
- const task = prev.catch(() => { }).then(() => doWrite(dataDir, body));
256
+ const task = prev.catch(() => { }).then(() => doWrite(dataDir, body, filesystem));
234
257
  writeLocks.set(dataDir, task);
235
258
  try {
236
259
  return await task;
@@ -240,9 +263,9 @@ export async function writeMcpConfig(dataDir, body) {
240
263
  writeLocks.delete(dataDir);
241
264
  }
242
265
  }
243
- async function doWrite(dataDir, body) {
244
- const next = sanitizeMcpConfig(body);
245
- await writeSecretFileAtomic(configFile(dataDir), JSON.stringify(next, null, 2));
266
+ async function doWrite(dataDir, body, filesystem) {
267
+ const next = sanitizeMcpConfig({ raw: body });
268
+ await filesystem.writeSecretText({ filePath: configFile(dataDir), contents: JSON.stringify(next, null, 2) });
246
269
  return next;
247
270
  }
248
271
  // ───────────────────────────────────────────────────────────────────────
@@ -253,7 +276,7 @@ async function doWrite(dataDir, body) {
253
276
  * falls back to lexical `path.resolve` (still normalizing `..`/`.`
254
277
  * segments) when it does not exist yet, or the platform can't resolve it.
255
278
  */
256
- function resolveRealOrLexical(p) {
279
+ function resolveRealOrLexical({ filePath: p }) {
257
280
  try {
258
281
  return realpathSync.native(p);
259
282
  }
@@ -271,20 +294,20 @@ function resolveRealOrLexical(p) {
271
294
  * Both `cwd` and `projectsDir` are realpath-resolved before the containment
272
295
  * check (falling back to lexical `path.resolve` normalization for a path
273
296
  * that doesn't exist on disk yet), and containment is decided with
274
- * `@jini-ai/platform`'s separator-aware `pathContains` rather than a raw
297
+ * `@jini-ai/core/primitives`' separator-aware `pathContains` rather than a raw
275
298
  * string-prefix check — a `..`-containing path or a symlinked descendant
276
299
  * can defeat a plain `startsWith` (CR-006 / SEC-RB-011).
277
300
  */
278
- export function isManagedProjectCwd(cwd, projectsDir) {
301
+ export function isManagedProjectCwd({ cwd, projectsDir }, { resolvePath = resolveRealOrLexical } = {}) {
279
302
  if (!cwd || typeof cwd !== 'string')
280
303
  return false;
281
304
  if (typeof projectsDir !== 'string' || projectsDir.length === 0)
282
305
  return false;
283
- const resolvedProjectsDir = resolveRealOrLexical(projectsDir);
284
- const resolvedCwd = resolveRealOrLexical(cwd);
306
+ const resolvedProjectsDir = resolvePath({ filePath: projectsDir });
307
+ const resolvedCwd = resolvePath({ filePath: cwd });
285
308
  if (resolvedCwd === resolvedProjectsDir)
286
309
  return false; // projects root, not a project
287
- return pathContains(resolvedProjectsDir, resolvedCwd);
310
+ return pathContains({ root: resolvedProjectsDir, target: resolvedCwd }, { caseSensitive: path.sep !== '\\', separator: path.sep === '\\' ? '\\' : '/' });
288
311
  }
289
312
  /**
290
313
  * Project-cwd `.mcp.json` shape that Claude Code auto-loads on spawn (the
@@ -300,7 +323,7 @@ export function isManagedProjectCwd(cwd, projectsDir) {
300
323
  * pre-authenticated credentials. User-supplied headers always win on
301
324
  * conflict so they can pin a specific token if they really want to.
302
325
  */
303
- export function buildClaudeMcpJson(servers, tokens = {}) {
326
+ export function buildClaudeMcpJson({ servers }, { tokens = {} } = {}) {
304
327
  const enabled = servers.filter((s) => s.enabled);
305
328
  if (enabled.length === 0)
306
329
  return null;
@@ -361,7 +384,7 @@ function mergeAuthHeader(existing, bearer) {
361
384
  * @param servers The full server list from `McpConfig`.
362
385
  * @returns ACP-shaped server entries for all enabled stdio servers.
363
386
  */
364
- export function buildAcpMcpServers(servers) {
387
+ export function buildAcpMcpServers({ servers }) {
365
388
  const enabled = servers.filter((s) => s.enabled && s.transport === 'stdio');
366
389
  const out = [];
367
390
  for (const s of enabled) {
@@ -383,7 +406,12 @@ export function buildAcpMcpServers(servers) {
383
406
  }
384
407
  return out;
385
408
  }
386
- export function buildOpenCodeMcpConfigContent(servers, tokens = {}, options = {}) {
409
+ export function buildOpenCodeMcpConfigContent({ servers }, options = {}) {
410
+ const tokens = options.tokens ?? {};
411
+ const resolvePath = options.resolvePath ?? (({ filePath }) => realpathSync.native(filePath));
412
+ return buildOpenCodeContent(servers, tokens, options, resolvePath);
413
+ }
414
+ function buildOpenCodeContent(servers, tokens, options, resolvePath) {
387
415
  const enabled = servers.filter((s) => s.enabled);
388
416
  const mcp = {};
389
417
  for (const s of enabled) {
@@ -416,7 +444,7 @@ export function buildOpenCodeMcpConfigContent(servers, tokens = {}, options = {}
416
444
  mcp[s.id] = entry;
417
445
  }
418
446
  }
419
- const externalDirectory = buildOpenCodeExternalDirectoryAllowlist(options.allowedDirectories);
447
+ const externalDirectory = buildOpenCodeExternalDirectoryAllowlist(options.allowedDirectories, resolvePath);
420
448
  const extraConfig = options.extraConfig ?? {};
421
449
  if (Object.keys(mcp).length === 0 &&
422
450
  !externalDirectory &&
@@ -436,11 +464,11 @@ export function buildOpenCodeMcpConfigContent(servers, tokens = {}, options = {}
436
464
  }
437
465
  return JSON.stringify(config);
438
466
  }
439
- function buildOpenCodeExternalDirectoryAllowlist(directories) {
467
+ function buildOpenCodeExternalDirectoryAllowlist(directories, resolvePath) {
440
468
  const normalized = Array.from(new Set((directories ?? [])
441
469
  .filter((dir) => typeof dir === 'string' && dir.trim().length > 0)
442
470
  .filter((dir) => path.isAbsolute(dir))
443
- .flatMap((dir) => normalizeAllowedDirectoryVariants(dir))));
471
+ .flatMap((dir) => normalizeAllowedDirectoryVariants(dir, resolvePath))));
444
472
  if (normalized.length === 0)
445
473
  return null;
446
474
  const allowlist = {};
@@ -458,11 +486,11 @@ function normalizeAllowedDirectory(dir) {
458
486
  return root;
459
487
  return resolved.replace(/[\\/]+$/, '');
460
488
  }
461
- function normalizeAllowedDirectoryVariants(dir) {
489
+ function normalizeAllowedDirectoryVariants(dir, resolvePath) {
462
490
  const normalized = normalizeAllowedDirectory(dir);
463
491
  let real = null;
464
492
  try {
465
- real = normalizeAllowedDirectory(realpathSync.native(dir));
493
+ real = normalizeAllowedDirectory(resolvePath({ filePath: dir }));
466
494
  }
467
495
  catch {
468
496
  real = null;
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * @module @jini-ai/mcp/core
3
3
  * Foundation kernel for the MCP domain: the shared config schema and IO
4
- * (`config`), the OAuth 2.1 / PKCE client-credential flow (`oauth`), the
4
+ * (`config`), the
5
5
  * per-server token store (`tokens`), and the install-payload builder
6
6
  * (`install-info`). These are the pure, dependency-light primitives every other
7
7
  * MCP concern (client, agent-install) and the daemon's routes and runtime build
8
8
  * on. `core` imports no sibling subdirectory.
9
9
  */
10
10
  export * from './config.js';
11
- export * from './oauth.js';
12
11
  export * from './tokens.js';
13
12
  export * from './install-info.js';
14
13
  //# sourceMappingURL=index.d.ts.map
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * @module @jini-ai/mcp/core
3
3
  * Foundation kernel for the MCP domain: the shared config schema and IO
4
- * (`config`), the OAuth 2.1 / PKCE client-credential flow (`oauth`), the
4
+ * (`config`), the
5
5
  * per-server token store (`tokens`), and the install-payload builder
6
6
  * (`install-info`). These are the pure, dependency-light primitives every other
7
7
  * MCP concern (client, agent-install) and the daemon's routes and runtime build
8
8
  * on. `core` imports no sibling subdirectory.
9
9
  */
10
10
  export * from './config.js';
11
- export * from './oauth.js';
12
11
  export * from './tokens.js';
13
12
  export * from './install-info.js';
14
13
  //# sourceMappingURL=index.js.map
@@ -32,6 +32,9 @@ export interface BuildMcpInstallPayloadInputs {
32
32
  * propagated into the snippet. The caller decides what's worth
33
33
  * propagating; this builder just merges. */
34
34
  sidecarEnv: Record<string, string>;
35
+ }
36
+ /** Optional launch settings, separated from required runtime facts. */
37
+ export interface BuildMcpInstallPayloadOptions {
35
38
  /** Browser-facing base URL (e.g. `http://127.0.0.1:65321`). Used by MCP
36
39
  * clients to build deep links so the outer agent can suggest a URL. Null
37
40
  * when the daemon was launched without a known web port (headless). */
@@ -65,5 +68,5 @@ export interface McpInstallPayload {
65
68
  * @param inputs Runtime facts collected by the production route or test fixture.
66
69
  * @returns The install payload; `buildHint` is non-null when prerequisites are missing.
67
70
  */
68
- export declare function buildMcpInstallPayload(inputs: BuildMcpInstallPayloadInputs): McpInstallPayload;
71
+ export declare function buildMcpInstallPayload(inputs: BuildMcpInstallPayloadInputs, options?: BuildMcpInstallPayloadOptions): McpInstallPayload;
69
72
  //# sourceMappingURL=install-info.d.ts.map
@@ -25,8 +25,8 @@
25
25
  * @param inputs Runtime facts collected by the production route or test fixture.
26
26
  * @returns The install payload; `buildHint` is non-null when prerequisites are missing.
27
27
  */
28
- export function buildMcpInstallPayload(inputs) {
29
- const subcommand = inputs.subcommand ?? 'mcp';
28
+ export function buildMcpInstallPayload(inputs, options = {}) {
29
+ const subcommand = options.subcommand ?? 'mcp';
30
30
  const hints = [];
31
31
  if (!inputs.cliExists) {
32
32
  hints.push(`CLI entry is missing at ${inputs.cliPath}. Rebuild the daemon or packaged app and refresh.`);
@@ -41,8 +41,8 @@ export function buildMcpInstallPayload(inputs) {
41
41
  // per-cwd data dir which for packaged installs can be a read-only app
42
42
  // bundle that trips EPERM.
43
43
  const env = {
44
- [inputs.dataDirEnvVar]: inputs.dataDir,
45
44
  ...inputs.sidecarEnv,
45
+ [inputs.dataDirEnvVar]: inputs.dataDir,
46
46
  };
47
47
  if (inputs.electronAsNode) {
48
48
  env.ELECTRON_RUN_AS_NODE = '1';
@@ -64,8 +64,8 @@ export function buildMcpInstallPayload(inputs) {
64
64
  args,
65
65
  env,
66
66
  daemonUrl: `http://127.0.0.1:${inputs.port}`,
67
- webBaseUrl: typeof inputs.webBaseUrl === 'string' && inputs.webBaseUrl.length > 0
68
- ? inputs.webBaseUrl
67
+ webBaseUrl: typeof options.webBaseUrl === 'string' && options.webBaseUrl.length > 0
68
+ ? options.webBaseUrl
69
69
  : null,
70
70
  // Surface platform so the install panel can localize path hints
71
71
  // (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts
@@ -1,9 +1,5 @@
1
- /**
2
- * @module @jini-ai/mcp/core/tokens
3
- * Persistent, 0600-guarded OAuth-token store for remote MCP servers, keyed by
4
- * server id, with expiry checks. The durable counterpart to `oauth`'s live flow.
5
- * Part of the MCP `core` kernel; depends on no sibling subdirectory.
6
- */
1
+ import { type Clock } from "@jini-ai/core/primitives";
2
+ import type { McpConfigFilesystemPort } from './config.js';
7
3
  /**
8
4
  * Stored OAuth token for a single MCP server. Mirrors the relevant subset
9
5
  * of an OAuth 2.0 token-endpoint response (RFC 6749 §5.1), plus the OAuth
@@ -44,29 +40,59 @@ export interface McpTokensFile {
44
40
  /** keyed by McpServerConfig.id */
45
41
  servers: Record<string, StoredMcpToken>;
46
42
  }
43
+ /** Optional clock used when imported tokens have no saved timestamp. */
44
+ export interface McpTokenClockOptions {
45
+ clock?: Clock;
46
+ }
47
+ /** Injectable store boundaries; defaults use the Node filesystem and wall clock. */
48
+ export interface McpTokenStoreOptions extends McpTokenClockOptions {
49
+ filesystem?: McpConfigFilesystemPort;
50
+ }
47
51
  /** Coerce a freeform JSON blob into the typed shape, dropping anything that
48
52
  * doesn't deserialize cleanly. Used both at read time and as a defensive
49
53
  * pass when third-party tooling has hand-edited the file. */
50
- export declare function sanitizeTokensFile(raw: unknown): McpTokensFile;
54
+ export declare function sanitizeTokensFile({ raw }: {
55
+ raw: unknown;
56
+ }, { clock }?: McpTokenClockOptions): McpTokensFile;
51
57
  /**
52
58
  * Read and sanitize the MCP token store from `<dataDir>/mcp-tokens.json`.
53
59
  * Returns an empty store when the file does not exist or contains invalid JSON.
54
60
  * @param dataDir The resolved runtime data directory.
55
61
  * @returns The sanitized token file, never rejects on missing-file or corrupt-JSON.
56
62
  */
57
- export declare function readTokensFile(dataDir: string): Promise<McpTokensFile>;
63
+ export declare function readTokensFile({ dataDir }: {
64
+ dataDir: string;
65
+ }, options?: McpTokenStoreOptions): Promise<McpTokensFile>;
58
66
  /** Get the current token for a given server, or null when none is stored
59
67
  * (or the persisted entry is malformed). */
60
- export declare function getToken(dataDir: string, serverId: string): Promise<StoredMcpToken | null>;
68
+ export declare function getToken({ dataDir, serverId }: {
69
+ dataDir: string;
70
+ serverId: string;
71
+ }, options?: McpTokenStoreOptions): Promise<StoredMcpToken | null>;
61
72
  /** Atomically merge a new token for `serverId` into the tokens file. */
62
- export declare function setToken(dataDir: string, serverId: string, token: StoredMcpToken): Promise<void>;
73
+ export declare function setToken({ dataDir, serverId, token }: {
74
+ dataDir: string;
75
+ serverId: string;
76
+ token: StoredMcpToken;
77
+ }, options?: McpTokenStoreOptions): Promise<void>;
63
78
  /** Atomically delete the stored token for `serverId`. No-op when absent. */
64
- export declare function clearToken(dataDir: string, serverId: string): Promise<void>;
79
+ export declare function clearToken({ dataDir, serverId }: {
80
+ dataDir: string;
81
+ serverId: string;
82
+ }, options?: McpTokenStoreOptions): Promise<void>;
65
83
  /** Bulk read used by the spawn pipeline so we make one disk hit per spawn,
66
84
  * not one per server. */
67
- export declare function readAllTokens(dataDir: string): Promise<Record<string, StoredMcpToken>>;
85
+ export declare function readAllTokens({ dataDir }: {
86
+ dataDir: string;
87
+ }, options?: McpTokenStoreOptions): Promise<Record<string, StoredMcpToken>>;
68
88
  /** True when the stored token is past its `expiresAt` (or within `skew`
69
89
  * milliseconds of expiring). Returns false when no `expiresAt` is recorded
70
90
  * — many providers issue non-expiring tokens. */
71
- export declare function isTokenExpired(token: StoredMcpToken, now?: number, skew?: number): boolean;
91
+ export declare function isTokenExpired({ token }: {
92
+ token: StoredMcpToken;
93
+ }, { clock, now, skew }?: {
94
+ clock?: Clock;
95
+ now?: number;
96
+ skew?: number;
97
+ }): boolean;
72
98
  //# sourceMappingURL=tokens.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { createSystemClock } from "@jini-ai/core/primitives";
1
2
  /**
2
3
  * @module @jini-ai/mcp/core/tokens
3
4
  * Persistent, 0600-guarded OAuth-token store for remote MCP servers, keyed by
@@ -13,18 +14,47 @@
13
14
  // atomic write + per-dataDir mutex pattern the rest of the runtime uses.
14
15
  //
15
16
  // File mode: owner-only (0600) from the very first byte, via
16
- // `writeSecretFileAtomic` (CR-006 / SEC-RB-002) — NOT a post-rename chmod.
17
+ // `writeFileAtomicAsync` (CR-006 / SEC-RB-002) — NOT a post-rename chmod.
17
18
  // The previous chmod-after-rename approach left a window where the file
18
19
  // briefly existed with default (potentially world/group-readable)
19
20
  // permissions, and silently continued persisting the token when chmod
20
21
  // failed for any reason other than ENOTSUP/EPERM, which is not a
21
- // fail-closed guarantee. `writeSecretFileAtomic` creates the file with mode
22
+ // fail-closed guarantee. `writeFileAtomicAsync` creates the file with mode
22
23
  // 0600 atomically at creation and fails closed (removing the temp file and
23
24
  // throwing) if a POSIX platform did not honor that mode; it skips
24
25
  // verification on `win32`, which has no POSIX permission-bit model.
25
26
  import { readFile } from 'node:fs/promises';
26
27
  import path from 'node:path';
27
- import { writeSecretFileAtomic } from './secure-write.js';
28
+ import { writeFileAtomicAsync } from '@jini-ai/platform/fs';
29
+ /** Shared atomic secret persistence (CR-006 / SEC-RB-002).
30
+ * The previous pattern in each of those stores wrote the temp file with
31
+ * default permissions and either never restricted it (config, OAuth client
32
+ * cache) or `chmod(0600)`'d it *after* the atomic rename and silently
33
+ * continued when the chmod failed (tokens) — both leave a window where the
34
+ * file exists world/group-readable, and the latter makes the "protection"
35
+ * advisory rather than enforced. The platform writer instead:
36
+ *
37
+ * 1. Creates the temp file *exclusively* (`wx`) with mode 0600 supplied to
38
+ * the same `open()`/`writeFile()` call that creates it — POSIX applies
39
+ * the requested mode atomically at creation time, so there is no
40
+ * window where the file exists with a broader mode. Creation-mode enforcement
41
+ * follows the existing prompt-file precedent; the shared writer also reapplies
42
+ * 0600 before checking the mode and flushing the file, never after rename.
43
+ * 2. On POSIX platforms, verifies the on-disk mode actually landed as
44
+ * owner-only before renaming into place, and fails closed — removing
45
+ * the temp file and throwing — if it did not (e.g. an unusual umask or
46
+ * a filesystem that doesn't honor `mode`).
47
+ * 3. Skips that verification on `win32`: Windows has no POSIX permission-
48
+ * bit model (`mode` there only toggles the read-only attribute), so
49
+ * there is nothing meaningful to enforce or fail closed on.
50
+ * 4. Flushes file content before rename and the parent directory after it. A directory
51
+ * flush can fail after replacement; it is reported rather than silently ignored.
52
+ *
53
+ */
54
+ const defaultFilesystem = {
55
+ readText: ({ filePath }) => readFile(filePath, 'utf8'),
56
+ writeSecretText: ({ filePath, contents }) => writeFileAtomicAsync({ filePath, content: contents }, { mode: 0o600, verifyOwnerOnly: true, createParent: true }),
57
+ };
28
58
  const EMPTY = { servers: {} };
29
59
  function tokensFile(dataDir) {
30
60
  return path.join(dataDir, 'mcp-tokens.json');
@@ -35,7 +65,7 @@ function isPlainObject(v) {
35
65
  /** Coerce a freeform JSON blob into the typed shape, dropping anything that
36
66
  * doesn't deserialize cleanly. Used both at read time and as a defensive
37
67
  * pass when third-party tooling has hand-edited the file. */
38
- export function sanitizeTokensFile(raw) {
68
+ export function sanitizeTokensFile({ raw }, { clock = createSystemClock() } = {}) {
39
69
  if (!isPlainObject(raw))
40
70
  return { servers: {} };
41
71
  const servers = raw.servers;
@@ -45,14 +75,14 @@ export function sanitizeTokensFile(raw) {
45
75
  for (const [id, value] of Object.entries(servers)) {
46
76
  if (id === '__proto__' || id === 'constructor')
47
77
  continue;
48
- const tok = sanitizeToken(value);
78
+ const tok = sanitizeToken(value, clock);
49
79
  if (!tok)
50
80
  continue;
51
81
  out[id] = tok;
52
82
  }
53
83
  return { servers: out };
54
84
  }
55
- function sanitizeToken(raw) {
85
+ function sanitizeToken(raw, clock) {
56
86
  if (!isPlainObject(raw))
57
87
  return null;
58
88
  const accessToken = typeof raw.accessToken === 'string' ? raw.accessToken.trim() : '';
@@ -72,7 +102,7 @@ function sanitizeToken(raw) {
72
102
  : undefined;
73
103
  const savedAt = typeof raw.savedAt === 'number' && Number.isFinite(raw.savedAt)
74
104
  ? raw.savedAt
75
- : Date.now();
105
+ : clock.nowMs();
76
106
  const tokenEndpoint = typeof raw.tokenEndpoint === 'string' && raw.tokenEndpoint.trim()
77
107
  ? raw.tokenEndpoint.trim()
78
108
  : undefined;
@@ -118,10 +148,10 @@ function sanitizeToken(raw) {
118
148
  * @param dataDir The resolved runtime data directory.
119
149
  * @returns The sanitized token file, never rejects on missing-file or corrupt-JSON.
120
150
  */
121
- export async function readTokensFile(dataDir) {
151
+ export async function readTokensFile({ dataDir }, options = {}) {
122
152
  try {
123
- const raw = await readFile(tokensFile(dataDir), 'utf8');
124
- return sanitizeTokensFile(JSON.parse(raw));
153
+ const raw = await (options.filesystem ?? defaultFilesystem).readText({ filePath: tokensFile(dataDir) });
154
+ return sanitizeTokensFile({ raw: JSON.parse(raw) }, options);
125
155
  }
126
156
  catch (err) {
127
157
  const e = err;
@@ -147,49 +177,49 @@ async function withLock(dataDir, fn) {
147
177
  writeLocks.delete(dataDir);
148
178
  }
149
179
  }
150
- async function writeTokensFile(dataDir, next) {
180
+ async function writeTokensFile(dataDir, next, options) {
151
181
  const file = tokensFile(dataDir);
152
182
  // Bearer tokens can hand someone posting-as-you against the upstream MCP,
153
183
  // so the file is created with owner-only (0600) permissions from the
154
184
  // first byte and the write fails closed if that can't be guaranteed on a
155
- // POSIX platform — see `writeSecretFileAtomic`.
156
- await writeSecretFileAtomic(file, JSON.stringify(next, null, 2));
185
+ // POSIX platform — see `writeFileAtomicAsync`.
186
+ await (options.filesystem ?? defaultFilesystem).writeSecretText({ filePath: file, contents: JSON.stringify(next, null, 2) });
157
187
  return next;
158
188
  }
159
189
  /** Get the current token for a given server, or null when none is stored
160
190
  * (or the persisted entry is malformed). */
161
- export async function getToken(dataDir, serverId) {
162
- const file = await readTokensFile(dataDir);
191
+ export async function getToken({ dataDir, serverId }, options = {}) {
192
+ const file = await readTokensFile({ dataDir }, options);
163
193
  return file.servers[serverId] ?? null;
164
194
  }
165
195
  /** Atomically merge a new token for `serverId` into the tokens file. */
166
- export async function setToken(dataDir, serverId, token) {
196
+ export async function setToken({ dataDir, serverId, token }, options = {}) {
167
197
  await withLock(dataDir, async () => {
168
- const file = await readTokensFile(dataDir);
198
+ const file = await readTokensFile({ dataDir }, options);
169
199
  file.servers[serverId] = token;
170
- await writeTokensFile(dataDir, file);
200
+ await writeTokensFile(dataDir, file, options);
171
201
  });
172
202
  }
173
203
  /** Atomically delete the stored token for `serverId`. No-op when absent. */
174
- export async function clearToken(dataDir, serverId) {
204
+ export async function clearToken({ dataDir, serverId }, options = {}) {
175
205
  await withLock(dataDir, async () => {
176
- const file = await readTokensFile(dataDir);
206
+ const file = await readTokensFile({ dataDir }, options);
177
207
  if (!(serverId in file.servers))
178
208
  return;
179
209
  delete file.servers[serverId];
180
- await writeTokensFile(dataDir, file);
210
+ await writeTokensFile(dataDir, file, options);
181
211
  });
182
212
  }
183
213
  /** Bulk read used by the spawn pipeline so we make one disk hit per spawn,
184
214
  * not one per server. */
185
- export async function readAllTokens(dataDir) {
186
- const file = await readTokensFile(dataDir);
215
+ export async function readAllTokens({ dataDir }, options = {}) {
216
+ const file = await readTokensFile({ dataDir }, options);
187
217
  return file.servers;
188
218
  }
189
219
  /** True when the stored token is past its `expiresAt` (or within `skew`
190
220
  * milliseconds of expiring). Returns false when no `expiresAt` is recorded
191
221
  * — many providers issue non-expiring tokens. */
192
- export function isTokenExpired(token, now = Date.now(), skew = 30_000) {
222
+ export function isTokenExpired({ token }, { clock = createSystemClock(), now = clock.nowMs(), skew = 30_000 } = {}) {
193
223
  if (typeof token.expiresAt !== 'number')
194
224
  return false;
195
225
  return token.expiresAt - skew <= now;