@jini-ai/mcp 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/README.md +97 -22
- package/dist/agent-install/install.d.ts +20 -4
- package/dist/agent-install/install.js +11 -11
- package/dist/bin/serve.d.ts +25 -13
- package/dist/bin/serve.js +42 -21
- package/dist/client/client.d.ts +28 -8
- package/dist/client/client.js +25 -21
- package/dist/core/config.d.ts +57 -18
- package/dist/core/config.js +76 -48
- package/dist/core/index.d.ts +1 -2
- package/dist/core/index.js +1 -2
- package/dist/core/install-info.d.ts +4 -1
- package/dist/core/install-info.js +5 -5
- package/dist/core/tokens.d.ts +39 -13
- package/dist/core/tokens.js +54 -24
- package/dist/federation/adapter.http.d.ts +67 -0
- package/dist/federation/adapter.http.js +414 -0
- package/dist/federation/approvals/call-confirmation.d.ts +23 -0
- package/dist/federation/approvals/call-confirmation.js +124 -0
- package/dist/federation/approvals/index.d.ts +4 -0
- package/dist/federation/approvals/index.js +4 -0
- package/dist/federation/approvals/ports.d.ts +107 -0
- package/dist/federation/approvals/ports.js +2 -0
- package/dist/federation/approvals/revocation.d.ts +61 -0
- package/dist/federation/approvals/revocation.js +52 -0
- package/dist/federation/bootstrap.d.ts +100 -0
- package/dist/federation/bootstrap.js +182 -0
- package/dist/federation/config.d.ts +68 -0
- package/dist/federation/config.js +40 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.js +14 -0
- package/dist/federation/mcp-protocol.d.ts +115 -0
- package/dist/federation/mcp-protocol.js +151 -0
- package/dist/federation/messages.d.ts +54 -0
- package/dist/federation/messages.js +35 -0
- package/dist/federation/ports.d.ts +287 -0
- package/dist/federation/ports.js +5 -0
- package/dist/federation/presets.d.ts +66 -0
- package/dist/federation/presets.js +30 -0
- package/dist/federation/refusal-notice.d.ts +198 -0
- package/dist/federation/refusal-notice.js +193 -0
- package/dist/federation/registrations.d.ts +171 -0
- package/dist/federation/registrations.js +181 -0
- package/dist/federation/reload.d.ts +112 -0
- package/dist/federation/reload.js +110 -0
- package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
- package/dist/federation/stdio/adapter.stdio.js +526 -0
- package/dist/federation/stdio/default-connect.d.ts +29 -0
- package/dist/federation/stdio/default-connect.js +92 -0
- package/dist/federation/stdio/index.d.ts +4 -0
- package/dist/federation/stdio/index.js +4 -0
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
- package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
- package/dist/federation/testing/adapter.memory.d.ts +177 -0
- package/dist/federation/testing/adapter.memory.js +189 -0
- package/dist/federation/testing/index.d.ts +3 -0
- package/dist/federation/testing/index.js +3 -0
- package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
- package/dist/federation/testing/tool-approvals.memory.js +38 -0
- package/dist/federation/tool-approvals.d.ts +97 -0
- package/dist/federation/tool-approvals.js +42 -0
- package/dist/federation/trust.d.ts +471 -0
- package/dist/federation/trust.js +707 -0
- package/dist/index.d.ts +175 -2
- package/dist/index.js +172 -2
- package/dist/server/daemon-client.d.ts +16 -4
- package/dist/server/daemon-client.js +41 -19
- package/dist/server/ports.d.ts +9 -0
- package/dist/server/ports.js +2 -0
- package/dist/server/resource-protocol.d.ts +14 -4
- package/dist/server/resource-protocol.js +6 -6
- package/dist/server/resources/active-resource.js +3 -3
- package/dist/server/sdk-transport.d.ts +11 -0
- package/dist/server/sdk-transport.js +31 -0
- package/dist/server/tool-protocol.d.ts +31 -9
- package/dist/server/tool-protocol.js +20 -18
- package/dist/server/tool-server.d.ts +60 -43
- package/dist/server/tool-server.js +51 -27
- package/dist/server/tools/component-catalog-tools.js +7 -7
- package/dist/server/tools/delegated-tool.d.ts +7 -4
- package/dist/server/tools/delegated-tool.js +14 -15
- package/dist/server/tools/run-tools.js +15 -15
- package/dist/server/tools/tool-catalog-tools.js +7 -7
- package/dist/tools/ask-choice/index.d.ts +123 -0
- package/dist/tools/ask-choice/index.js +98 -0
- package/dist/tools/ask-choice/pending.d.ts +11 -0
- package/dist/tools/ask-choice/pending.js +34 -0
- package/dist/tools/ask-choice/question.d.ts +16 -0
- package/dist/tools/ask-choice/question.js +60 -0
- package/dist/tools/ask-choice/schema.d.ts +91 -0
- package/dist/tools/ask-choice/schema.js +27 -0
- package/dist/tools/ask-choice/types.d.ts +106 -0
- package/dist/tools/ask-choice/types.js +2 -0
- package/package.json +43 -5
package/dist/core/config.js
CHANGED
|
@@ -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/
|
|
26
|
-
import {
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
|
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/
|
|
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 =
|
|
284
|
-
const resolvedCwd =
|
|
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
|
|
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(
|
|
493
|
+
real = normalizeAllowedDirectory(resolvePath({ filePath: dir }));
|
|
466
494
|
}
|
|
467
495
|
catch {
|
|
468
496
|
real = null;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
68
|
-
?
|
|
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
|
package/dist/core/tokens.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/dist/core/tokens.js
CHANGED
|
@@ -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
|
-
// `
|
|
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. `
|
|
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 {
|
|
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
|
-
:
|
|
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
|
|
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 `
|
|
156
|
-
await
|
|
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 =
|
|
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;
|