@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
@@ -1,6 +1,60 @@
1
+ /**
2
+ * @module @jini-ai/mcp/server/tool-protocol
3
+ *
4
+ * The pure, SDK-connection-free half of the MCP tool-hosting mechanism: the
5
+ * `McpToolDef` contract a caller registers tools against, `tools/list`
6
+ * projection, and `tools/call` dispatch (look up a tool by name, run its
7
+ * handler, wrap the result). None of this touches `@modelcontextprotocol/sdk`'s
8
+ * `Server`/transport classes or the network — a tool `handler` is just
9
+ * `({ args, ctx }) => value | Promise<value>` that either returns a
10
+ * JSON-serializable payload (wrapped as a successful MCP result) or throws
11
+ * (wrapped as an `{isError:true}` MCP result). `./tool-server.js` is the thin
12
+ * layer that wires this to a real `Server` + `StdioServerTransport`.
13
+ *
14
+ * ## The one exception to "wrapped as a successful MCP result"
15
+ *
16
+ * {@link okResult} used to JSON.stringify every non-string payload unconditionally, which is exactly
17
+ * right for the overwhelming majority of handlers (plain JSON — an id, a list, a count) but wrong
18
+ * for a handler whose payload IS already a well-formed MCP content envelope (`{content: [...]}` of
19
+ * recognized block types). Stringifying that flattens a typed `image` block into an inert
20
+ * base64-in-quotes string the client can no longer render or hand to a vision model — the exact bug
21
+ * `execute_delegated_tool` (`../tools/delegated-tool.js`) hit: `@jini-ai/daemon`'s
22
+ * `delegated-tool-bridge.ts` already keeps an image block in its returned `ToolExecutionResult.output`
23
+ * (it is model-safe per that package's `tool-result-surfaces.ts`), but this module still turned it
24
+ * back into text on the way out. It is also how a **human-confirmation token reached model-visible
25
+ * text** in a separate, traced incident ( Decision 5): a `resource` block carrying the token
26
+ * fell to the same stringify path because an earlier version of this fix only recognized `text` and
27
+ * `image`, and `resource` was not yet in that hand-rolled allowlist.
28
+ *
29
+ * {@link okResult} now recognizes a `content` array whose every entry validates against the pinned
30
+ * `@modelcontextprotocol/sdk`'s own `ContentBlockSchema` — the same union the SDK itself defines
31
+ * `CallToolResultSchema.content` against — and passes such an envelope through verbatim instead of
32
+ * stringifying it. Deferring to the SDK's schema rather than re-implementing a per-type shape check
33
+ * here means every block type the pinned protocol version defines (`text`, `image`, `audio`,
34
+ * `resource`, `resource_link` as of `@modelcontextprotocol/sdk@^1.29.0`) is recognized automatically,
35
+ * and a future SDK upgrade that adds another block type is picked up the same way, without another
36
+ * hand-edit to this module. Whitelist, not blacklist: a `content` array holding even one entry that
37
+ * does not validate falls back to the stringify path, so a malformed block is never forwarded as if it
38
+ * were valid protocol output. What this does **not** guarantee: a block type the pinned SDK does not
39
+ * yet know about (a real future MCP protocol addition ahead of this dependency) still falls back to
40
+ * stringify until the SDK dependency itself is upgraded — this fix converts "remember to update this
41
+ * allowlist" into "remember to bump the SDK", not into an unconditional guarantee. It also does not
42
+ * (and cannot) judge intent: a handler that puts a secret inside an otherwise well-formed `text` block
43
+ * will still have it pass through as designed — withholding a value from the model is the handler's
44
+ * responsibility ( Decision 4), not this generic wrapper's.
45
+ * See docs/decisions/DR-002-protocol-resource-boundary.md.
46
+ */
47
+ import { type ToolDescriptor } from '@jini-ai/core';
1
48
  import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
49
+ import { type McpToolArgumentContract } from './tool-arguments.js';
2
50
  /** What every tool handler receives alongside its parsed arguments. */
