@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/agent-install/install.js +102 -194
  3. package/dist/bin/serve.d.ts +4 -1
  4. package/dist/bin/serve.js +115 -43
  5. package/dist/client/client.js +71 -58
  6. package/dist/core/config.js +113 -149
  7. package/dist/core/tokens.js +29 -53
  8. package/dist/federation/adapter.http.js +3 -2
  9. package/dist/federation/approvals/call-confirmation.js +5 -70
  10. package/dist/federation/approvals/remembered-approval.d.ts +39 -0
  11. package/dist/federation/approvals/remembered-approval.js +98 -0
  12. package/dist/federation/approvals/revocation-reason.d.ts +9 -0
  13. package/dist/federation/approvals/revocation-reason.js +11 -0
  14. package/dist/federation/approvals/revocation.js +19 -13
  15. package/dist/federation/approvals/scope-option.d.ts +6 -0
  16. package/dist/federation/approvals/scope-option.js +6 -0
  17. package/dist/federation/stdio/adapter.stdio.js +14 -6
  18. package/dist/federation/stdio/default-connect.js +24 -27
  19. package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
  20. package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
  21. package/dist/federation/tool-approvals.js +3 -1
  22. package/dist/federation/trust.d.ts +23 -2
  23. package/dist/federation/trust.js +71 -36
  24. package/dist/server/daemon-client.d.ts +2 -0
  25. package/dist/server/daemon-client.js +86 -69
  26. package/dist/server/tool-arguments.d.ts +46 -0
  27. package/dist/server/tool-arguments.js +111 -0
  28. package/dist/server/tool-protocol.d.ts +62 -7
  29. package/dist/server/tool-protocol.js +30 -40
  30. package/dist/server/tool-server.d.ts +19 -0
  31. package/dist/server/tool-server.js +91 -19
  32. package/dist/server/tools/delegated-tool.d.ts +14 -0
  33. package/dist/server/tools/delegated-tool.js +43 -4
  34. package/dist/server/tools/tool-catalog-tools.js +30 -8
  35. package/dist/tools/ask-choice/index.d.ts +213 -14
  36. package/dist/tools/ask-choice/index.js +90 -46
  37. package/dist/tools/ask-choice/question.d.ts +5 -1
  38. package/dist/tools/ask-choice/question.js +36 -2
  39. package/dist/tools/ask-choice/schema.d.ts +196 -10
  40. package/dist/tools/ask-choice/schema.js +33 -12
  41. package/dist/tools/ask-choice/types.d.ts +8 -0
  42. package/package.json +7 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # @jini-ai/mcp
2
2
 
