@jini-ai/mcp 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -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 +114 -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 +69 -35
  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 +47 -0
  33. package/dist/server/tools/delegated-tool.js +35 -2
  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 +5 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @jini-ai/mcp
2
2
 
3
+ ## 0.4.4 — 2026-10-22
4
+
5
+ - 2026-10-22: Typed tools from the launched bridge, concrete describe schemas, list-changed, diagnostic error codes, coverage.
6
+
3
7
  ## 0.4.2
4
8
 
5
9
  ### 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,76 @@ 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,
103
151
  // Parsed, not validated: `createExecuteDelegatedToolTool` already rejects a non-finite or
104
152
  // 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 = [
153
+ delegatedToolTimeoutMs: rawTimeout === undefined ? undefined : Number(rawTimeout),
154
+ });
155
+ }
156
+ function buildTools({ runId, delegatedGatewayOptions }) {
157
+ return [
110
158
  ...RUN_TOOLS,
111
159
  ...TOOL_CATALOG_TOOLS,
112
160
  ...COMPONENT_CATALOG_TOOLS,
@@ -116,29 +164,52 @@ export async function serve(_requiredArgs, deps = {}) {
116
164
  // sees both and picks by what its call actually does.
117
165
  createExecuteReadonlyDelegatedToolTool({ runId }, delegatedGatewayOptions),
118
166
  ];
167
+ }
168
+ function buildServerOptions({ env, runId, delegatedGatewayOptions }, deps) {
119
169
  // Absent credential => no `authHeaders` at all => byte-identical request headers to before this
120
170
  // env var existed. See DAEMON_TOKEN_ENV_VAR's doc for why an unset value is not a startup failure.
121
171
  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 = {
172
+ return {
127
173
  resources: [...KERNEL_RESOURCES, createSiteGuideResource({ runId }, delegatedGatewayOptions)],
128
174
  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 } : {}),
175
+ ...definedOnly({
176
+ authHeaders: credential ? { Authorization: `Bearer ${credential}` } : undefined,
177
+ fetchImpl: deps.fetchImpl,
178
+ stdin: deps.stdin,
179
+ stdout: deps.stdout,
180
+ }),
133
181
  };
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 });
182
+ }
183
+ // Only the spawning host supplies registration metadata. Never learn exceptions from an
184
+ // error body or model arguments: either would turn a secret into its own redaction exemption.
185
+ function parseLaunchToolIds({ env, key }) {
186
+ const toolIds = JSON.parse(env[key] ?? '[]');
187
+ if (!Array.isArray(toolIds) || !toolIds.every(id => typeof id === 'string')) {
188
+ throw new Error(`${key} must be a JSON array of registered tool IDs`);
141
189
  }
190
+ return toolIds;
191
+ }
192
+ /**
193
+ * Resolve each unique startup ID through the existing authenticated describe/publication owner.
194
+ * The temporary descriptor list seeds M1's bounded exposure; it is not an execution catalog.
195
+ * Failure aborts startup before any partially resolved list can reach the client.
196
+ * @returns Authoritative descriptors; no daemon I/O when startup IDs are absent.
197
+ * @throws The existing descriptor/daemon errors, reported by serve's stderr/exit boundary.
198
+ * @complexity O(n + s) space/time plus n sequential bounded GETs for n unique IDs and s schema bytes.
199
+ */
200
+ async function resolveStartupTools({ toolIds, resolveBaseUrl, serverOptions }) {
201
+ if (toolIds.length === 0)
202
+ return [];
203
+ const descriptors = [];
204
+ const ctx = {
205
+ baseUrl: await resolveBaseUrl(),
206
+ fetchImpl: serverOptions.fetchImpl ?? fetch,
207
+ ...definedOnly({ authHeaders: serverOptions.authHeaders, diagnosticToolIds: serverOptions.diagnosticToolIds }),
208
+ publishTool: async ({ descriptor }) => { descriptors.push(descriptor); },
209
+ };
210
+ for (const id of new Set(toolIds))
211
+ await describeToolTool.handler({ args: { id }, ctx });
212
+ return descriptors;
142
213
  }
143
214
  // Only run for real when this module is the actual process entrypoint (`node dist/bin/serve.js`,
144
215
  // or the `jini-mcp` bin shim pnpm/npm links to it) — not merely imported, e.g. by this file's own