@jini-ai/mcp 0.4.2 → 0.4.4

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 (61) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -0
  3. package/dist/agent-install/chrome-devtools.d.ts +4 -0
  4. package/dist/agent-install/chrome-devtools.js +7 -0
  5. package/dist/agent-install/index.d.ts +1 -0
  6. package/dist/agent-install/index.js +1 -0
  7. package/dist/agent-install/install.js +102 -194
  8. package/dist/bin/serve.d.ts +4 -1
  9. package/dist/bin/serve.js +120 -45
  10. package/dist/client/client.js +71 -58
  11. package/dist/core/config.js +113 -149
  12. package/dist/core/tokens.js +29 -53
  13. package/dist/federation/adapter.http.js +3 -2
  14. package/dist/federation/approvals/call-confirmation.js +5 -70
  15. package/dist/federation/approvals/remembered-approval.d.ts +39 -0
  16. package/dist/federation/approvals/remembered-approval.js +98 -0
  17. package/dist/federation/approvals/revocation-reason.d.ts +9 -0
  18. package/dist/federation/approvals/revocation-reason.js +11 -0
  19. package/dist/federation/approvals/revocation.js +19 -13
  20. package/dist/federation/approvals/scope-option.d.ts +6 -0
  21. package/dist/federation/approvals/scope-option.js +6 -0
  22. package/dist/federation/bootstrap.d.ts +18 -0
  23. package/dist/federation/bootstrap.js +4 -4
  24. package/dist/federation/presets.d.ts +16 -17
  25. package/dist/federation/presets.js +35 -28
  26. package/dist/federation/stdio/adapter.stdio.js +14 -6
  27. package/dist/federation/stdio/default-connect.js +24 -27
  28. package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
  29. package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
  30. package/dist/federation/tool-approvals.js +3 -1
  31. package/dist/federation/trust.d.ts +23 -2
  32. package/dist/federation/trust.js +69 -35
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -2
  35. package/dist/server/daemon-client.d.ts +2 -0
  36. package/dist/server/daemon-client.js +86 -69
  37. package/dist/server/index.d.ts +1 -0
  38. package/dist/server/index.js +1 -0
  39. package/dist/server/resources/site-guide-resource.d.ts +15 -0
  40. package/dist/server/resources/site-guide-resource.js +25 -0
  41. package/dist/server/tool-arguments.d.ts +46 -0
  42. package/dist/server/tool-arguments.js +111 -0
  43. package/dist/server/tool-protocol.d.ts +62 -7
  44. package/dist/server/tool-protocol.js +30 -40
  45. package/dist/server/tool-server.d.ts +19 -0
  46. package/dist/server/tool-server.js +91 -19
  47. package/dist/server/tools/delegated-tool.d.ts +47 -0
  48. package/dist/server/tools/delegated-tool.js +35 -2
  49. package/dist/server/tools/tool-catalog-tools.js +30 -8
  50. package/dist/tools/ask-choice/index.d.ts +213 -14
  51. package/dist/tools/ask-choice/index.js +90 -46
  52. package/dist/tools/ask-choice/question.d.ts +5 -1
  53. package/dist/tools/ask-choice/question.js +36 -2
  54. package/dist/tools/ask-choice/schema.d.ts +196 -10
  55. package/dist/tools/ask-choice/schema.js +33 -12
  56. package/dist/tools/ask-choice/types.d.ts +8 -0
  57. package/package.json +6 -5
  58. package/dist/core/oauth.d.ts +0 -274
  59. package/dist/core/oauth.js +0 -667
  60. package/dist/core/secure-write.d.ts +0 -17
  61. package/dist/core/secure-write.js +0 -72
@@ -162,38 +162,23 @@ function isHtmlLike(mime, fromPath) {
162
162
  return true;
163
163
  return /\.html?$/i.test(fromPath);
164
164
  }
