@jini-ai/mcp 0.4.3 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/dist/agent-install/install.js +102 -194
- package/dist/bin/serve.d.ts +4 -1
- package/dist/bin/serve.js +115 -43
- package/dist/client/client.js +71 -58
- package/dist/core/config.js +113 -149
- package/dist/core/tokens.js +29 -53
- package/dist/federation/adapter.http.js +3 -2
- package/dist/federation/approvals/call-confirmation.js +5 -70
- package/dist/federation/approvals/remembered-approval.d.ts +39 -0
- package/dist/federation/approvals/remembered-approval.js +98 -0
- package/dist/federation/approvals/revocation-reason.d.ts +9 -0
- package/dist/federation/approvals/revocation-reason.js +11 -0
- package/dist/federation/approvals/revocation.js +19 -13
- package/dist/federation/approvals/scope-option.d.ts +6 -0
- package/dist/federation/approvals/scope-option.js +6 -0
- package/dist/federation/stdio/adapter.stdio.js +14 -6
- package/dist/federation/stdio/default-connect.js +24 -27
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
- package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
- package/dist/federation/tool-approvals.js +3 -1
- package/dist/federation/trust.d.ts +23 -2
- package/dist/federation/trust.js +71 -36
- package/dist/server/daemon-client.d.ts +2 -0
- package/dist/server/daemon-client.js +86 -69
- package/dist/server/tool-arguments.d.ts +46 -0
- package/dist/server/tool-arguments.js +111 -0
- package/dist/server/tool-protocol.d.ts +62 -7
- package/dist/server/tool-protocol.js +30 -40
- package/dist/server/tool-server.d.ts +19 -0
- package/dist/server/tool-server.js +91 -19
- package/dist/server/tools/delegated-tool.d.ts +14 -0
- package/dist/server/tools/delegated-tool.js +43 -4
- package/dist/server/tools/tool-catalog-tools.js +30 -8
- package/dist/tools/ask-choice/index.d.ts +213 -14
- package/dist/tools/ask-choice/index.js +90 -46
- package/dist/tools/ask-choice/question.d.ts +5 -1
- package/dist/tools/ask-choice/question.js +36 -2
- package/dist/tools/ask-choice/schema.d.ts +196 -10
- package/dist/tools/ask-choice/schema.js +33 -12
- package/dist/tools/ask-choice/types.d.ts +8 -0
- package/package.json +7 -5
package/dist/client/client.js
CHANGED
|
@@ -162,38 +162,23 @@ function isHtmlLike(mime, fromPath) {
|
|
|
162
162
|
return true;
|
|
163
163
|
return /\.html?$/i.test(fromPath);
|
|
164
164
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 (
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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 =
|
|
360
|
+
let userAuth = false;
|
|
364
361
|
for (const [k, v] of Object.entries(existing ?? {})) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
|
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
|
|
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 ?? [])
|
package/dist/core/tokens.js
CHANGED
|
@@ -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 =
|
|
106
|
+
const accessToken = trimmedString(raw.accessToken);
|
|
89
107
|
if (!accessToken)
|
|
90
108
|
return null;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
: 'Bearer'
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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.
|