3
51
  export interface McpToolContext {
52
+ /** Publishes a successfully described, host-owned descriptor in this server run only. */
53
+ readonly publishTool?: (required: {
54
+ descriptor: ToolDescriptor;
55
+ }, optional?: Record<string, never>) => Promise<void>;
56
+ /** Host registration metadata, supplied outside model-controlled arguments. */
57
+ readonly diagnosticToolIds?: readonly string[];
4
58
  /** The resolved daemon HTTP base URL (no trailing slash), fixed for the lifetime of one server run. */
5
59
  readonly baseUrl: string;
6
60
  /** Defaults to the global `fetch`; threaded through so a host can inject its own (e.g. for tests). */
@@ -42,6 +96,7 @@ export declare function daemonCallOptions({ ctx }: {
42
96
  fetchImpl: typeof fetch;
43
97
  headers?: Record<string, string>;
44
98
  signal?: AbortSignal;
99
+ diagnosticToolIds?: readonly string[];
45
100
  };
46
101
  /**
47
102
  * One MCP tool a `createMcpToolServer` caller registers. `name` must be unique within a given
@@ -51,10 +106,9 @@ export declare function daemonCallOptions({ ctx }: {
51
106
  * `CallToolResult` shape by {@link handleToolCall}, so individual tools never construct MCP
52
107
  * protocol objects themselves.
53
108
  */
54
- export interface McpToolDef<Args extends Record<string, unknown> = Record<string, unknown>> {
109
+ export interface McpToolDef<Args extends Record<string, unknown> = Record<string, unknown>> extends McpToolArgumentContract {
55
110
  readonly name: string;
56
111
  readonly description: string;
57
- readonly inputSchema: Tool['inputSchema'];
58
112
  readonly annotations?: Tool['annotations'];
59
113
  readonly handler: (requiredArgs: {
60
114
  args: Args;
@@ -62,7 +116,7 @@ export interface McpToolDef<Args extends Record<string, unknown> = Record<string
62
116
  }) => Promise<unknown> | unknown;
63
117
  }
64
118
  /**
65
- * Wraps a successful tool result as an MCP `CallToolResult`.
119
+ * Wraps a tool result as an MCP `CallToolResult`, preserving explicit errors and failed batch outcomes.
66
120
  *
67
121
  * A payload that is already a well-formed MCP content envelope (see {@link isMcpContentEnvelope}) is
68
122
  * returned with its `content` array verbatim, so a typed block inside it — `image`, `resource`, or any
@@ -72,7 +126,7 @@ export interface McpToolDef<Args extends Record<string, unknown> = Record<string
72
126
  * JSON-stringified (compact, no indentation) into one.
73
127
  *
74
128
  * @complexity O(n) in the number of content-array entries, only when `payload` already looks like an
75
- * envelope; O(1) otherwise.
129
+ * envelope or batch; O(1) otherwise.
76
130
  */
77
131
  export declare function okResult({ payload }: {
78
132
  payload: unknown;
@@ -95,7 +149,8 @@ export declare function buildToolIndex({ tools }: {
95
149
  tools: readonly McpToolDef[];
96
150
  }): Map<string, McpToolDef>;
97
151
  /**
98
- * `tools/call` dispatch: looks up `name` in `tools`, validates `rawArgs` against the tool's
152
+ * `tools/call` dispatch: looks up `name` in `tools`, normalizes aliases/wrapper input, then
153
+ * validates the canonical arguments against the tool's
99
154
  * declared `inputSchema`, runs its handler, and converts the outcome to a `CallToolResult` — an
100
155
  * unknown tool name, a schema violation, or a thrown error all produce an `{isError:true}` result
101
156
  * rather than rejecting, matching MCP's convention that tool failures are protocol-level results,
@@ -103,8 +158,8 @@ export declare function buildToolIndex({ tools }: {
103
158
  * agent, not this repo's own code) supplies `rawArgs`, so a handler must never be trusted to check
104
159
  * its own declared contract on the untrusted-input path. Both a validation failure's message and a
105
160
  * thrown error's message are passed through {@link sanitizeUntrustedText} before reaching the
106
- * result: either can end up echoing caller- or daemon-supplied text, and it is cheaper to sanitize
107
- * unconditionally than to prove which call site needs it.
161
+ * result: either can end up echoing caller- or daemon-supplied text. The validation prefix uses the
162
+ * registered tool name outside sanitization so long gateway names are not mistaken for secrets.
108
163
  */
109
164
  export declare function handleToolCall({ name, tools, ctx }: {
110
165
  name: string;
@@ -44,9 +44,11 @@
44
44
  * responsibility ( Decision 4), not this generic wrapper's.
45
45
  * See docs/decisions/DR-002-protocol-resource-boundary.md.
46
46
  */
47
+ import { batchOutcomeFor } from '@jini-ai/core';
47
48
  import { sanitizeUntrustedText } from '@jini-ai/core/text';
48
- import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker';
49
+ import { Validator } from '@cfworker/json-schema';
49
50
  import { ContentBlockSchema } from '@modelcontextprotocol/sdk/types.js';
51
+ import { argumentShapeMessage, isRecord, normalizeToolArguments } from './tool-arguments.js';
50
52
  /**
51
53
  * Maps a tool context onto the `DaemonRequestOptions` every daemon call needs.
52
54
  *
@@ -64,13 +66,11 @@ import { ContentBlockSchema } from '@modelcontextprotocol/sdk/types.js';
64
66
  export function daemonCallOptions({ ctx }) {
65
67
  return {
66
68
  fetchImpl: ctx.fetchImpl,
69
+ ...(ctx.diagnosticToolIds !== undefined ? { diagnosticToolIds: ctx.diagnosticToolIds } : {}),
67
70
  ...(ctx.authHeaders !== undefined ? { headers: { ...ctx.authHeaders } } : {}),
68
71
  ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
69
72
  };
70
73
  }
71
- function isRecord(value) {
72
- return typeof value === 'object' && value !== null && !Array.isArray(value);
73
- }
74
74
  /**
75
75
  * True when `block` is a well-formed MCP content block this module will pass through verbatim rather
76
76
  * than JSON-stringify. Delegates to the pinned `@modelcontextprotocol/sdk`'s own `ContentBlockSchema`
@@ -101,7 +101,7 @@ function isMcpContentEnvelope(payload) {
101
101
  return Array.isArray(content) && content.every(isPassthroughContentBlock);
102
102
  }
103
103
  /**
104
- * Wraps a successful tool result as an MCP `CallToolResult`.
104
+ * Wraps a tool result as an MCP `CallToolResult`, preserving explicit errors and failed batch outcomes.
105
105
  *
106
106
  * A payload that is already a well-formed MCP content envelope (see {@link isMcpContentEnvelope}) is
107
107
  * returned with its `content` array verbatim, so a typed block inside it — `image`, `resource`, or any
@@ -111,17 +111,20 @@ function isMcpContentEnvelope(payload) {
111
111
  * JSON-stringified (compact, no indentation) into one.
112
112
  *
113
113
  * @complexity O(n) in the number of content-array entries, only when `payload` already looks like an
114
- * envelope; O(1) otherwise.
114
+ * envelope or batch; O(1) otherwise.
115
115
  */
116
116
  export function okResult({ payload }) {
117
+ const outcome = batchOutcomeFor({ output: payload });
118
+ const isError = (typeof payload === 'object' && payload !== null && 'isError' in payload && payload.isError === true)
119
+ || outcome === 'partial_failure' || outcome === 'error';
117
120
  if (isMcpContentEnvelope(payload)) {
118
- return { content: payload.content };
121
+ return { content: payload.content, ...(isError ? { isError: true } : {}) };
119
122
  }
120
123
  // Compact on the wire: indentation made nested results ~2.5x larger for the model to read. A view
121
124
  // that shows a result to a person re-indents it at render time (`@jini-ai/chat`'s
122
125
  // `formatToolOutputForDisplay`).
123
126
  const text = typeof payload === 'string' ? payload : JSON.stringify(payload);
124
- return { content: [{ type: 'text', text }] };
127
+ return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
125
128
  }
126
129
  /** Wraps a tool failure as an MCP `isError` result. */
127
130
  export function errorResult({ message }) {
@@ -154,31 +157,22 @@ export function buildToolIndex({ tools }) {
154
157
  }
155
158
  return index;
156
159
  }
157
- /**
158
- * The SDK's own edge-runtime-compatible (no `eval`/codegen) JSON Schema validator — the same
159
- * mechanism `McpServer.registerTool()`'s high-level helper uses internally, borrowed here because
160
- * `createMcpToolServer` is built on the SDK's low-level `Server.setRequestHandler` instead (see
161
- * `tool-server.ts`'s module doc), which does not validate a tool's declared `inputSchema` against
162
- * arguments before its handler runs. One provider instance is reused across every tool: it holds no
163
- * per-schema state itself (see {@link compiledValidators} for the actual compiled-validator cache).
164
- */
165
- const schemaValidatorProvider = new CfWorkerJsonSchemaValidator();
166
- /**
167
- * Compiling a validator from a schema is real work `CfWorkerJsonSchemaValidator` does not cache
168
- * internally (per its own doc), so it is cached here per `McpToolDef` instance — stable for a
169
- * server's whole lifetime, since tool defs are created once at module load and never rebuilt.
170
- */
160
+ /** Compiled schemas are reused for each stable tool definition, as in the SDK provider. */
171
161
  const compiledValidators = new WeakMap();
162
+ /** Returns the cached validator, retaining structured field locations for recovery instructions. */
172
163
  function validatorForTool(tool) {
173
164
  const cached = compiledValidators.get(tool);
174
165
  if (cached)
175
166
  return cached;
176
- const validator = schemaValidatorProvider.getValidator(tool.inputSchema);
167
+ // The validator adds internal URI fields while compiling. F0's published schemas are frozen;
168
+ // compile a detached copy so validation cannot mutate the immutable wire contract.
169
+ const validator = new Validator(structuredClone(tool.inputSchema), '2020-12', true);
177
170
  compiledValidators.set(tool, validator);
178
171
  return validator;
179
172
  }
180
173
  /**
181
- * `tools/call` dispatch: looks up `name` in `tools`, validates `rawArgs` against the tool's
174
+ * `tools/call` dispatch: looks up `name` in `tools`, normalizes aliases/wrapper input, then
175
+ * validates the canonical arguments against the tool's
182
176
  * declared `inputSchema`, runs its handler, and converts the outcome to a `CallToolResult` — an
183
177
  * unknown tool name, a schema violation, or a thrown error all produce an `{isError:true}` result
184
178
  * rather than rejecting, matching MCP's convention that tool failures are protocol-level results,
@@ -186,34 +180,30 @@ function validatorForTool(tool) {
186
180
  * agent, not this repo's own code) supplies `rawArgs`, so a handler must never be trusted to check
187
181
  * its own declared contract on the untrusted-input path. Both a validation failure's message and a
188
182
  * thrown error's message are passed through {@link sanitizeUntrustedText} before reaching the
189
- * result: either can end up echoing caller- or daemon-supplied text, and it is cheaper to sanitize
190
- * unconditionally than to prove which call site needs it.
183
+ * result: either can end up echoing caller- or daemon-supplied text. The validation prefix uses the
184
+ * registered tool name outside sanitization so long gateway names are not mistaken for secrets.
191
185
  */
192
186
  export async function handleToolCall({ name, tools, ctx }, { rawArgs } = {}) {
187
+ const diagnosticToolIds = [...tools.keys(), ...(ctx.diagnosticToolIds ?? [])];
188
+ ctx = { ...ctx, diagnosticToolIds };
193
189
  const tool = tools.get(name);
194
190
  if (tool === undefined) {
195
191
  return errorResult({ message: `unknown tool: ${name}` });
196
192
  }
197
- const args = rawArgs ?? {};
198
- // Validation gets its own error boundary, separate from the handler's below. The
199
- // validator does not only return `{valid:false}` for a bad argument — `@cfworker/json-schema`
200
- // *throws* for JavaScript values JSON cannot encode (an explicitly-`undefined` optional
201
- // property yields `Instances of "undefined" type are not supported.`). Compiling the
202
- // validator can throw too, on a malformed `inputSchema`. Either escaping would break this
203
- // function's whole contract, that a schema violation is an `{isError:true}` result and never
204
- // a rejection — and `handleToolCall` is exported, typed `Record<string, unknown>`, and
205
- // callable directly by a host, so this is reachable and not merely theoretical.
193
+ // Normalization and schema validation share an error boundary; handlers run only afterward.
194
+ // See docs/decisions/DR-005-proxy-argument-shapes.md.
206
195
  let validatedArgs;
207
196
  try {
208
- const validation = validatorForTool(tool)(args);
197
+ const args = normalizeToolArguments({ tool, rawArgs: rawArgs ?? {} });
198
+ const validation = validatorForTool(tool).validate(args);
209
199
  if (!validation.valid) {
210
- return errorResult({ message: sanitizeUntrustedText({ text: `invalid arguments for ${name}: ${validation.errorMessage}` }) });
200
+ return errorResult({ message: `invalid arguments for ${tool.name}: ${sanitizeUntrustedText({ text: argumentShapeMessage({ errors: validation.errors }) }, { diagnosticToolIds })}` });
211
201
  }
212
- validatedArgs = validation.data;
202
+ validatedArgs = args;
213
203
  }
214
204
  catch (err) {
215
205
  const message = err instanceof Error ? err.message : String(err);
216
- return errorResult({ message: sanitizeUntrustedText({ text: `invalid arguments for ${name}: ${message}` }) });
206
+ return errorResult({ message: `invalid arguments for ${tool.name}: ${sanitizeUntrustedText({ text: message }, { diagnosticToolIds })}` });
217
207
  }
218
208
  try {
219
209
  const result = await tool.handler({ args: validatedArgs, ctx: ctx });
@@ -221,7 +211,7 @@ export async function handleToolCall({ name, tools, ctx }, { rawArgs } = {}) {
221
211
  }
222
212
  catch (err) {
223
213
  const message = err instanceof Error ? err.message : String(err);
224
- return errorResult({ message: sanitizeUntrustedText({ text: message }) });
214
+ return errorResult({ message: sanitizeUntrustedText({ text: message }, { diagnosticToolIds }) });
225
215
  }
226
216
  }
227
217
  //# sourceMappingURL=tool-protocol.js.map
@@ -7,6 +7,7 @@
7
7
  * with nominal private fields; default adapters translate those contracts to the real SDK.
8
8
  */
9
9
  import type { Readable, Writable } from 'node:stream';
10
+ import { type ToolDescriptor } from '@jini-ai/core';
10
11
  import type { McpTransportLike } from './ports.js';
11
12
  export type { McpTransportLike } from './ports.js';
12
13
  import type { ServerOptions } from '@modelcontextprotocol/sdk/server/index.js';
@@ -14,6 +15,7 @@ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSche
14
15
  import type { CallToolResult, Implementation, ListResourcesResult, ListToolsResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
15
16
  import { type McpResourceDef } from './resource-protocol.js';
16
17
  import { type McpToolDef } from './tool-protocol.js';
18
+ import { type CreateExecuteDelegatedToolToolOptions } from './tools/delegated-tool.js';
17
19
  /** Object-shaped server port. The default adapter translates it to the SDK interface. */
18
20
  export interface McpServerLike {
19
21
  setRequestHandler(args: {
@@ -52,6 +54,8 @@ export interface McpServerLike {
52
54
  connect(args: {
53
55
  transport: McpTransportLike;
54
56
  }): Promise<void>;
57
+ /** Optional for static embeddings; required when delegated publication is enabled. */
58
+ sendToolListChanged?(required: Record<string, never>, optional?: Record<string, never>): Promise<void>;
55
59
  }
56
60
  /** Required identity, tool surface, and daemon URL resolver. */
57
61
  export interface McpToolServerRequiredArgs {
@@ -64,6 +68,10 @@ export interface McpToolServerRequiredArgs {
64
68
  readonly resolveBaseUrl: () => Promise<string> | string;
65
69
  }
66
70
  export interface McpToolServerOptions {
71
+ /** Opt in to bounded, per-run typed publication after describe_tool. */
72
+ readonly delegatedTools?: McpDelegatedToolsOptions;
73
+ /** Trusted daemon registration IDs carried into every error sanitizer. */
74
+ readonly diagnosticToolIds?: readonly string[];
67
75
  /** The bounded, explicit read-only resource list this server hosts. Duplicate uris throw at construction time. Omit (or pass an empty array) for a tools-only server — `capabilities.resources` is only advertised when this is non-empty. */
68
76
  readonly resources?: readonly McpResourceDef[];
69
77
  /** Free-text guidance surfaced to the MCP client alongside the tool list. Optional — omit for a caller with nothing to add beyond each tool's own `description`. */
@@ -93,6 +101,17 @@ export interface McpToolServerOptions {
93
101
  stdout?: Writable;
94
102
  }) => McpTransportLike;
95
103
  }
104
+ /** The admitted host descriptors supplied here must come from the existing registry/catalog.
105
+ * Publication never connects to a federated server or admits its upstream list changes.
106
+ */
107
+ export interface McpDelegatedToolsOptions extends CreateExecuteDelegatedToolToolOptions {
108
+ readonly runId: string;
109
+ /** Resolve these through the authenticated daemon before running a client without refresh. */
110
+ readonly initialTools?: readonly ToolDescriptor[];
111
+ /** Defaults to F0's 64 tools and 256 KiB of schemas; new entries are skipped at the cap. */
112
+ readonly maxTools?: number;
113
+ readonly maxSchemaBytes?: number;
114
+ }
96
115
  export interface McpToolServerHandle {
97
116
  /**
98
117
  * Resolves the daemon base URL, connects the MCP server to its transport, and holds the process
@@ -1,3 +1,4 @@
1
+ import { createToolExposure } from '@jini-ai/core';
1
2
  import { adaptSdkTransport, resolveSdkTransport } from './sdk-transport.js';
2
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -5,6 +6,7 @@ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSche
5
6
  import { createMcpIdleExitController } from '../client/client.js';
6
7
  import { buildResourceIndex, handleResourceRead, resourcesToList } from './resource-protocol.js';
7
8
  import { buildToolIndex, handleToolCall, toolsToList } from './tool-protocol.js';
9
+ import { createTypedDelegatedToolTool } from './tools/delegated-tool.js';
8
10
  /** Auto-exit an idle server after this long without tool activity. */
9
11
  const DEFAULT_IDLE_MS = 30 * 60 * 1000;
10
12
  // SDK objects remain behind these adapters; hosts implement the object-shaped ports above.
@@ -13,11 +15,84 @@ function defaultCreateServer({ info, options }) {
13
15
  return {
14
16
  setRequestHandler: (({ schema, handler }) => server.setRequestHandler(schema, (request, extra) => handler({ request }, { extra }))),
15
17
  connect: ({ transport }) => server.connect(resolveSdkTransport({ transport })),
18
+ sendToolListChanged: () => server.sendToolListChanged(),
16
19
  };
17
20
  }
18
21
  function defaultCreateTransport(_requiredArgs, { stdin, stdout } = {}) {
19
22
  return adaptSdkTransport({ transport: new StdioServerTransport(stdin, stdout) });
20
23
  }
24
+ /**
25
+ * The tool context shared by every call in one run: the resolved daemon URL without its trailing
26
+ * slash (coerced to a string for JS hosts that ignore the contract), the configured or global
27
+ * `fetch`, and the auth headers and diagnostic IDs only when configured.
28
+ */
29
+ function buildToolContext({ resolvedBaseUrl }, options) {
30
+ return {
31
+ ...(options.diagnosticToolIds !== undefined ? { diagnosticToolIds: options.diagnosticToolIds } : {}),
32
+ baseUrl: String(resolvedBaseUrl).replace(/\/$/, ''),
33
+ fetchImpl: options.fetchImpl ?? fetch,
34
+ ...(options.authHeaders !== undefined ? { authHeaders: options.authHeaders } : {}),
35
+ };
36
+ }
37
+ /** The stdio pair handed to `createTransport`, carrying only the streams the caller configured. */
38
+ function stdioOptions(options) {
39
+ return {
40
+ ...(options.stdin === undefined ? {} : { stdin: options.stdin }),
41
+ ...(options.stdout === undefined ? {} : { stdout: options.stdout }),
42
+ };
43
+ }
44
+ /**
45
+ * Own only the MCP projection/dispatch index. F0 owns immutable exposure, and the daemon catalog
46
+ * supplies each descriptor; the temporary source below is not another definition catalog.
47
+ * @returns An install-before-notify publisher, or undefined for a static server.
48
+ * @throws On malformed schemas, invalid exposure limits, or an embedding without notification support.
49
+ * @complexity O(n + s) per promotion for n bounded exposed tools and s schema bytes; no daemon I/O.
50
+ */
51
+ function createToolPublisher({ index, server, options }) {
52
+ if (!options)
53
+ return undefined;
54
+ const notify = server.sendToolListChanged?.bind(server);
55
+ if (!notify)
56
+ throw new Error('delegated publication requires tools/list_changed support');
57
+ let descriptors = options.initialTools ?? [];
58
+ const exposure = createToolExposure({ registry: { list: () => descriptors } }, {
59
+ reservedNames: [...index.keys()],
60
+ ...(options.maxTools === undefined ? {} : { maxTools: options.maxTools }),
61
+ ...(options.maxSchemaBytes === undefined ? {} : { maxSchemaBytes: options.maxSchemaBytes }),
62
+ });
63
+ // Validate before promotion: malformed descriptors must not consume a pinned name or budget.
64
+ const validate = (descriptor) => createTypedDelegatedToolTool({ runId: options.runId, tool: { wireName: descriptor.id, descriptor } }, options);
65
+ for (const descriptor of descriptors)
66
+ validate(descriptor);
67
+ const initial = exposure.promote({ toolIds: descriptors.map(descriptor => descriptor.id) });
68
+ for (const tool of initial.snapshot.tools)
69
+ index.set(tool.wireName, createTypedDelegatedToolTool({ runId: options.runId, tool }, options));
70
+ descriptors = [];
71
+ let notificationPending = false;
72
+ return async ({ descriptor }, _optional = {}) => {
73
+ validate(descriptor);
74
+ descriptors = [descriptor];
75
+ const delta = exposure.promote({ toolIds: [descriptor.id] });
76
+ descriptors = [];
77
+ for (const tool of delta.snapshot.tools) {
78
+ if (!index.has(tool.wireName))
79
+ index.set(tool.wireName, createTypedDelegatedToolTool({ runId: options.runId, tool }, options));
80
+ }
81
+ if (delta.added.length > 0)
82
+ notificationPending = true;
83
+ if (!notificationPending)
84
+ return;
85
+ // Clear before awaiting: a concurrent promotion must retain its own unsent notification.
86
+ notificationPending = false;
87
+ try {
88
+ await notify({});
89
+ }
90
+ catch (error) {
91
+ notificationPending = true;
92
+ throw error;
93
+ }
94
+ };
95
+ }
21
96
  /**
22
97
  * Builds a stdio MCP server hosting `requiredArgs.tools` (and, optionally, `options.resources`).
23
98
  * Construction is synchronous and cheap (validates tool-name uniqueness via
@@ -26,35 +101,35 @@ function defaultCreateTransport(_requiredArgs, { stdin, stdout } = {}) {
26
101
  * handle's `run()`.
27
102
  */
28
103
  export function createMcpToolServer(requiredArgs, options = {}) {
29
- const toolIndex = buildToolIndex({ tools: requiredArgs.tools });
104
+ // Validate eagerly, but keep the mutable dispatch index inside run() so re-runs are isolated.
105
+ buildToolIndex({ tools: requiredArgs.tools });
30
106
  const resources = options.resources ?? [];
31
107
  const resourceIndex = buildResourceIndex({ resources });
32
108
  const createServer = options.createServer ?? defaultCreateServer;
33
109
  const createTransport = options.createTransport ?? defaultCreateTransport;
34
110
  return {
35
111
  async run(_required) {
36
- const resolvedBaseUrl = await requiredArgs.resolveBaseUrl();
37
- const ctx = {
38
- baseUrl: String(resolvedBaseUrl).replace(/\/$/, ''),
39
- fetchImpl: options.fetchImpl ?? fetch,
40
- ...(options.authHeaders !== undefined ? { authHeaders: options.authHeaders } : {}),
41
- };
42
- let closeTransportForIdle = null;
43
- const idleExit = createMcpIdleExitController({
44
- idleMs: options.idleMs ?? DEFAULT_IDLE_MS,
45
- onIdle: () => closeTransportForIdle?.(),
46
- });
47
- const withActivity = (handler) => (...args) => idleExit.trackRequest({ fn: () => handler(...args) });
112
+ const toolIndex = buildToolIndex({ tools: requiredArgs.tools });
113
+ let ctx = buildToolContext({ resolvedBaseUrl: await requiredArgs.resolveBaseUrl() }, options);
48
114
  const server = createServer({
49
115
  info: { name: requiredArgs.name, version: requiredArgs.version },
50
116
  options: {
51
- capabilities: { tools: {}, ...(resources.length > 0 ? { resources: {} } : {}) },
117
+ capabilities: { tools: options.delegatedTools ? { listChanged: true } : {}, ...(resources.length > 0 ? { resources: {} } : {}) },
52
118
  ...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
53
119
  },
54
120
  });
121
+ const publishTool = createToolPublisher({ index: toolIndex, server, options: options.delegatedTools });
122
+ if (publishTool)
123
+ ctx = { ...ctx, publishTool };
124
+ let closeTransportForIdle = null;
125
+ const idleExit = createMcpIdleExitController({
126
+ idleMs: options.idleMs ?? DEFAULT_IDLE_MS,
127
+ onIdle: () => closeTransportForIdle?.(),
128
+ });
129
+ const withActivity = (handler) => (...args) => idleExit.trackRequest({ fn: () => handler(...args) });
55
130
  server.setRequestHandler({
56
131
  schema: ListToolsRequestSchema,
57
- handler: withActivity(async () => ({ tools: toolsToList({ tools: requiredArgs.tools }) })),
132
+ handler: withActivity(async () => ({ tools: toolsToList({ tools: [...toolIndex.values()] }) })),
58
133
  });
59
134
  server.setRequestHandler({
60
135
  schema: CallToolRequestSchema,
@@ -70,10 +145,7 @@ export function createMcpToolServer(requiredArgs, options = {}) {
70
145
  handler: withActivity(async ({ request }) => handleResourceRead({ uri: request.params.uri, resources: resourceIndex, ctx })),
71
146
  });
72
147
  }
73
- const transport = createTransport({}, {
74
- ...(options.stdin === undefined ? {} : { stdin: options.stdin }),
75
- ...(options.stdout === undefined ? {} : { stdout: options.stdout }),
76
- });
148
+ const transport = createTransport({}, stdioOptions(options));
77
149
  try {
78
150
  closeTransportForIdle = () => {
79
151
  void transport.close({}).catch(() => { });
@@ -1,3 +1,39 @@
1
+ /**
2
+ * @module @jini-ai/mcp/server/tools/delegated-tool
3
+ *
4
+ * `execute_delegated_tool` and its read-only companion `execute_readonly_delegated_tool` — the
5
+ * MCP-callback half of gap 3's continuation transport (see
6
+ * `packages/daemon/archived provenance ledger`'s "run/chat orchestration gap 3, part 1" addition and this
7
+ * package's own dated section in `archived provenance ledger` for the rest of the spike). The swarm-consensus
8
+ * Final Recommendation asked for exactly this: "inject the already-shipped MCP host into one
9
+ * MCP-capable CLI's launch config, prove a tool round-trip through the existing
10
+ * `delegated-tool-bridge.ts`." Where `../tools/run-tools.js`'s tools proxy generic
11
+ * run-lifecycle HTTP endpoints, this tool proxies the new `POST /api/delegated-tool-calls`
12
+ * daemon route (`packages/http/src/delegated-tools.ts`), which is the one and only thing that
13
+ * calls into `createDelegatedToolBridge` (`packages/daemon/src/delegated-tool-bridge.ts`) — the
14
+ * same `ToolExecutor` deny-by-default gate every other tool-execution path in this codebase
15
+ * already uses. No parallel authorization mechanism is introduced anywhere on this path.
16
+ *
17
+ * Unlike every other tool this package ships (`run-tools.ts`'s five tools, plain static
18
+ * objects), this one is a **factory**. The MCP server subprocess a `claude` run spawns via
19
+ * `.mcp.json` (see `../../bin/serve.js`) is scoped to exactly one `runId` for its entire
20
+ * lifetime — the daemon injected that run id into the subprocess's own environment at spawn
21
+ * time, before the model ever said a word. `runId` is therefore closed over at construction
22
+ * time, not accepted as a per-call tool argument: a model has no legitimate way to pick, or
23
+ * need to know, which run it is currently inside, and letting it supply one would open a
24
+ * confused-deputy path (one run's MCP subprocess executing a tool call "as" a different run).
25
+ * `toolUseId` is likewise generated per call (`randomUUID` by default), not model-supplied — a
26
+ * model has no legitimate reason to choose its own correlation id either.
27
+ *
28
+ * TWO defs, ONE code path. `createDelegatedGateway` builds both; the factories differ only in the
29
+ * name/title/description they present and in whether the request carries `requireReadOnly: true`.
30
+ * The read-only companion exists because MCP annotations are static per tool declaration — a
31
+ * surface that promises "this only reads" can only be a second declaration, never a per-call mode —
32
+ * and it ENFORCES that promise daemon-side rather than asserting it, because a `readOnlyHint: true`
33
+ * tool that could still reach writes would launder a write past its caller's own safety gate. See
34
+ * {@link createExecuteReadonlyDelegatedToolTool} for the failure that motivated it.
35
+ */
36
+ import { type ExposedToolDescriptor } from '@jini-ai/core';
1
37
  import { type McpToolDef } from '../tool-protocol.js';
2
38
  /**
3
39
  * Request deadline for one delegated tool call, overriding `daemon-client.ts`'s 15 s default.
@@ -87,4 +123,15 @@ export declare function createExecuteDelegatedToolTool(requiredArgs: CreateExecu
87
123
  * every caller, including the ones the annotation exists to protect.
88
124
  */
89
125
  export declare function createExecuteReadonlyDelegatedToolTool(requiredArgs: CreateExecuteDelegatedToolToolRequiredArgs, options?: CreateExecuteDelegatedToolToolOptions): McpToolDef;
126
+ /**
127
+ * Project a pinned registry descriptor as one concrete MCP tool, reusing the existing gateway.
128
+ * @param requiredArgs - Trusted run scope and F0's collision-checked, exposed descriptor.
129
+ * @param options - The same correlation source and deadline used by the legacy wrappers.
130
+ * @returns A schema-preserving definition; model arguments become only the delegated input.
131
+ * @throws Error when the descriptor has no MCP object input schema.
132
+ * @complexity O(s) schema parsing time/space for s schema fields; execution is one daemon call.
133
+ */
134
+ export declare function createTypedDelegatedToolTool({ runId, tool }: CreateExecuteDelegatedToolToolRequiredArgs & {
135
+ tool: ExposedToolDescriptor;
136
+ }, options?: CreateExecuteDelegatedToolToolOptions): McpToolDef;
90
137
  //# sourceMappingURL=delegated-tool.d.ts.map
@@ -33,6 +33,8 @@
33
33
  * tool that could still reach writes would launder a write past its caller's own safety gate. See
34
34
  * {@link createExecuteReadonlyDelegatedToolTool} for the failure that motivated it.
35
35
  */
36
+ import { batchOutcomeFor, isReadOnlyTool } from '@jini-ai/core';
37
+ import { ToolSchema } from '@modelcontextprotocol/sdk/types.js';
36
38
  import { randomUUID } from 'node:crypto';
37
39
  import { postDaemonJson } from '../daemon-client.js';
38
40
  import { daemonCallOptions, requireString } from '../tool-protocol.js';
@@ -42,6 +44,7 @@ function isRecord(value) {
42
44
  /**
43
45
  * Unwraps a completed delegated call's `{executionId, status, output, ...}` envelope down to just
44
46
  * `output` when — and only when — `output` is itself an MCP content envelope (`{content: [...]}`).
47
+ * Batches expose partial_failure/error and isError while retaining every item.
45
48
  * Every other shape (a non-completed status, a plain JSON `output` like a string or a list, no
46
49
  * `output` at all) is returned byte-identical to `result` — this is an additive unwrap, not a new
47
50
  * general response shape, so the existing "surfaces the result envelope unchanged" contract for an
@@ -57,9 +60,14 @@ function isRecord(value) {
57
60
  * block the bridge already preserved.
58
61
  */
59
62
  function unwrapMcpContentEnvelope(result) {
60
- if (!isRecord(result) || result['status'] !== 'completed')
63
+ if (!isRecord(result))
61
64
  return result;
62
65
  const output = result['output'];
66
+ const outcome = batchOutcomeFor({ output });
67
+ if (outcome === 'partial_failure' || outcome === 'error')
68
+ return { ...result, status: outcome, outcome, isError: true };
69
+ if (result['status'] !== 'completed')
70
+ return result;
63
71
  if (!isRecord(output) || !Array.isArray(output['content']))
64
72
  return result;
65
73
  return output;
@@ -109,7 +117,8 @@ function createDelegatedGateway(variant, { runId }, options) {
109
117
  : DEFAULT_DELEGATED_TOOL_TIMEOUT_MS;
110
118
  return {
111
119
  name: variant.name,
112
- description: variant.description,
120
+ description: `${variant.description} Put the tool's own arguments inside input; non-conflicting top-level tool arguments are moved there automatically.`,
121
+ argumentNormalization: { foldIntoInput: true },
113
122
  inputSchema: {
114
123
  type: 'object',
115
124
  properties: {
@@ -223,4 +232,28 @@ export function createExecuteReadonlyDelegatedToolTool(requiredArgs, options = {
223
232
  requireReadOnly: true,
224
233
  }, requiredArgs, options);
225
234
  }
235
+ /**
236
+ * Project a pinned registry descriptor as one concrete MCP tool, reusing the existing gateway.
237
+ * @param requiredArgs - Trusted run scope and F0's collision-checked, exposed descriptor.
238
+ * @param options - The same correlation source and deadline used by the legacy wrappers.
239
+ * @returns A schema-preserving definition; model arguments become only the delegated input.
240
+ * @throws Error when the descriptor has no MCP object input schema.
241
+ * @complexity O(s) schema parsing time/space for s schema fields; execution is one daemon call.
242
+ */
243
+ export function createTypedDelegatedToolTool({ runId, tool }, options = {}) {
244
+ const { descriptor, wireName } = tool;
245
+ const inputSchema = ToolSchema.shape.inputSchema.parse(descriptor.inputSchema);
246
+ const readOnly = isReadOnlyTool({ descriptor });
247
+ const gateway = readOnly
248
+ ? createExecuteReadonlyDelegatedToolTool({ runId }, options)
249
+ : createExecuteDelegatedToolTool({ runId }, options);
250
+ return {
251
+ name: wireName,
252
+ description: descriptor.description ?? descriptor.id,
253
+ // Parse to check the MCP boundary, but publish the original schema, including extensions.
254
+ inputSchema: descriptor.inputSchema,
255
+ annotations: { ...gateway.annotations, title: descriptor.id },
256
+ handler: ({ args, ctx }) => gateway.handler({ args: { toolId: descriptor.id, input: args }, ctx }),
257
+ };
258
+ }
226
259
  //# sourceMappingURL=delegated-tool.js.map