3
+ ## 0.6.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 4f52784: Add a stable `./bin` export subpath resolving to the `jini-mcp` executable.
8
+
9
+ The package declared a `bin` entry for `dist/bin/serve.js` but an `exports` map containing only
10
+ `"."`, so a consumer that needed the executable's path in order to spawn it as a CLI-injected MCP
11
+ server could not ask for one: `require.resolve('@jini-ai/mcp/dist/bin/serve.js')` fails with
12
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`. Consumers were resolving the root export and walking to a sibling
13
+ path by hand, hardcoding this package's private build layout.
14
+
15
+ `require.resolve('@jini-ai/mcp/bin')` now returns that path directly. The subpath deliberately
16
+ declares only a `default` condition — it is meant to be resolved to a path and handed to `spawn`,
17
+ not imported (`serve.ts` uses top-level await behind its `isMainModule` guard, so it is not
18
+ `require()`-able). Purely additive: the `"."` export is unchanged.
19
+
20
+ - 8ff5653: Enforce a tool's declared `inputSchema` server-side before its handler runs.
21
+
22
+ `createMcpToolServer` wires tools through the MCP SDK's low-level `Server.setRequestHandler`, not
23
+ the schema-validating `McpServer.registerTool()` helper — the former never checked a real client's
24
+ `tools/call` arguments against the tool's own declared `inputSchema`. A wire-level test (a real SDK
25
+ `Client` and `Server` over `InMemoryTransport`, not the existing `FakeTransport` suite, which calls
26
+ handlers directly either way and so cannot see this) proved schema-violating arguments — wrong
27
+ type, an undeclared property under `additionalProperties: false` — reached every handler completely
28
+ unvalidated.
29
+
30
+ `handleToolCall` now validates `rawArgs` against `tool.inputSchema` via the MCP SDK's own
31
+ `@modelcontextprotocol/sdk/validation/cfworker` provider (no `eval`/codegen, matching this package's
32
+ Node CLI runtime) before invoking the handler, returning the same `{isError:true}` shape as an
33
+ unknown-tool-name or thrown-error result rather than ever calling the handler with unvalidated
34
+ input. Each tool's compiled validator is cached (`WeakMap` keyed by the `McpToolDef` instance, stable
35
+ for a server's whole lifetime) since compiling one is real work the SDK's validator does not cache
36
+ internally. Adds `@cfworker/json-schema` (already an optional peer of the installed SDK version) as
37
+ a direct dependency.
38
+
39
+ - 3fe4d14: Add default-off delegated-result v1 text markers for generic, read-only and typed gateways. The stdio host can opt in with JINI_DELEGATED_RESULT_MARKERS=1.
40
+ - 9098d22: Describe site-guide discovery as an optional host capability in the generic MCP instructions.
41
+ - 4f52784: Add a `sidecar-strict` security mode and per-run MCP credential propagation.
42
+
43
+ For a daemon whose threat model is **another process running as the same OS user** rather than a
44
+ remote attacker, the existing `jini-local` mode is a no-op: `registerApiBearerAuthMiddleware`
45
+ short-circuits for any loopback peer before it reads the `Authorization` header, and a `127.0.0.1`
46
+ bind keeps remote hosts out while doing nothing about a co-resident process. A consumer that spawns a
47
+ Jini daemon holding real authority — starting agent runs, executing tools against a real database —
48
+ previously had to write its own middleware.
49
+
50
+ - `@jini-ai/http-kit` gains `requireStrictBearerToken`: fail-closed 503 when the named token env var
51
+ is unset, 401 on mismatch, **no loopback exemption and no disable flag**. Its `tokenEnvVar` is
52
+ required with no default, so this package never names a host's secret.
53
+ - `composeJiniKernel` gains `security: { mode: 'sidecar-strict', host, tokenEnvVar, exemptPaths? }`.
54
+ Purely additive — `host` and `jini-local` are unchanged by construction, since the modes are arms of
55
+ a discriminated union. The strict gate mounts ahead of the JSON body parser, so a caller it rejects
56
+ never has its body parsed.
57
+ - `@jini-ai/daemon`'s `McpJsonInjectionOptions` gains `credential?: (runId) => string | Promise<string>`
58
+ — a **resolver, not a string**, because injection options are built once before any run exists and a
59
+ boot-wide shared secret would defeat the point of scoping a credential to a run. It is delivered to
60
+ the child as `JINI_DAEMON_TOKEN`.
61
+ - `@jini-ai/mcp`'s `jini-mcp` reads that variable and attaches `Authorization` to every daemon call.
62
+ Optional throughout: with no credential, request headers and `.mcp.json` output are byte-identical
63
+ to before.
64
+
65
+ Also generalized: both existing bearer gates now compare tokens in constant time (`timingSafeEqual`)
66
+ and share one header-parsing helper, closing a timing side channel and removing a duplicated regex.
67
+
68
+ - Updated dependencies [22f0619]
69
+ - Updated dependencies [82ed2d3]
70
+ - Updated dependencies [3fe4d14]
71
+ - Updated dependencies [5a3de86]
72
+ - @jini-ai/core@0.6.10
73
+ - @jini-ai/protocol@0.6.10
74
+
75
+ ## 0.4.4 — 2026-10-22
76
+
77
+ - 2026-10-22: Typed tools from the launched bridge, concrete describe schemas, list-changed, diagnostic error codes, coverage.
78
+
3
79
  ## 0.4.2
4
80
 
5
81
  ### Patch Changes
@@ -84,205 +84,122 @@ function envFlags(env, flag) {
84
84
  }
85
85
  return out;
86
86
  }
87
+ // Attaches the env map under `key` only when non-empty, so configs stay clean.
88
+ function withEnv(entry, env, key) {
89
+ if (Object.keys(env).length > 0)
90
+ entry[key] = env;
91
+ return entry;
92
+ }
87
93
  /** JSON server entry shared by the `env`-style agents (cursor / copilot /
88
94
  * cline / openclaw / antigravity / trae). `extra` carries per-agent
89
95
  * fields; env is only attached when non-empty so configs stay clean. */
90
- function jsonEntry(spec, extra = {}, envKey = 'env') {
91
- const entry = {
92
- command: spec.command,
93
- args: spec.args,
94
- ...extra,
95
- };
96
- if (Object.keys(spec.env).length > 0) {
97
- entry[envKey] = spec.env;
98
- }
99
- return entry;
96
+ function jsonEntry(spec, extra = {}) {
97
+ return withEnv({ command: spec.command, args: spec.args, ...extra }, spec.env, 'env');
98
+ }
99
+ function cliPlan(slug, bin, argv, serverName) {
100
+ return { kind: 'cli', slug, bin, ...argv, getArgv: ['mcp', 'get', serverName] };
101
+ }
102
+ function jsonPlan(slug, configPath, keyPath, serverKey, entry) {
103
+ return { kind: 'json', slug, configPath, keyPath, serverKey, entry };
100
104
  }
105
+ // One planner per slug. Typed as a total map over `AgentSlug`, so adding a slug
106
+ // without a planner is a compile error (the role the old switch's `never` default played).
107
+ const AGENT_PLANNERS = {
108
+ // ----- CLI-driven agents (idempotent via the agent's own tool) -----
109
+ claude: ({ spec, ctx: { serverName } }) => cliPlan('claude', 'claude', {
110
+ addArgv: [
111
+ 'mcp', 'add', '--scope', 'user',
112
+ serverName,
113
+ ...envFlags(spec.env, '-e'),
114
+ '--', spec.command, ...spec.args,
115
+ ],
116
+ removeArgv: ['mcp', 'remove', '--scope', 'user', serverName],
117
+ }, serverName),
118
+ codex: ({ spec, ctx: { serverName } }) => cliPlan('codex', 'codex', {
119
+ addArgv: [
120
+ 'mcp', 'add', serverName,
121
+ ...envFlags(spec.env, '--env'),
122
+ '--', spec.command, ...spec.args,
123
+ ],
124
+ removeArgv: ['mcp', 'remove', serverName],
125
+ }, serverName),
126
+ kimi: ({ spec, ctx: { serverName } }) => cliPlan('kimi', 'kimi', {
127
+ addArgv: [
128
+ 'mcp', 'add', '--transport', 'stdio',
129
+ ...envFlags(spec.env, '--env'),
130
+ serverName, '--', spec.command, ...spec.args,
131
+ ],
132
+ removeArgv: ['mcp', 'remove', serverName],
133
+ }, serverName),
134
+ // ----- JSON config-file agents (safe deep-merge) -----
135
+ cursor: ({ spec, ctx: { home, serverName } }) => jsonPlan('cursor', path.join(home, '.cursor', 'mcp.json'), ['mcpServers'], serverName, jsonEntry(spec, { type: 'stdio' })),
136
+ // GitHub Copilot CLI: ~/.copilot/mcp-config.json, type "local".
137
+ // (VS Code Copilot uses a different `servers` key in
138
+ // .vscode/mcp.json; the terminal installer targets the CLI.)
139
+ copilot: ({ spec, ctx: { home, serverName } }) => jsonPlan('copilot', path.join(home, '.copilot', 'mcp-config.json'), ['mcpServers'], serverName, jsonEntry(spec, { type: 'local', tools: ['*'] })),
140
+ cline: ({ spec, ctx: { home, platform, serverName }, appData }) => jsonPlan('cline', clineConfigPath(home, platform, appData), ['mcpServers'], serverName, jsonEntry(spec, { disabled: false, autoApprove: [] })),
141
+ // OpenCode nests under `mcp`, folds command+args into one array,
142
+ // and gates the server with `enabled`.
143
+ opencode: ({ spec, ctx: { home, serverName } }) => jsonPlan('opencode', path.join(home, '.config', 'opencode', 'opencode.json'), ['mcp'], serverName, withEnv({ type: 'local', command: [spec.command, ...spec.args], enabled: true }, spec.env, 'environment')),
144
+ openclaw: ({ spec, ctx: { home, serverName } }) => jsonPlan('openclaw', path.join(home, '.openclaw', 'openclaw.json'), ['mcp', 'servers'], serverName, jsonEntry(spec)),
145
+ antigravity: ({ spec, ctx: { home, serverName } }) => jsonPlan('antigravity', path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), ['mcpServers'], serverName, jsonEntry(spec)),
146
+ trae: ({ spec, ctx: { home, platform, serverName }, appData }) => jsonPlan('trae', traeConfigPath(home, platform, appData), ['mcpServers'], serverName, jsonEntry(spec)),
147
+ // ----- Unverified formats: print-only, never write -----
148
+ vibe: ({ spec, ctx: { home, serverName } }) => ({
149
+ kind: 'manual',
150
+ slug: 'vibe',
151
+ format: 'toml',
152
+ configPath: path.join(home, '.vibe', 'config.toml'),
153
+ snippet: vibeTomlSnippet(spec, serverName),
154
+ reason: 'Vibe uses a TOML array-of-tables ([[mcp_servers]]); ' +
155
+ 'its exact schema is unverified, so append this block by hand ' +
156
+ 'to avoid corrupting an existing config.',
157
+ }),
158
+ pi: ({ spec, ctx: { home, serverName } }) => ({
159
+ kind: 'manual',
160
+ slug: 'pi',
161
+ format: 'json',
162
+ configPath: path.join(home, '.pi', 'agent', 'mcp.json'),
163
+ snippet: JSON.stringify({ mcpServers: { [serverName]: jsonEntry(spec) } }, null, 2),
164
+ reason: 'The pi coding agent exposes MCP, but its config path/schema is ' +
165
+ 'not authoritatively documented. Paste this into pi’s MCP ' +
166
+ 'config (check `pi --help` for the exact location).',
167
+ }),
168
+ hermes: ({ spec, ctx: { home, serverName } }) => ({
169
+ kind: 'manual',
170
+ slug: 'hermes',
171
+ format: 'yaml',
172
+ configPath: path.join(home, '.hermes', 'config.yaml'),
173
+ snippet: hermesYamlSnippet(spec, serverName),
174
+ reason: 'Hermes config format is unverified. Add this under your Hermes ' +
175
+ 'MCP server configuration by hand.',
176
+ }),
177
+ };
101
178
  /**
102
179
  * Build the registration plan for one agent. Pure — all IO happens in the
103
180
  * executor. Throws on an unknown slug so callers surface a clear error.
104
181
  */
105
182
  export function planAgentInstall({ slug, spec, ctx }, { env = process.env } = {}) {
106
- const { home, platform, serverName } = ctx;
107
- switch (slug) {
108
- // ----- CLI-driven agents (idempotent via the agent's own tool) -----
109
- case 'claude':
110
- return {
111
- kind: 'cli',
112
- slug,
113
- bin: 'claude',
114
- addArgv: [
115
- 'mcp', 'add', '--scope', 'user',
116
- serverName,
117
- ...envFlags(spec.env, '-e'),
118
- '--', spec.command, ...spec.args,
119
- ],
120
- removeArgv: ['mcp', 'remove', '--scope', 'user', serverName],
121
- getArgv: ['mcp', 'get', serverName],
122
- };
123
- case 'codex':
124
- return {
125
- kind: 'cli',
126
- slug,
127
- bin: 'codex',
128
- addArgv: [
129
- 'mcp', 'add', serverName,
130
- ...envFlags(spec.env, '--env'),
131
- '--', spec.command, ...spec.args,
132
- ],
133
- removeArgv: ['mcp', 'remove', serverName],
134
- getArgv: ['mcp', 'get', serverName],
135
- };
136
- case 'kimi':
137
- return {
138
- kind: 'cli',
139
- slug,
140
- bin: 'kimi',
141
- addArgv: [
142
- 'mcp', 'add', '--transport', 'stdio',
143
- ...envFlags(spec.env, '--env'),
144
- serverName, '--', spec.command, ...spec.args,
145
- ],
146
- removeArgv: ['mcp', 'remove', serverName],
147
- getArgv: ['mcp', 'get', serverName],
148
- };
149
- // ----- JSON config-file agents (safe deep-merge) -----
150
- case 'cursor':
151
- return {
152
- kind: 'json',
153
- slug,
154
- configPath: path.join(home, '.cursor', 'mcp.json'),
155
- keyPath: ['mcpServers'],
156
- serverKey: serverName,
157
- entry: jsonEntry(spec, { type: 'stdio' }),
158
- };
159
- case 'copilot':
160
- // GitHub Copilot CLI: ~/.copilot/mcp-config.json, type "local".
161
- // (VS Code Copilot uses a different `servers` key in
162
- // .vscode/mcp.json; the terminal installer targets the CLI.)
163
- return {
164
- kind: 'json',
165
- slug,
166
- configPath: path.join(home, '.copilot', 'mcp-config.json'),
167
- keyPath: ['mcpServers'],
168
- serverKey: serverName,
169
- entry: jsonEntry(spec, { type: 'local', tools: ['*'] }),
170
- };
171
- case 'cline':
172
- return {
173
- kind: 'json',
174
- slug,
175
- configPath: clineConfigPath(home, platform, env.APPDATA),
176
- keyPath: ['mcpServers'],
177
- serverKey: serverName,
178
- entry: jsonEntry(spec, { disabled: false, autoApprove: [] }),
179
- };
180
- case 'opencode':
181
- // OpenCode nests under `mcp`, folds command+args into one array,
182
- // and gates the server with `enabled`.
183
- return {
184
- kind: 'json',
185
- slug,
186
- configPath: path.join(home, '.config', 'opencode', 'opencode.json'),
187
- keyPath: ['mcp'],
188
- serverKey: serverName,
189
- entry: (() => {
190
- const e = {
191
- type: 'local',
192
- command: [spec.command, ...spec.args],
193
- enabled: true,
194
- };
195
- if (Object.keys(spec.env).length > 0)
196
- e.environment = spec.env;
197
- return e;
198
- })(),
199
- };
200
- case 'openclaw':
201
- return {
202
- kind: 'json',
203
- slug,
204
- configPath: path.join(home, '.openclaw', 'openclaw.json'),
205
- keyPath: ['mcp', 'servers'],
206
- serverKey: serverName,
207
- entry: jsonEntry(spec),
208
- };
209
- case 'antigravity':
210
- return {
211
- kind: 'json',
212
- slug,
213
- configPath: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
214
- keyPath: ['mcpServers'],
215
- serverKey: serverName,
216
- entry: jsonEntry(spec),
217
- };
218
- case 'trae':
219
- return {
220
- kind: 'json',
221
- slug,
222
- configPath: traeConfigPath(home, platform, env.APPDATA),
223
- keyPath: ['mcpServers'],
224
- serverKey: serverName,
225
- entry: jsonEntry(spec),
226
- };
227
- // ----- Unverified formats: print-only, never write -----
228
- case 'vibe':
229
- return {
230
- kind: 'manual',
231
- slug,
232
- format: 'toml',
233
- configPath: path.join(home, '.vibe', 'config.toml'),
234
- snippet: vibeTomlSnippet(spec, serverName),
235
- reason: 'Vibe uses a TOML array-of-tables ([[mcp_servers]]); ' +
236
- 'its exact schema is unverified, so append this block by hand ' +
237
- 'to avoid corrupting an existing config.',
238
- };
239
- case 'pi':
240
- return {
241
- kind: 'manual',
242
- slug,
243
- format: 'json',
244
- configPath: path.join(home, '.pi', 'agent', 'mcp.json'),
245
- snippet: genericMcpServersSnippet(spec, serverName),
246
- reason: 'The pi coding agent exposes MCP, but its config path/schema is ' +
247
- 'not authoritatively documented. Paste this into pi’s MCP ' +
248
- 'config (check `pi --help` for the exact location).',
249
- };
250
- case 'hermes':
251
- return {
252
- kind: 'manual',
253
- slug,
254
- format: 'yaml',
255
- configPath: path.join(home, '.hermes', 'config.yaml'),
256
- snippet: hermesYamlSnippet(spec, serverName),
257
- reason: 'Hermes config format is unverified. Add this under your Hermes ' +
258
- 'MCP server configuration by hand.',
259
- };
260
- default: {
261
- const exhaustive = slug;
262
- throw new Error(`unknown agent slug: ${String(exhaustive)}`);
263
- }
183
+ // Own-property lookup only: a slug such as `constructor` or `__proto__` must not
184
+ // resolve to an inherited Object.prototype member and be called as a planner.
185
+ if (!Object.prototype.hasOwnProperty.call(AGENT_PLANNERS, slug)) {
186
+ throw new Error(`unknown agent slug: ${String(slug)}`);
264
187
  }
188
+ return AGENT_PLANNERS[slug]({ spec, ctx, appData: env.APPDATA });
189
+ }
190
+ // VS Code-family editors keep per-user state under one platform-specific root.
191
+ function editorUserDir(app, home, platform, suppliedAppData) {
192
+ if (platform === 'darwin')
193
+ return path.join(home, 'Library', 'Application Support', app, 'User');
194
+ if (platform === 'win32')
195
+ return path.join(suppliedAppData ?? path.join(home, 'AppData', 'Roaming'), app, 'User');
196
+ return path.join(home, '.config', app, 'User');
265
197
  }
266
198
  function clineConfigPath(home, platform, suppliedAppData) {
267
- const rel = path.join('globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json');
268
- if (platform === 'darwin') {
269
- return path.join(home, 'Library', 'Application Support', 'Code', 'User', rel);
270
- }
271
- if (platform === 'win32') {
272
- const appData = suppliedAppData ?? path.join(home, 'AppData', 'Roaming');
273
- return path.join(appData, 'Code', 'User', rel);
274
- }
275
- return path.join(home, '.config', 'Code', 'User', rel);
199
+ return path.join(editorUserDir('Code', home, platform, suppliedAppData), 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json');
276
200
  }
277
201
  function traeConfigPath(home, platform, suppliedAppData) {
278
- if (platform === 'darwin') {
279
- return path.join(home, 'Library', 'Application Support', 'Trae', 'User', 'mcp.json');
280
- }
281
- if (platform === 'win32') {
282
- const appData = suppliedAppData ?? path.join(home, 'AppData', 'Roaming');
283
- return path.join(appData, 'Trae', 'User', 'mcp.json');
284
- }
285
- return path.join(home, '.config', 'Trae', 'User', 'mcp.json');
202
+ return path.join(editorUserDir('Trae', home, platform, suppliedAppData), 'mcp.json');
286
203
  }
287
204
  // --- Pure JSON merge / removal (the heart of the 'json' strategy) -------
288
205
  // CR-007: `JsonInstallPlan` is a public shape and `applyJsonInstall` /
@@ -372,15 +289,6 @@ function parseJsonObject(text, where) {
372
289
  return parsed;
373
290
  }
374
291
  // --- Snippets for the manual (print-only) strategy ----------------------
375
- function genericMcpServersSnippet(spec, name) {
376
- const server = {
377
- command: spec.command,
378
- args: spec.args,
379
- };
380
- if (Object.keys(spec.env).length > 0)
381
- server.env = spec.env;
382
- return JSON.stringify({ mcpServers: { [name]: server } }, null, 2);
383
- }
384
292
  function hermesYamlSnippet(spec, name) {
385
293
  const lines = [
386
294
  'mcp_servers:',
@@ -67,6 +67,8 @@ export declare const DAEMON_TOKEN_ENV_VAR = "JINI_DAEMON_TOKEN";
67
67
  * policy that would make a given number correct, so it must not refuse to boot over one.
68
68
  */
69
69
  export declare const DELEGATED_TOOL_TIMEOUT_ENV_VAR = "JINI_DELEGATED_TOOL_TIMEOUT_MS";
70
+ /** JSON array of host-selected visible registry IDs; never sourced from diagnostic IDs. */
71
+ export declare const STARTUP_TOOL_IDS_ENV_VAR = "JINI_STARTUP_TOOL_IDS";
70
72
  export interface ServeDeps {
71
73
  /** @default process.env */
72
74
  readonly env?: NodeJS.ProcessEnv;
@@ -105,7 +107,8 @@ export interface McpDaemonUrlOptions {
105
107
  * has no meaningful default run to serve) plus `DAEMON_URL_ENV_VAR` resolution, then hosts
106
108
  * `RUN_TOOLS` (run-agnostic) alongside a fresh `execute_delegated_tool` def scoped to the resolved
107
109
  * `runId`, and `KERNEL_RESOURCES`. Resolves once the client disconnects (stdin EOF) or the
108
- * idle-exit window elapses (`createMcpToolServer`'s own default) — never bare-throws: every error
110
+ * idle-exit window elapses (`createMcpToolServer`'s own default). Enables describe publication and
111
+ * resolves startup descriptors before connecting stdio. Never bare-throws: every error
109
112
  * path below writes a message to `writeErr` and calls `exit(1)` instead.
110
113
  */
111
114
  export declare function serve(_requiredArgs: Record<string, never>, deps?: ServeDeps): Promise<void>;
package/dist/bin/serve.js CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
3
3
  import { isLoopbackHostname } from '@jini-ai/platform/net';
4
4
  import { createMcpToolServer as createMcpToolServerDefault } from '../server/tool-server.js';
5
5
  import { RUN_TOOLS } from '../server/tools/run-tools.js';
6
- import { TOOL_CATALOG_TOOLS } from '../server/tools/tool-catalog-tools.js';
6
+ import { describeToolTool, TOOL_CATALOG_TOOLS } from '../server/tools/tool-catalog-tools.js';
7
7
  import { COMPONENT_CATALOG_TOOLS } from '../server/tools/component-catalog-tools.js';
8
8
  import { createExecuteDelegatedToolTool, createExecuteReadonlyDelegatedToolTool } from '../server/tools/delegated-tool.js';
9
9
  import { KERNEL_RESOURCES } from '../server/resources/active-resource.js';
@@ -37,11 +37,15 @@ export const DAEMON_TOKEN_ENV_VAR = 'JINI_DAEMON_TOKEN';
37
37
  * policy that would make a given number correct, so it must not refuse to boot over one.
38
38
  */
39
39
  export const DELEGATED_TOOL_TIMEOUT_ENV_VAR = 'JINI_DELEGATED_TOOL_TIMEOUT_MS';
40
+ /** JSON array of host-selected visible registry IDs; never sourced from diagnostic IDs. */
41
+ export const STARTUP_TOOL_IDS_ENV_VAR = 'JINI_STARTUP_TOOL_IDS';
40
42
  const SERVER_NAME = 'jini-mcp';
41
43
  const SERVER_VERSION = '0.0.0';
42
44
  const INSTRUCTIONS = 'Tools proxying this host\'s Jini daemon over loopback HTTP, scoped to the run that spawned this MCP server process. ' +
43
45
  'Use execute_delegated_tool to invoke a Jini-registered tool (never an agent-vendor-specific tool name) against that run — ' +
44
46
  'every call is routed through the daemon\'s deny-by-default ToolExecutor gate. ' +
47
+ 'Concrete operation tools accept their own arguments directly. describe_tool publishes a concrete definition; ' +
48
+ 'refresh tools/list when supported, or use the delegated gateways as the compatibility fallback. ' +
45
49
  'When the host provides site_guide, consult jini://site-guide or call site_guide with operation overview ' +
46
50
  'through execute_readonly_delegated_tool; follow with explain for the relevant topic ids. ' +
47
51
  'If the host does not provide site_guide, report that availability gap.';
@@ -49,9 +53,10 @@ const INSTRUCTIONS = 'Tools proxying this host\'s Jini daemon over loopback HTTP
49
53
  // runtime dependency on another shell. No discovery/default URL is selected for a run child.
50
54
  async function resolveDaemonUrlDefault(_required, options) {
51
55
  const url = options.env[options.envVarName];
52
- if (!url)
53
- throw new Error('no daemon URL resolved: pass flagUrl, set envVarName on a populated env var, ' +
54
- 'supply a discover() probe, or provide defaultUrl.');
56
+ if (!url) {
57
+ throw new Error(`no daemon URL resolved: ${options.envVarName} is unset or empty; ` +
58
+ 'the spawning Jini daemon must set it to its HTTP base URL.');
59
+ }
55
60
  let parsed;
56
61
  try {
57
62
  parsed = new URL(url);
@@ -60,16 +65,18 @@ async function resolveDaemonUrlDefault(_required, options) {
60
65
  return url;
61
66
  }
62
67
  if (parsed.protocol !== 'https:' && !isLoopbackHostname({ hostname: parsed.hostname })) {
63
- let display = url;
64
- if (parsed.username || parsed.password) {
65
- parsed.username = '';
66
- parsed.password = '';
67
- display = parsed.toString();
68
- }
69
- options.warn({ message: `daemon URL ${display} is neither loopback nor HTTPS; traffic to a remote, non-HTTPS daemon is not encrypted.` });
68
+ options.warn({ message: `daemon URL ${redactUserinfo(url, parsed)} is neither loopback nor HTTPS; traffic to a remote, non-HTTPS daemon is not encrypted.` });
70
69
  }
71
70
  return url;
72
71
  }
72
+ // Keeps credentials out of the warning; a URL without userinfo is shown exactly as given.
73
+ function redactUserinfo(url, parsed) {
74
+ if (!parsed.username && !parsed.password)
75
+ return url;
76
+ parsed.username = '';
77
+ parsed.password = '';
78
+ return parsed.toString();
79
+ }
73
80
  function defaultWriteErr({ text }) {
74
81
  process.stderr.write(text);
75
82
  }
@@ -78,35 +85,77 @@ function defaultWriteErr({ text }) {
78
85
  * has no meaningful default run to serve) plus `DAEMON_URL_ENV_VAR` resolution, then hosts
79
86
  * `RUN_TOOLS` (run-agnostic) alongside a fresh `execute_delegated_tool` def scoped to the resolved
80
87
  * `runId`, and `KERNEL_RESOURCES`. Resolves once the client disconnects (stdin EOF) or the
81
- * idle-exit window elapses (`createMcpToolServer`'s own default) — never bare-throws: every error
88
+ * idle-exit window elapses (`createMcpToolServer`'s own default). Enables describe publication and
89
+ * resolves startup descriptors before connecting stdio. Never bare-throws: every error
82
90
  * path below writes a message to `writeErr` and calls `exit(1)` instead.
83
91
  */
84
92
  export async function serve(_requiredArgs, deps = {}) {
85
- const env = deps.env ?? process.env;
86
- const writeErr = deps.writeErr ?? defaultWriteErr;
87
- const exit = deps.exit ?? (({ code }) => process.exit(code));
88
- const resolveDaemonUrlFn = deps.resolveDaemonUrl ?? resolveDaemonUrlDefault;
89
- const createMcpToolServerFn = deps.createMcpToolServer ?? createMcpToolServerDefault;
93
+ const { env, writeErr, exit, resolveDaemonUrlFn, createMcpToolServerFn } = withProcessDefaults(deps);
90
94
  const runId = env[RUN_ID_ENV_VAR];
91
95
  if (typeof runId !== 'string' || runId.length === 0) {
92
96
  writeErr({ text: `${SERVER_NAME}: missing required ${RUN_ID_ENV_VAR} environment variable (this process must be spawned by a Jini daemon, which sets it)\n` });
93
97
  return exit({ code: 1 });
94
98
  }
99
+ let baseUrlResolution;
95
100
  const resolveBaseUrl = () => {
96
101
  const options = { flagUrl: null, env, envVarName: DAEMON_URL_ENV_VAR, warn: ({ message }) => writeErr({ text: message }) };
97
- return resolveDaemonUrlFn({}, options);
102
+ // Startup discovery and subsequent execution must use the same resolved daemon.
103
+ return baseUrlResolution ??= resolveDaemonUrlFn({}, options);
104
+ };
105
+ const delegatedGatewayOptions = buildDelegatedGatewayOptions({ env }, deps);
106
+ const requiredArgs = {
107
+ name: SERVER_NAME,
108
+ version: SERVER_VERSION,
109
+ tools: buildTools({ runId, delegatedGatewayOptions }),
110
+ resolveBaseUrl,
111
+ };
112
+ const serverOptions = buildServerOptions({ env, runId, delegatedGatewayOptions }, deps);
113
+ try {
114
+ const diagnosticToolIds = parseLaunchToolIds({ env, key: 'JINI_DIAGNOSTIC_TOOL_IDS' });
115
+ if (diagnosticToolIds.length)
116
+ Object.assign(serverOptions, { diagnosticToolIds });
117
+ const initialTools = await resolveStartupTools({
118
+ toolIds: parseLaunchToolIds({ env, key: STARTUP_TOOL_IDS_ENV_VAR }), resolveBaseUrl, serverOptions,
119
+ });
120
+ Object.assign(serverOptions, { delegatedTools: { runId, ...delegatedGatewayOptions, initialTools } });
121
+ await createMcpToolServerFn(requiredArgs, serverOptions).run({});
122
+ }
123
+ catch (err) {
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ writeErr({ text: `${SERVER_NAME}: ${message}\n` });
126
+ return exit({ code: 1 });
127
+ }
128
+ }
129
+ // The process-bound fallbacks for every injectable seam `serve()` itself calls.
130
+ function withProcessDefaults(deps) {
131
+ return {
132
+ env: deps.env ?? process.env,
133
+ writeErr: deps.writeErr ?? defaultWriteErr,
134
+ exit: deps.exit ?? (({ code }) => process.exit(code)),
135
+ resolveDaemonUrlFn: deps.resolveDaemonUrl ?? resolveDaemonUrlDefault,
136
+ createMcpToolServerFn: deps.createMcpToolServer ?? createMcpToolServerDefault,
98
137
  };
99
- // Shared by both gateways so their run scope, correlation-id source, and deadline cannot diverge
100
- // — the only difference between the two defs is the read-only constraint each declares.
101
- const delegatedGatewayOptions = {
102
- ...(deps.generateToolUseId !== undefined ? { generateToolUseId: deps.generateToolUseId } : {}),
138
+ }
139
+ // Drops `undefined` values so an absent dependency leaves no key at all — required under
140
+ // `exactOptionalPropertyTypes`, and it keeps built option objects byte-identical to the
141
+ // pre-injection shape when nothing is overridden.
142
+ function definedOnly(entries) {
143
+ return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined));
144
+ }
145
+ // Shared by both gateways so their run scope, correlation-id source, and deadline cannot diverge
146
+ // — the only difference between the two defs is the read-only constraint each declares.
147
+ function buildDelegatedGatewayOptions({ env }, deps) {
148
+ const rawTimeout = env[DELEGATED_TOOL_TIMEOUT_ENV_VAR];
149
+ return definedOnly({
150
+ generateToolUseId: deps.generateToolUseId,
151
+ delegatedResultMarkers: env.JINI_DELEGATED_RESULT_MARKERS === '1' ? true : undefined,
103
152
  // Parsed, not validated: `createExecuteDelegatedToolTool` already rejects a non-finite or
104
153
  // non-positive value back to the default, so `NaN` from a malformed env var lands there too.
105
- ...(env[DELEGATED_TOOL_TIMEOUT_ENV_VAR] !== undefined
106
- ? { delegatedToolTimeoutMs: Number(env[DELEGATED_TOOL_TIMEOUT_ENV_VAR]) }
107
- : {}),
108
- };
109
- const tools = [
154
+ delegatedToolTimeoutMs: rawTimeout === undefined ? undefined : Number(rawTimeout),
155
+ });
156
+ }
157
+ function buildTools({ runId, delegatedGatewayOptions }) {
158
+ return [
110
159
  ...RUN_TOOLS,
111
160
  ...TOOL_CATALOG_TOOLS,
112
161
  ...COMPONENT_CATALOG_TOOLS,
@@ -116,29 +165,52 @@ export async function serve(_requiredArgs, deps = {}) {
116
165
  // sees both and picks by what its call actually does.
117
166
  createExecuteReadonlyDelegatedToolTool({ runId }, delegatedGatewayOptions),
118
167
  ];
168
+ }
169
+ function buildServerOptions({ env, runId, delegatedGatewayOptions }, deps) {
119
170
  // Absent credential => no `authHeaders` at all => byte-identical request headers to before this
120
171
  // env var existed. See DAEMON_TOKEN_ENV_VAR's doc for why an unset value is not a startup failure.
121
172
  const credential = env[DAEMON_TOKEN_ENV_VAR];
122
- const authHeaders = typeof credential === 'string' && credential.length > 0
123
- ? { Authorization: `Bearer ${credential}` }
124
- : undefined;
125
- const requiredArgs = { name: SERVER_NAME, version: SERVER_VERSION, tools, resolveBaseUrl };
126
- const serverOptions = {
173
+ return {
127
174
  resources: [...KERNEL_RESOURCES, createSiteGuideResource({ runId }, delegatedGatewayOptions)],
128
175
  instructions: INSTRUCTIONS,
129
- ...(authHeaders !== undefined ? { authHeaders } : {}),
130
- ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
131
- ...(deps.stdin !== undefined ? { stdin: deps.stdin } : {}),
132
- ...(deps.stdout !== undefined ? { stdout: deps.stdout } : {}),
176
+ ...definedOnly({
177
+ authHeaders: credential ? { Authorization: `Bearer ${credential}` } : undefined,
178
+ fetchImpl: deps.fetchImpl,
179
+ stdin: deps.stdin,
180
+ stdout: deps.stdout,
181
+ }),
133
182
  };
134
- try {
135
- await createMcpToolServerFn(requiredArgs, serverOptions).run({});
136
- }
137
- catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
139
- writeErr({ text: `${SERVER_NAME}: ${message}\n` });
140
- return exit({ code: 1 });
183
+ }
184
+ // Only the spawning host supplies registration metadata. Never learn exceptions from an
185
+ // error body or model arguments: either would turn a secret into its own redaction exemption.
186
+ function parseLaunchToolIds({ env, key }) {
187
+ const toolIds = JSON.parse(env[key] ?? '[]');
188
+ if (!Array.isArray(toolIds) || !toolIds.every(id => typeof id === 'string')) {
189
+ throw new Error(`${key} must be a JSON array of registered tool IDs`);
141
190
  }
191
+ return toolIds;
192
+ }
193
+ /**
194
+ * Resolve each unique startup ID through the existing authenticated describe/publication owner.
195
+ * The temporary descriptor list seeds M1's bounded exposure; it is not an execution catalog.
196
+ * Failure aborts startup before any partially resolved list can reach the client.
197
+ * @returns Authoritative descriptors; no daemon I/O when startup IDs are absent.
198
+ * @throws The existing descriptor/daemon errors, reported by serve's stderr/exit boundary.
199
+ * @complexity O(n + s) space/time plus n sequential bounded GETs for n unique IDs and s schema bytes.
200
+ */
201
+ async function resolveStartupTools({ toolIds, resolveBaseUrl, serverOptions }) {
202
+ if (toolIds.length === 0)
203
+ return [];
204
+ const descriptors = [];
205
+ const ctx = {
206
+ baseUrl: await resolveBaseUrl(),
207
+ fetchImpl: serverOptions.fetchImpl ?? fetch,
208
+ ...definedOnly({ authHeaders: serverOptions.authHeaders, diagnosticToolIds: serverOptions.diagnosticToolIds }),
209
+ publishTool: async ({ descriptor }) => { descriptors.push(descriptor); },
210
+ };
211
+ for (const id of new Set(toolIds))
212
+ await describeToolTool.handler({ args: { id }, ctx });
213
+ return descriptors;
142
214
  }
143
215
  // Only run for real when this module is the actual process entrypoint (`node dist/bin/serve.js`,
144
216
  // or the `jini-mcp` bin shim pnpm/npm links to it) — not merely imported, e.g. by this file's own