165
- /**
166
- * Extract all relative (non-absolute, non-CDN, non-data-URI) file references from
167
- * the text content of a file, normalized to root-relative paths.
168
- * Runs HTML, CSS, and/or JS pattern sets based on the file's MIME type and extension.
169
- * Skips `https?:`, `//`, `data:`, `mailto:`, `tel:`, and `#` prefixes; drops any
170
- * reference that would escape the root via `..` traversal.
171
- * @param text The textual content of the file.
172
- * @param fromPath The root-relative path of the file (used for relative-path resolution).
173
- * @param fromMime The MIME type of the file (used to select reference-extraction patterns).
174
- * @returns Deduplicated root-relative paths of all referenced files found.
175
- */
176
- export function extractRelativeRefs({ text, fromPath, fromMime }) {
177
- if (!text)
178
- return [];
179
- const refs = new Set();
165
+ // Order matters: candidates are collected HTML → CSS → JS, so the returned
166
+ // list keeps that first-seen order after dedupe.
167
+ function selectRefPatterns(fromMime, fromPath, htmlLike) {
180
168
  const runPatterns = [];
181
- if (isHtmlLike(fromMime, fromPath)) {
169
+ if (htmlLike)
182
170
  runPatterns.push(...HTML_REF_PATTERNS, ...CSS_REF_PATTERNS);
183
- }
184
- if (isCssLike(fromMime, fromPath)) {
171
+ if (isCssLike(fromMime, fromPath))
185
172
  runPatterns.push(...CSS_REF_PATTERNS);
186
- }
187
- if (isJsLike(fromMime, fromPath)) {
173
+ if (isJsLike(fromMime, fromPath))
188
174
  runPatterns.push(...JS_REF_PATTERNS);
189
- }
190
175
  // Fallback for unknown textual files: only the safest pattern,
191
176
  // url() in case it's a CSS-in-something we don't recognize.
192
- if (runPatterns.length === 0) {
193
- runPatterns.push(...CSS_REF_PATTERNS);
194
- }
177
+ return runPatterns.length === 0 ? [...CSS_REF_PATTERNS] : runPatterns;
178
+ }
179
+ function collectPatternCandidates(text, patterns) {
195
180
  const candidates = [];
196
- for (const re of runPatterns) {
181
+ for (const re of patterns) {
197
182
  for (const m of text.matchAll(re)) {
198
183
  // The capture group is required in every pattern, so `m[1]` is always a
199
184
  // matched string here (the `!` only satisfies noUncheckedIndexedAccess).
@@ -202,44 +187,72 @@ export function extractRelativeRefs({ text, fromPath, fromMime }) {
202
187
  candidates.push(ref);
203
188
  }
204
189
  }
205
- // Pull every candidate URL out of any srcset attributes in HTML.
206
- if (isHtmlLike(fromMime, fromPath)) {
207
- for (const m of text.matchAll(SRCSET_PATTERN)) {
208
- const list = m[1];
209
- for (const part of list.split(',')) {
210
- const url = part.trim().split(/\s+/)[0];
211
- if (url)
212
- candidates.push(url);
213
- }
190
+ return candidates;
191
+ }
192
+ // Pull every candidate URL out of any srcset attributes in HTML.
193
+ function collectSrcsetCandidates(text) {
194
+ const candidates = [];
195
+ for (const m of text.matchAll(SRCSET_PATTERN)) {
196
+ for (const part of m[1].split(',')) {
197
+ const url = part.trim().split(/\s+/)[0];
198
+ if (url)
199
+ candidates.push(url);
214
200
  }
215
201
  }
216
- for (const raw of candidates) {
217
- if (/^(?:https?:|\/\/|data:|mailto:|tel:|#)/i.test(raw))
202
+ return candidates;
203
+ }
204
+ // Collapse `.`/`..`/empty segments. Returns null when a `..` would climb
205
+ // above the root (traversal escape) — such refs are dropped, never clamped.
206
+ function collapseSegments(segs) {
207
+ const out = [];
208
+ for (const s of segs) {
209
+ if (s === '' || s === '.')
218
210
  continue;
219
- const dir = fromPath.includes('/')
220
- ? fromPath.slice(0, fromPath.lastIndexOf('/') + 1)
221
- : '';
222
- const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
223
- const stripped = resolved.replace(/[?#].*$/, '');
224
- const segs = stripped.split('/').filter(Boolean);
225
- const out = [];
226
- let escaped = false;
227
- for (const s of segs) {
228
- if (s === '.')
229
- continue;
230
- if (s === '..') {
231
- if (out.length === 0) {
232
- escaped = true;
233
- break;
234
- }
235
- out.pop();
236
- continue;
237
- }
211
+ if (s !== '..') {
238
212
  out.push(s);
239
- }
240
- if (escaped || out.length === 0)
241
213
  continue;
242
- refs.add(out.join('/'));
214
+ }
215
+ if (out.length === 0)
216
+ return null;
217
+ out.pop();
218
+ }
219
+ return out;
220
+ }
221
+ // Resolve one raw ref against the referencing file's directory. Returns null
222
+ // for external refs (`https?:`, `//`, `data:`, `mailto:`, `tel:`, `#`), for
223
+ // refs that escape the root; a ref that collapses to nothing yields ''.
224
+ function normalizeRef(raw, dir) {
225
+ if (/^(?:https?:|\/\/|data:|mailto:|tel:|#)/i.test(raw))
226
+ return null;
227
+ const resolved = raw.startsWith('/') ? raw.slice(1) : dir + raw;
228
+ const stripped = resolved.replace(/[?#].*$/, '');
229
+ return collapseSegments(stripped.split('/'))?.join('/') ?? null;
230
+ }
231
+ /**
232
+ * Extract all relative (non-absolute, non-CDN, non-data-URI) file references from
233
+ * the text content of a file, normalized to root-relative paths.
234
+ * Runs HTML, CSS, and/or JS pattern sets based on the file's MIME type and extension.
235
+ * Skips `https?:`, `//`, `data:`, `mailto:`, `tel:`, and `#` prefixes; drops any
236
+ * reference that would escape the root via `..` traversal.
237
+ * @param text The textual content of the file.
238
+ * @param fromPath The root-relative path of the file (used for relative-path resolution).
239
+ * @param fromMime The MIME type of the file (used to select reference-extraction patterns).
240
+ * @returns Deduplicated root-relative paths of all referenced files found.
241
+ */
242
+ export function extractRelativeRefs({ text, fromPath, fromMime }) {
243
+ if (!text)
244
+ return [];
245
+ const htmlLike = isHtmlLike(fromMime, fromPath);
246
+ const candidates = collectPatternCandidates(text, selectRefPatterns(fromMime, fromPath, htmlLike));
247
+ if (htmlLike)
248
+ candidates.push(...collectSrcsetCandidates(text));
249
+ // `lastIndexOf('/') + 1` is 0 for a root-level file, giving an empty dir.
250
+ const dir = fromPath.slice(0, fromPath.lastIndexOf('/') + 1);
251
+ const refs = new Set();
252
+ for (const raw of candidates) {
253
+ const ref = normalizeRef(raw, dir);
254
+ if (ref)
255
+ refs.add(ref);
243
256
  }
244
257
  return [...refs];
245
258
  }
@@ -70,29 +70,29 @@ function configFile(dataDir) {
70
70
  function isPlainObject(v) {
71
71
  return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
72
72
  }
73
+ function isKeepableMapEntry(entry) {
74
+ const [k, v] = entry;
75
+ if (k === '__proto__' || k === 'constructor' || !k.trim())
76
+ return false;
77
+ // Drop empty / whitespace-only values. Persisting them is worse than
78
+ // omitting them: the spawn-time merge treats a present header as
79
+ // "user pinned this", which would block our daemon-issued OAuth
80
+ // Bearer from being injected. The UI also has placeholder fields
81
+ // (e.g. an "Authorization=" template row) the user can leave blank
82
+ // — those should never make it into the saved config.
83
+ return typeof v === 'string' && v.trim() !== '';
84
+ }
73
85
  function sanitizeStringMap(raw) {
74
86
  if (!isPlainObject(raw))
75
87
  return undefined;
76
88
  const out = {};
77
- for (const [k, v] of Object.entries(raw)) {
78
- if (k === '__proto__' || k === 'constructor')
79
- continue;
80
- if (!k.trim())
81
- continue;
82
- if (typeof v !== 'string')
83
- continue;
84
- // Drop empty / whitespace-only values. Persisting them is worse than
85
- // omitting them: the spawn-time merge treats a present header as
86
- // "user pinned this", which would block our daemon-issued OAuth
87
- // Bearer from being injected. The UI also has placeholder fields
88
- // (e.g. an "Authorization=" template row) the user can leave blank
89
- // — those should never make it into the saved config.
90
- if (v.trim() === '')
91
- continue;
89
+ for (const [k, v] of Object.entries(raw).filter(isKeepableMapEntry))
92
90
  out[k] = v;
93
- }
94
91
  return Object.keys(out).length > 0 ? out : undefined;
95
92
  }
93
+ function nonBlankTrimmed(raw) {
94
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
95
+ }
96
96
  function sanitizeStringArray(raw) {
97
97
  if (!Array.isArray(raw))
98
98
  return undefined;
@@ -140,12 +140,8 @@ export function sanitizeMcpServer({ raw }) {
140
140
  const id = typeof raw.id === 'string' ? raw.id.trim() : '';
141
141
  if (!SERVER_ID_PATTERN.test(id))
142
142
  return null;
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;
148
- if (!VALID_TRANSPORTS.has(transport))
143
+ const transport = sanitizeTransport(raw.transport);
144
+ if (!transport)
149
145
  return null;
150
146
  if (raw.authMode !== undefined && sanitizeMcpAuthMode(raw.authMode) === undefined)
151
147
  return null;
@@ -154,41 +150,47 @@ export function sanitizeMcpServer({ raw }) {
154
150
  transport,
155
151
  enabled: raw.enabled !== false,
156
152
  };
157
- if (typeof raw.label === 'string' && raw.label.trim()) {
158
- next.label = raw.label.trim();
159
- }
160
- if (transport === 'stdio') {
161
- if (typeof raw.command !== 'string' || !raw.command.trim())
162
- return null;
163
- next.command = raw.command.trim();
164
- const args = sanitizeStringArray(raw.args);
165
- if (args)
166
- next.args = args;
167
- const env = sanitizeStringMap(raw.env);
168
- if (env)
169
- next.env = env;
170
- }
171
- else {
172
- if (typeof raw.url !== 'string' || !raw.url.trim())
173
- return null;
174
- // Reject anything that isn't an http(s) URL — protects against accidental
175
- // `file://` / `javascript:` slipping into a config file.
176
- let parsed;
177
- try {
178
- parsed = new URL(raw.url.trim());
179
- }
180
- catch {
181
- return null;
182
- }
183
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
184
- return null;
185
- next.url = parsed.toString();
186
- next.authMode = sanitizeMcpAuthMode(raw.authMode) ?? inferMcpAuthModeForUrl({ rawUrl: next.url });
187
- const headers = sanitizeStringMap(raw.headers);
188
- if (headers)
189
- next.headers = headers;
190
- }
191
- return next;
153
+ const label = nonBlankTrimmed(raw.label);
154
+ if (label)
155
+ next.label = label;
156
+ const fields = transport === 'stdio' ? sanitizeStdioFields(raw) : sanitizeRemoteFields(raw);
157
+ return fields ? { ...next, ...fields } : null;
158
+ }
159
+ // Absence preserves the legacy stdio default; a supplied invalid discriminator must not
160
+ // silently select a different transport or authentication policy.
161
+ function sanitizeTransport(raw) {
162
+ if (raw === undefined)
163
+ return 'stdio';
164
+ return typeof raw === 'string' && VALID_TRANSPORTS.has(raw) ? raw : null;
165
+ }
166
+ function sanitizeStdioFields(raw) {
167
+ const command = nonBlankTrimmed(raw.command);
168
+ if (!command)
169
+ return null;
170
+ const out = { command };
171
+ const args = sanitizeStringArray(raw.args);
172
+ if (args)
173
+ out.args = args;
174
+ const env = sanitizeStringMap(raw.env);
175
+ if (env)
176
+ out.env = env;
177
+ return out;
178
+ }
179
+ function sanitizeRemoteFields(raw) {
180
+ const rawUrl = nonBlankTrimmed(raw.url);
181
+ if (!rawUrl)
182
+ return null;
183
+ // Reject anything that isn't an http(s) URL — protects against accidental
184
+ // `file://` / `javascript:` slipping into a config file.
185
+ const parsed = URL.canParse(rawUrl) ? new URL(rawUrl) : null;
186
+ if (parsed?.protocol !== 'http:' && parsed?.protocol !== 'https:')
187
+ return null;
188
+ const url = parsed.toString();
189
+ const out = { url, authMode: sanitizeMcpAuthMode(raw.authMode) ?? inferMcpAuthModeForUrl({ rawUrl: url }) };
190
+ const headers = sanitizeStringMap(raw.headers);
191
+ if (headers)
192
+ out.headers = headers;
193
+ return out;
192
194
  }
193
195
  /**
194
196
  * Coerce and de-duplicate a freeform JSON blob into a valid `McpConfig`.
@@ -307,7 +309,7 @@ export function isManagedProjectCwd({ cwd, projectsDir }, { resolvePath = resolv
307
309
  const resolvedCwd = resolvePath({ filePath: cwd });
308
310
  if (resolvedCwd === resolvedProjectsDir)
309
311
  return false; // projects root, not a project
310
- return pathContains({ root: resolvedProjectsDir, target: resolvedCwd }, { caseSensitive: path.sep !== '\\', separator: path.sep === '\\' ? '\\' : '/' });
312
+ return pathContains({ root: resolvedProjectsDir, target: resolvedCwd }, { caseSensitive: path.sep !== '\\', separator: path.sep });
311
313
  }
312
314
  /**
313
315
  * Project-cwd `.mcp.json` shape that Claude Code auto-loads on spawn (the
@@ -329,27 +331,22 @@ export function buildClaudeMcpJson({ servers }, { tokens = {} } = {}) {
329
331
  return null;
330
332
  const out = {};
331
333
  for (const s of enabled) {
332
- if (s.transport === 'stdio') {
333
- const entry = { command: s.command };
334
- if (s.args && s.args.length > 0)
335
- entry.args = s.args;
336
- if (s.env && Object.keys(s.env).length > 0)
337
- entry.env = s.env;
338
- out[s.id] = entry;
339
- }
340
- else {
341
- const entry = {
342
- type: s.transport, // 'sse' | 'http'
343
- url: s.url,
344
- };
345
- const headers = mergeAuthHeader(s.headers, effectiveMcpAuthMode(s) === 'oauth' ? tokens[s.id] : undefined);
346
- if (headers && Object.keys(headers).length > 0)
347
- entry.headers = headers;
348
- out[s.id] = entry;
349
- }
334
+ out[s.id] = s.transport === 'stdio'
335
+ ? { command: s.command, ...nonEmptyField({ key: 'args', value: s.args }), ...nonEmptyField({ key: 'env', value: s.env }) }
336
+ // type: 'sse' | 'http'
337
+ : { type: s.transport, url: s.url, ...remoteHeadersField({ server: s, tokens }) };
350
338
  }
351
339
  return { mcpServers: out };
352
340
  }
341
+ /** `{ [key]: value }` when `value` has at least one element/key, else `{}` — for spreading optional fields. */
342
+ function nonEmptyField({ key, value }) {
343
+ return value && Object.keys(value).length > 0 ? { [key]: value } : {};
344
+ }
345
+ /** `{ headers }` for a remote server (user headers + daemon OAuth bearer when in oauth mode), or `{}` when empty. */
346
+ function remoteHeadersField({ server, tokens }) {
347
+ const bearer = effectiveMcpAuthMode(server) === 'oauth' ? tokens[server.id] : undefined;
348
+ return nonEmptyField({ key: 'headers', value: mergeAuthHeader(server.headers, bearer) });
349
+ }
353
350
  /** Build a headers object that includes the daemon-issued bearer token when
354
351
  * the user hasn't already supplied a NON-EMPTY Authorization header. A
355
352
  * blank Authorization (empty string / whitespace only) is treated as
@@ -360,22 +357,19 @@ export function buildClaudeMcpJson({ servers }, { tokens = {} } = {}) {
360
357
  * win so manually-set PATs aren't silently overwritten. */
361
358
  function mergeAuthHeader(existing, bearer) {
362
359
  const merged = {};
363
- let userAuth = null;
360
+ let userAuth = false;
364
361
  for (const [k, v] of Object.entries(existing ?? {})) {
365
- if (k.toLowerCase() === 'authorization') {
366
- if (typeof v === 'string' && v.trim() !== '') {
367
- userAuth = v;
368
- merged[k] = v;
369
- }
370
- // empty / whitespace authorization is ignored, NOT carried through
362
+ const isAuth = k.toLowerCase() === 'authorization';
363
+ // empty / whitespace authorization is ignored, NOT carried through
364
+ if (isAuth && (typeof v !== 'string' || v.trim() === ''))
371
365
  continue;
372
- }
366
+ userAuth ||= isAuth;
373
367
  merged[k] = v;
374
368
  }
375
369
  if (bearer && !userAuth) {
376
370
  merged['Authorization'] = `Bearer ${bearer}`;
377
371
  }
378
- return Object.keys(merged).length > 0 ? merged : undefined;
372
+ return merged;
379
373
  }
380
374
  /**
381
375
  * Convert user-configured external MCP servers into the ACP `mcpServers` array.
@@ -385,84 +379,54 @@ function mergeAuthHeader(existing, bearer) {
385
379
  * @returns ACP-shaped server entries for all enabled stdio servers.
386
380
  */
387
381
  export function buildAcpMcpServers({ servers }) {
388
- const enabled = servers.filter((s) => s.enabled && s.transport === 'stdio');
389
- const out = [];
390
- for (const s of enabled) {
391
- const envEntries = [];
392
- if (s.env) {
393
- for (const [name, value] of Object.entries(s.env)) {
394
- if (typeof value !== 'string')
395
- continue;
396
- envEntries.push({ name, value });
397
- }
398
- }
399
- out.push({
400
- type: 'stdio',
401
- name: s.id,
402
- command: s.command ?? '',
403
- args: Array.isArray(s.args) ? [...s.args] : [],
404
- env: envEntries,
405
- });
406
- }
407
- return out;
382
+ return servers
383
+ .filter((s) => s.enabled && s.transport === 'stdio')
384
+ .map((s) => ({
385
+ type: 'stdio',
386
+ name: s.id,
387
+ command: s.command ?? '',
388
+ args: Array.isArray(s.args) ? [...s.args] : [],
389
+ env: Object.entries(s.env ?? {})
390
+ .filter(([, value]) => typeof value === 'string')
391
+ .map(([name, value]) => ({ name, value })),
392
+ }));
408
393
  }
409
394
  export function buildOpenCodeMcpConfigContent({ servers }, options = {}) {
410
395
  const tokens = options.tokens ?? {};
411
396
  const resolvePath = options.resolvePath ?? (({ filePath }) => realpathSync.native(filePath));
412
- return buildOpenCodeContent(servers, tokens, options, resolvePath);
413
- }
414
- function buildOpenCodeContent(servers, tokens, options, resolvePath) {
415
- const enabled = servers.filter((s) => s.enabled);
416
397
  const mcp = {};
417
- for (const s of enabled) {
418
- if (s.transport === 'stdio') {
419
- const cmd = typeof s.command === 'string' ? s.command.trim() : '';
420
- if (!cmd)
421
- continue;
422
- const entry = {
423
- type: 'local',
424
- command: [cmd, ...(Array.isArray(s.args) ? s.args : [])],
425
- };
426
- if (s.env && Object.keys(s.env).length > 0) {
427
- entry.environment = { ...s.env };
428
- }
429
- entry.enabled = true;
398
+ for (const s of servers.filter((server) => server.enabled)) {
399
+ const entry = buildOpenCodeServerEntry({ server: s, tokens });
400
+ if (entry)
430
401
  mcp[s.id] = entry;
431
- }
432
- else {
433
- const url = typeof s.url === 'string' ? s.url.trim() : '';
434
- if (!url)
435
- continue;
436
- const entry = {
437
- type: 'remote',
438
- url: s.url,
439
- };
440
- const headers = mergeAuthHeader(s.headers, effectiveMcpAuthMode(s) === 'oauth' ? tokens[s.id] : undefined);
441
- if (headers && Object.keys(headers).length > 0)
442
- entry.headers = headers;
443
- entry.enabled = true;
444
- mcp[s.id] = entry;
445
- }
446
402
  }
447
403
  const externalDirectory = buildOpenCodeExternalDirectoryAllowlist(options.allowedDirectories, resolvePath);
448
- const extraConfig = options.extraConfig ?? {};
449
- if (Object.keys(mcp).length === 0 &&
450
- !externalDirectory &&
451
- Object.keys(extraConfig).length === 0)
452
- return null;
453
- const config = { ...extraConfig };
454
- if (Object.keys(mcp).length > 0)
455
- config.mcp = mcp;
404
+ const config = { ...options.extraConfig, ...nonEmptyField({ key: 'mcp', value: mcp }) };
456
405
  if (externalDirectory) {
457
- const priorPermission = config.permission && typeof config.permission === 'object' && !Array.isArray(config.permission)
458
- ? config.permission
459
- : {};
406
+ const priorPermission = isPlainObject(config.permission) ? config.permission : {};
460
407
  config.permission = {
461
408
  ...priorPermission,
462
409
  external_directory: externalDirectory,
463
410
  };
464
411
  }
465
- return JSON.stringify(config);
412
+ return Object.keys(config).length === 0 ? null : JSON.stringify(config);
413
+ }
414
+ /** One OpenCode `mcp` entry, or null when the server lacks its transport-required command/url. */
415
+ function buildOpenCodeServerEntry({ server: s, tokens }) {
416
+ if (s.transport === 'stdio') {
417
+ const cmd = typeof s.command === 'string' ? s.command.trim() : '';
418
+ if (!cmd)
419
+ return null;
420
+ return {
421
+ type: 'local',
422
+ command: [cmd, ...(Array.isArray(s.args) ? s.args : [])],
423
+ ...nonEmptyField({ key: 'environment', value: { ...s.env } }),
424
+ enabled: true,
425
+ };
426
+ }
427
+ if (typeof s.url !== 'string' || !s.url.trim())
428
+ return null;
429
+ return { type: 'remote', url: s.url, ...remoteHeadersField({ server: s, tokens }), enabled: true };
466
430
  }
467
431
  function buildOpenCodeExternalDirectoryAllowlist(directories, resolvePath) {
468
432
  const normalized = Array.from(new Set((directories ?? [])
@@ -82,64 +82,40 @@ export function sanitizeTokensFile({ raw }, { clock = createSystemClock() } = {}
82
82
  }
83
83
  return { servers: out };
84
84
  }
85
+ function trimmedString(raw) {
86
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
87
+ }
88
+ function finiteNumber(raw) {
89
+ return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
90
+ }
91
+ /** Optional fields copied when they deserialize cleanly, in persisted key order. */
92
+ const OPTIONAL_TOKEN_FIELDS = [
93
+ ['refreshToken', trimmedString],
94
+ ['scope', trimmedString],
95
+ ['expiresAt', finiteNumber],
96
+ ['tokenEndpoint', trimmedString],
97
+ ['clientId', trimmedString],
98
+ ['clientSecret', trimmedString],
99
+ ['authServerIssuer', trimmedString],
100
+ ['redirectUri', trimmedString],
101
+ ['resourceUrl', trimmedString],
102
+ ];
85
103
  function sanitizeToken(raw, clock) {
86
104
  if (!isPlainObject(raw))
87
105
  return null;
88
- const accessToken = typeof raw.accessToken === 'string' ? raw.accessToken.trim() : '';
106
+ const accessToken = trimmedString(raw.accessToken);
89
107
  if (!accessToken)
90
108
  return null;
91
- const tokenType = typeof raw.tokenType === 'string' && raw.tokenType.trim()
92
- ? raw.tokenType.trim()
93
- : 'Bearer';
94
- const refreshToken = typeof raw.refreshToken === 'string' && raw.refreshToken.trim()
95
- ? raw.refreshToken.trim()
96
- : undefined;
97
- const scope = typeof raw.scope === 'string' && raw.scope.trim()
98
- ? raw.scope.trim()
99
- : undefined;
100
- const expiresAt = typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt)
101
- ? raw.expiresAt
102
- : undefined;
103
- const savedAt = typeof raw.savedAt === 'number' && Number.isFinite(raw.savedAt)
104
- ? raw.savedAt
105
- : clock.nowMs();
106
- const tokenEndpoint = typeof raw.tokenEndpoint === 'string' && raw.tokenEndpoint.trim()
107
- ? raw.tokenEndpoint.trim()
108
- : undefined;
109
- const clientId = typeof raw.clientId === 'string' && raw.clientId.trim()
110
- ? raw.clientId.trim()
111
- : undefined;
112
- const clientSecret = typeof raw.clientSecret === 'string' && raw.clientSecret.trim()
113
- ? raw.clientSecret.trim()
114
- : undefined;
115
- const authServerIssuer = typeof raw.authServerIssuer === 'string' && raw.authServerIssuer.trim()
116
- ? raw.authServerIssuer.trim()
117
- : undefined;
118
- const redirectUri = typeof raw.redirectUri === 'string' && raw.redirectUri.trim()
119
- ? raw.redirectUri.trim()
120
- : undefined;
121
- const resourceUrl = typeof raw.resourceUrl === 'string' && raw.resourceUrl.trim()
122
- ? raw.resourceUrl.trim()
123
- : undefined;
124
- const out = { accessToken, tokenType, savedAt };
125
- if (refreshToken)
126
- out.refreshToken = refreshToken;
127
- if (scope)
128
- out.scope = scope;
129
- if (expiresAt !== undefined)
130
- out.expiresAt = expiresAt;
131
- if (tokenEndpoint)
132
- out.tokenEndpoint = tokenEndpoint;
133
- if (clientId)
134
- out.clientId = clientId;
135
- if (clientSecret)
136
- out.clientSecret = clientSecret;
137
- if (authServerIssuer)
138
- out.authServerIssuer = authServerIssuer;
139
- if (redirectUri)
140
- out.redirectUri = redirectUri;
141
- if (resourceUrl)
142
- out.resourceUrl = resourceUrl;
109
+ const out = {
110
+ accessToken,
111
+ tokenType: trimmedString(raw.tokenType) ?? 'Bearer',
112
+ savedAt: finiteNumber(raw.savedAt) ?? clock.nowMs(),
113
+ };
114
+ for (const [key, read] of OPTIONAL_TOKEN_FIELDS) {
115
+ const value = read(raw[key]);
116
+ if (value !== undefined)
117
+ Object.assign(out, { [key]: value });
118
+ }
143
119
  return out;
144
120
  }
145
121
  /**
@@ -200,11 +200,11 @@ class McpHttpSession {
200
200
  const controller = new AbortController();
201
201
  const onCallerAbort = forwardAbort(controller, callerSignal);
202
202
  const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
203
+ let response;
203
204
  try {
204
- const response = await this.exchange.send({ url: this.spec.url, method: "POST", headers: await this.authenticatedHeaders(headers, controller.signal) }, { body, signal: controller.signal });
205
+ response = await this.exchange.send({ url: this.spec.url, method: "POST", headers: await this.authenticatedHeaders(headers, controller.signal) }, { body, signal: controller.signal });
205
206
  if (response.status === 401)
206
207
  await this.notifyAuthenticationChallenge({ response, signal: controller.signal });
207
- return response;
208
208
  }
209
209
  catch (error) {
210
210
  throw buildPostFailureError({
@@ -218,6 +218,7 @@ class McpHttpSession {
218
218
  clearTimeout(timer);
219
219
  callerSignal?.removeEventListener("abort", onCallerAbort);
220
220
  }
221
+ return response;
221
222
  }
222
223
  /** Observes a 401 within the POST's remaining budget, without superseding its typed auth error.
223
224
  * @complexity O(1) beyond the injected hook's work, bounded by the request signal.