@jini-ai/mcp 0.6.17 → 0.6.19

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.
@@ -44,11 +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
+ import { batchOutcomeFor, normalizeToolInput, withNormalizationNotes } from '@jini-ai/core';
48
48
  import { sanitizeUntrustedText } from '@jini-ai/core/text';
49
49
  import { Validator } from '@cfworker/json-schema';
50
50
  import { ContentBlockSchema } from '@modelcontextprotocol/sdk/types.js';
51
- import { argumentShapeMessage, isRecord, normalizeToolArguments } from './tool-arguments.js';
51
+ import { argumentShapeMessage, isRecord, normalizeToolArguments, resolveMcpToolName, withArgumentContract } from './tool-arguments.js';
52
52
  import { safeDiagnostic } from './daemon-client.js';
53
53
  import { scopeNestedSchemaRefs } from './schema-ref-scope.js';
54
54
  /**
@@ -120,7 +120,9 @@ export function okResult({ payload }) {
120
120
  const flaggedError = typeof payload === 'object' && payload !== null && 'isError' in payload && payload.isError === true;
121
121
  const isError = flaggedError || outcome === 'partial_failure' || outcome === 'error';
122
122
  if (isMcpContentEnvelope(payload)) {
123
- return { content: payload.content, ...(isError ? { isError: true } : {}) };
123
+ return { content: payload.content,
124
+ ...(isRecord(payload.structuredContent) ? { structuredContent: payload.structuredContent } : {}),
125
+ ...(isError ? { isError: true } : {}) };
124
126
  }
125
127
  // Compact on the wire: indentation made nested results ~2.5x larger for the model to read. A view
126
128
  // that shows a result to a person re-indents it at render time (`@jini-ai/chat`'s
@@ -182,6 +184,33 @@ function validatorForTool(tool) {
182
184
  compiledValidators.set(tool, validator);
183
185
  return validator;
184
186
  }
187
+ /** Normalize registry-tool input through its existing owner before strict transport validation.
188
+ * MCP-local wrapper tools retain their own alias/folding contract. Domain hooks and permission
189
+ * checks remain in the daemon; this pass reads only the published schema.
190
+ * @returns A detached argument object and value-free normalization notes.
191
+ * @throws Input errors before any handler or daemon request.
192
+ * @complexity Core's bounded schema walks plus O(k) wrapper keys; no I/O.
193
+ */
194
+ async function normalizeCallArguments({ tool, rawArgs }, _options = {}) {
195
+ const args = normalizeToolArguments({ tool, rawArgs });
196
+ if (!tool.argumentNormalization?.schemaInput)
197
+ return { args, notes: [] };
198
+ const normalized = await normalizeToolInput({ input: args, registration: { descriptor: { id: tool.name, inputSchema: tool.inputSchema } } });
199
+ if (!isRecord(normalized.input))
200
+ throw new Error('normalization must produce one input object');
201
+ return { args: normalized.input, notes: normalized.notes };
202
+ }
203
+ /** Format a refused normalization with the same bounded contract used by schema validation.
204
+ * Registry tools need the full contract to repair aliases; MCP-local wrapper diagnostics keep
205
+ * their established messages. No supplied argument values are added to the diagnostic.
206
+ * @returns An MCP error result; never executes a handler.
207
+ * @complexity O(s) for s schema bytes plus bounded text sanitization.
208
+ */
209
+ function normalizationErrorResult({ tool, error, diagnosticToolIds }, _options = {}) {
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ const diagnostic = tool.argumentNormalization?.schemaInput ? withArgumentContract({ message, inputSchema: wireInputSchema(tool) }) : message;
212
+ return errorResult({ message: `invalid arguments for ${tool.name}: ${sanitizeUntrustedText({ text: diagnostic }, { diagnosticToolIds, maxLength: 4608 })}` });
213
+ }
185
214
  /**
186
215
  * `tools/call` dispatch: looks up `name` in `tools`, normalizes aliases/wrapper input, then
187
216
  * validates the canonical arguments against the tool's
@@ -199,32 +228,33 @@ function validatorForTool(tool) {
199
228
  export async function handleToolCall({ name, tools, ctx }, { rawArgs } = {}) {
200
229
  const diagnosticToolIds = [...tools.keys(), ...(ctx.diagnosticToolIds ?? [])];
201
230
  ctx = { ...ctx, diagnosticToolIds };
202
- const tool = tools.get(name);
231
+ const tool = tools.get(resolveMcpToolName({ name }, { isRegistered: candidate => tools.has(candidate) }));
203
232
  if (tool === undefined) {
204
233
  return errorResult({ message: `unknown tool: ${name}` });
205
234
  }
206
235
  // Normalization and schema validation share an error boundary; handlers run only afterward.
207
236
  // See docs/decisions/DR-005-proxy-argument-shapes.md.
208
237
  let validatedArgs;
238
+ let normalizationNotes;
209
239
  try {
210
- const args = normalizeToolArguments({ tool, rawArgs: rawArgs ?? {} });
240
+ const { args, notes } = await normalizeCallArguments({ tool, rawArgs: rawArgs ?? {} });
211
241
  const validation = validatorForTool(tool).validate(args);
212
242
  if (!validation.valid) {
213
- return errorResult({ message: `invalid arguments for ${tool.name}: ${sanitizeUntrustedText({ text: argumentShapeMessage({ errors: validation.errors, inputSchema: wireInputSchema(tool) }) }, {
243
+ return errorResult({ message: `invalid arguments for ${tool.name}: ${sanitizeUntrustedText({ text: argumentShapeMessage({ errors: validation.errors, inputSchema: wireInputSchema(tool) }, { includeContract: tool.argumentNormalization?.schemaInput === true }) }, {
214
244
  diagnosticToolIds,
215
245
  // A complete bounded contract must survive the default 500-character diagnostic cap.
216
246
  maxLength: 4608,
217
247
  })}` });
218
248
  }
219
249
  validatedArgs = args;
250
+ normalizationNotes = notes;
220
251
  }
221
252
  catch (err) {
222
- const message = err instanceof Error ? err.message : String(err);
223
- return errorResult({ message: `invalid arguments for ${tool.name}: ${sanitizeUntrustedText({ text: message }, { diagnosticToolIds })}` });
253
+ return normalizationErrorResult({ tool, error: err, diagnosticToolIds });
224
254
  }
225
255
  try {
226
256
  const result = await tool.handler({ args: validatedArgs, ctx: ctx });
227
- return okResult({ payload: result });
257
+ return okResult({ payload: withNormalizationNotes({ output: result, notes: normalizationNotes }) });
228
258
  }
229
259
  catch (err) {
230
260
  const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,21 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { McpToolDef } from './tool-protocol.js';
3
+ export interface ToolResultPages {
4
+ readonly tool: McpToolDef;
5
+ project(required: {
6
+ result: CallToolResult;
7
+ }, optional?: Record<string, never>): CallToolResult;
8
+ clear(required: Record<string, never>, optional?: Record<string, never>): void;
9
+ }
10
+ /** Keep large text in this MCP run rather than a CLI-owned spill file outside the site sandbox.
11
+ * Text is paged losslessly; image/resource blocks and result error flags pass through unchanged.
12
+ * @param _required No required configuration; one instance belongs to one server run.
13
+ * @param optional Optional opaque ID source for deterministic tests; defaults to randomUUID.
14
+ * @returns A projection, read-only read_tool_result tool, and explicit cleanup.
15
+ * @complexity O(n) projection for n result characters; at most 32 entries / 8M UTF-16 code units.
16
+ * @example const pages = createToolResultPages({}); pages.project({ result });
17
+ */
18
+ export declare function createToolResultPages(_required: Record<string, never>, { generateId, }?: {
19
+ generateId?: () => string;
20
+ }): ToolResultPages;
21
+ //# sourceMappingURL=tool-result-pages.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const INLINE_CHARS = 12000;
3
+ const MAX_STORED_CHARS = 8_000_000;
4
+ const MAX_RESULTS = 32;
5
+ const SMALL_BLOCK_CHARS = 1024;
6
+ /** Keep large text in this MCP run rather than a CLI-owned spill file outside the site sandbox.
7
+ * Text is paged losslessly; image/resource blocks and result error flags pass through unchanged.
8
+ * @param _required No required configuration; one instance belongs to one server run.
9
+ * @param optional Optional opaque ID source for deterministic tests; defaults to randomUUID.
10
+ * @returns A projection, read-only read_tool_result tool, and explicit cleanup.
11
+ * @complexity O(n) projection for n result characters; at most 32 entries / 8M UTF-16 code units.
12
+ * @example const pages = createToolResultPages({}); pages.project({ result });
13
+ */
14
+ export function createToolResultPages(_required, { generateId = randomUUID, } = {}) {
15
+ const saved = new Map();
16
+ let storedChars = 0;
17
+ /** Evict oldest entries until the new result fits; all limits are per run. */
18
+ function save(text) {
19
+ while (saved.size >= MAX_RESULTS || storedChars + text.length > MAX_STORED_CHARS) {
20
+ const oldest = saved.keys().next().value;
21
+ storedChars -= saved.get(oldest).length;
22
+ saved.delete(oldest);
23
+ }
24
+ const id = generateId();
25
+ saved.set(id, text);
26
+ storedChars += text.length;
27
+ return id;
28
+ }
29
+ const tool = {
30
+ name: 'read_tool_result',
31
+ description: 'Read a page of a saved oversized tool result in this run. Use resultId from the tool response, offset (default 0), and limit (default 4000, max 8000). Returns one text block: JSON cursor metadata on its first line, then exact result text. Follow nextOffset until null. Unknown/evicted IDs fail; repeat the original tool with a narrower query. Never read CLI spill files with Bash.',
32
+ inputSchema: { type: 'object', required: ['resultId'], additionalProperties: false, properties: {
33
+ resultId: { type: 'string', minLength: 1 }, offset: { type: 'integer', minimum: 0 },
34
+ limit: { type: 'integer', minimum: 1, maximum: 8000 },
35
+ } },
36
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
37
+ handler({ args }) {
38
+ const resultId = args['resultId'];
39
+ const text = saved.get(resultId);
40
+ if (text === undefined)
41
+ throw new Error('Saved result is unavailable in this run. Repeat the original tool call with a narrower query.');
42
+ const offset = args['offset'] ?? 0;
43
+ if (offset > text.length)
44
+ throw new RangeError('offset exceeds the saved result length.');
45
+ const end = Math.min(text.length, offset + (args['limit'] ?? 4000));
46
+ const cursor = { resultId, offset, nextOffset: end < text.length ? end : null, totalChars: text.length };
47
+ return { content: [{ type: 'text', text: `${JSON.stringify(cursor)}\n${text.slice(offset, end)}` }] };
48
+ },
49
+ };
50
+ return {
51
+ tool,
52
+ project({ result }) {
53
+ const texts = result.content.flatMap(block => block.type === 'text' ? [block.text] : []);
54
+ const totalChars = texts.reduce((sum, text) => sum + text.length, 0);
55
+ if (totalChars <= INLINE_CHARS)
56
+ return result;
57
+ // Keep small metadata blocks inline; page medium blocks too when their combined size spills.
58
+ const retained = texts.filter(text => text.length > SMALL_BLOCK_CHARS);
59
+ const retainedChars = retained.reduce((sum, text) => sum + text.length, 0);
60
+ // Admit one response atomically so its later blocks cannot evict its earlier block IDs.
61
+ if (retained.length > MAX_RESULTS || retainedChars > MAX_STORED_CHARS || totalChars - retainedChars > 4000)
62
+ return { isError: true, content: [{ type: 'text',
63
+ text: 'Tool result exceeds the saved-result limit. Repeat the original tool call with a narrower query. Never use Bash to read CLI spill files.' }] };
64
+ return { ...result, content: result.content.map(block => {
65
+ if (block.type !== 'text' || block.text.length <= SMALL_BLOCK_CHARS)
66
+ return block;
67
+ const resultId = save(block.text);
68
+ return { ...block, text: `Saved tool result (${block.text.length} characters). Read it with read_tool_result(${JSON.stringify({ resultId, offset: 0 })}); follow nextOffset until null. Never use Bash or shell commands to read saved tool results.` };
69
+ }) };
70
+ },
71
+ clear() { saved.clear(); storedChars = 0; },
72
+ };
73
+ }
74
+ //# sourceMappingURL=tool-result-pages.js.map
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @module @jini-ai/mcp/server/tool-server
3
- * Generic stdio hosting for an explicit caller-supplied tool list. Construction validates unique
4
- * names synchronously without I/O; run resolves the daemon URL, connects and serves until closure.
3
+ * Transport-neutral hosting for an explicit tool list, with an optional stdio lifecycle.
4
+ * Construction validates unique names without I/O; run resolves the daemon URL and connects.
5
+ * An embedding owns its explicit transport; a stdio run serves until closure.
5
6
  * Tools capability is always advertised; resources only when nonempty resources are supplied.
6
7
  * Narrow object-shaped server/transport ports permit DI fakes without requiring SDK class instances
7
8
  * with nominal private fields; default adapters translate those contracts to the real SDK.
@@ -73,6 +74,8 @@ export interface McpToolServerRequiredArgs {
73
74
  export interface McpToolServerOptions extends StdioHostingOptions {
74
75
  /** Set only by relay-enabled spawns; native input engines incur no extra request or tool. */
75
76
  readonly messageRelay?: McpMessageRelay;
77
+ /** Keep oversized text in this run and advertise read_tool_result instead of CLI spill files. */
78
+ readonly resultPaging?: boolean;
76
79
  /** Opt in to bounded, per-run typed publication after describe_tool. */
77
80
  readonly delegatedTools?: McpDelegatedToolsOptions;
78
81
  /** Trusted daemon registration IDs carried into every error sanitizer. */
@@ -121,12 +124,15 @@ export interface McpToolServerHandle {
121
124
  /**
122
125
  * Resolves the daemon base URL, connects the MCP server to its transport, and holds the process
123
126
  * open until the client disconnects (stdin EOF) or the idle-exit window elapses. Resolves once
124
- * the transport has closed.
127
+ * the transport has closed. With an explicit transport, connects and returns immediately;
128
+ * the embedding owns closure, without stdin or an idle-exit timer.
125
129
  */
126
- run(required: Record<string, never>): Promise<void>;
130
+ run(required: {
131
+ transport?: McpTransportLike;
132
+ }): Promise<void>;
127
133
  }
128
134
  /**
129
- * Builds a stdio MCP server hosting `requiredArgs.tools` (and, optionally, `options.resources`).
135
+ * Builds an MCP tool host with a stdio lifecycle unless run() receives an explicit transport.
130
136
  * Construction is synchronous and cheap (validates tool-name uniqueness via
131
137
  * {@link buildToolIndex} and resource-uri uniqueness via {@link buildResourceIndex}); all I/O —
132
138
  * resolving the daemon URL, connecting the transport, serving requests — happens in the returned
@@ -7,6 +7,8 @@ import { createMcpIdleExitController } from '../client/client.js';
7
7
  import { buildResourceIndex, handleResourceRead, resourcesToList } from './resource-protocol.js';
8
8
  import { buildToolIndex, handleToolCall, toolsToList } from './tool-protocol.js';
9
9
  import { createTypedDelegatedToolTool } from './tools/delegated-tool.js';
10
+ import { resolveMcpToolName } from './tool-arguments.js';
11
+ import { createToolResultPages } from './tool-result-pages.js';
10
12
  import { prependRunUserMessages } from './message-relay.js';
11
13
  import { createStdioHosting, validateFrameLimit, wantsStdioHosting } from './stdio-hosting.js';
12
14
  /** Auto-exit an idle server after this long without tool activity. */
@@ -155,7 +157,7 @@ function createToolPublisher({ index, server, options }) {
155
157
  };
156
158
  }
157
159
  /**
158
- * Builds a stdio MCP server hosting `requiredArgs.tools` (and, optionally, `options.resources`).
160
+ * Builds an MCP tool host with a stdio lifecycle unless run() receives an explicit transport.
159
161
  * Construction is synchronous and cheap (validates tool-name uniqueness via
160
162
  * {@link buildToolIndex} and resource-uri uniqueness via {@link buildResourceIndex}); all I/O —
161
163
  * resolving the daemon URL, connecting the transport, serving requests — happens in the returned
@@ -169,20 +171,79 @@ export function createMcpToolServer(requiredArgs, options = {}) {
169
171
  const resourceIndex = buildResourceIndex({ resources });
170
172
  const createServer = options.createServer ?? defaultCreateServer;
171
173
  const createTransport = options.createTransport ?? defaultCreateTransport;
172
- return {
173
- async run(_required) {
174
- const toolIndex = buildToolIndex({ tools: requiredArgs.tools });
175
- let ctx = buildToolContext({ resolvedBaseUrl: await requiredArgs.resolveBaseUrl() }, options);
176
- const server = createServer({
177
- info: { name: requiredArgs.name, version: requiredArgs.version },
178
- options: {
179
- capabilities: { tools: options.delegatedTools ? { listChanged: true } : {}, ...(resources.length > 0 ? { resources: {} } : {}) },
180
- ...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
181
- },
174
+ /** Install the shared tool/resource dispatch for either hosting lifecycle. O(tools + resources).
175
+ * Transport closure clears result pages; the returned cleanup also covers failed stdio closure.
176
+ */
177
+ async function connectHost({ transport, withActivity }) {
178
+ const resultPages = options.resultPaging ? createToolResultPages({}) : undefined;
179
+ const toolIndex = buildToolIndex({ tools: [...requiredArgs.tools, ...(resultPages ? [resultPages.tool] : [])] });
180
+ let ctx = buildToolContext({ resolvedBaseUrl: await requiredArgs.resolveBaseUrl() }, options);
181
+ const server = createServer({
182
+ info: { name: requiredArgs.name, version: requiredArgs.version },
183
+ options: {
184
+ capabilities: { tools: options.delegatedTools ? { listChanged: true } : {}, ...(resources.length > 0 ? { resources: {} } : {}) },
185
+ ...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
186
+ },
187
+ });
188
+ const publishTool = createToolPublisher({ index: toolIndex, server, options: options.delegatedTools });
189
+ if (publishTool)
190
+ ctx = { ...ctx, publishTool };
191
+ server.setRequestHandler({
192
+ schema: ListToolsRequestSchema,
193
+ handler: withActivity(async () => ({ tools: toolsToList({ tools: [...toolIndex.values()] }) })),
194
+ });
195
+ server.setRequestHandler({
196
+ schema: CallToolRequestSchema,
197
+ handler: withActivity(async ({ request }, { extra } = {}) => {
198
+ const callContext = extra?.signal !== undefined ? { ...ctx, signal: extra.signal } : ctx;
199
+ let checkedMessages = resolveMcpToolName({ name: request.params.name }, { isRegistered: candidate => toolIndex.has(candidate) }) === 'check_messages';
200
+ const callLocalTool = async ({ name, input, requireReadOnly }, _optional = {}) => {
201
+ const tool = toolIndex.get(resolveMcpToolName({ name }, { isRegistered: candidate => toolIndex.has(candidate) }));
202
+ if (!tool)
203
+ return undefined;
204
+ if (requireReadOnly && tool.annotations?.readOnlyHint !== true) {
205
+ throw new Error(`Tool "${name}" is not declared read-only; use execute_delegated_tool.`);
206
+ }
207
+ checkedMessages = tool.name === 'check_messages';
208
+ // Keep nesting bounded: a local gateway's own handler receives the base context.
209
+ return handleToolCall({ name, tools: toolIndex, ctx: callContext }, { rawArgs: input });
210
+ };
211
+ const result = await handleToolCall({ name: request.params.name, tools: toolIndex,
212
+ ctx: { ...callContext, callLocalTool } }, { rawArgs: request.params.arguments });
213
+ // A delegated check must keep its trusted nonce and claim only once, just like a direct check.
214
+ const readableResult = checkedMessages ? result : (resultPages?.project({ result }) ?? result);
215
+ if (!options.messageRelay || checkedMessages)
216
+ return readableResult;
217
+ return prependRunUserMessages({ result: readableResult, relay: options.messageRelay, ctx: callContext });
218
+ }),
219
+ });
220
+ if (resources.length > 0) {
221
+ server.setRequestHandler({
222
+ schema: ListResourcesRequestSchema,
223
+ handler: withActivity(async () => ({ resources: resourcesToList({ resources }) })),
224
+ });
225
+ server.setRequestHandler({
226
+ schema: ReadResourceRequestSchema,
227
+ handler: withActivity(async ({ request }) => handleResourceRead({ uri: request.params.uri, resources: resourceIndex, ctx })),
182
228
  });
183
- const publishTool = createToolPublisher({ index: toolIndex, server, options: options.delegatedTools });
184
- if (publishTool)
185
- ctx = { ...ctx, publishTool };
229
+ }
230
+ try {
231
+ await server.connect({ transport });
232
+ const onclose = transport.onclose;
233
+ transport.onclose = () => { onclose?.(); resultPages?.clear({}); };
234
+ return () => resultPages?.clear({});
235
+ }
236
+ catch (error) {
237
+ resultPages?.clear({});
238
+ throw error;
239
+ }
240
+ }
241
+ return {
242
+ async run({ transport: externalTransport }) {
243
+ if (externalTransport) {
244
+ await connectHost({ transport: externalTransport, withActivity: handler => async (...args) => handler(...args) });
245
+ return;
246
+ }
186
247
  let closeTransportForIdle = null;
187
248
  const idleExit = createMcpIdleExitController({
188
249
  idleMs: options.idleMs ?? DEFAULT_IDLE_MS,
@@ -190,50 +251,13 @@ export function createMcpToolServer(requiredArgs, options = {}) {
190
251
  });
191
252
  const hosting = hostingForOptions(options);
192
253
  const withActivity = createActivityTracker({ idleExit, hosting });
193
- server.setRequestHandler({
194
- schema: ListToolsRequestSchema,
195
- handler: withActivity(async () => ({ tools: toolsToList({ tools: [...toolIndex.values()] }) })),
196
- });
197
- server.setRequestHandler({
198
- schema: CallToolRequestSchema,
199
- handler: withActivity(async ({ request }, { extra } = {}) => {
200
- const callContext = extra?.signal !== undefined ? { ...ctx, signal: extra.signal } : ctx;
201
- let checkedMessages = request.params.name === 'check_messages';
202
- const callLocalTool = async ({ name, input, requireReadOnly }, _optional = {}) => {
203
- const tool = toolIndex.get(name);
204
- if (!tool)
205
- return undefined;
206
- if (requireReadOnly && tool.annotations?.readOnlyHint !== true) {
207
- throw new Error(`Tool "${name}" is not declared read-only; use execute_delegated_tool.`);
208
- }
209
- checkedMessages = name === 'check_messages';
210
- // Keep nesting bounded: a local gateway's own handler receives the base context.
211
- return handleToolCall({ name, tools: toolIndex, ctx: callContext }, { rawArgs: input });
212
- };
213
- const result = await handleToolCall({ name: request.params.name, tools: toolIndex,
214
- ctx: { ...callContext, callLocalTool } }, { rawArgs: request.params.arguments });
215
- // A delegated check must keep its trusted nonce and claim only once, just like a direct check.
216
- if (!options.messageRelay || checkedMessages)
217
- return result;
218
- return prependRunUserMessages({ result, relay: options.messageRelay, ctx: callContext });
219
- }),
220
- });
221
- if (resources.length > 0) {
222
- server.setRequestHandler({
223
- schema: ListResourcesRequestSchema,
224
- handler: withActivity(async () => ({ resources: resourcesToList({ resources }) })),
225
- });
226
- server.setRequestHandler({
227
- schema: ReadResourceRequestSchema,
228
- handler: withActivity(async ({ request }) => handleResourceRead({ uri: request.params.uri, resources: resourceIndex, ctx })),
229
- });
230
- }
231
254
  const transport = createHostedTransport({ createTransport, options, hosting });
255
+ let disposeHost;
232
256
  try {
233
257
  closeTransportForIdle = () => {
234
258
  void transport.close({}).catch(() => { });
235
259
  };
236
- await server.connect({ transport });
260
+ disposeHost = await connectHost({ transport, withActivity });
237
261
  // `connect()` sets `transport.onmessage` to its own protocol-dispatch handler; wrap it so
238
262
  // every inbound message also counts as activity for the idle-exit timer, without losing
239
263
  // the SDK's own routing.
@@ -245,6 +269,7 @@ export function createMcpToolServer(requiredArgs, options = {}) {
245
269
  await waitForTransportClose({ transport, idleExit, hosting, stdin: options.stdin ?? process.stdin });
246
270
  }
247
271
  finally {
272
+ disposeHost?.();
248
273
  idleExit.dispose({});
249
274
  if (hosting)
250
275
  void hosting.dispose();
@@ -1,7 +1,10 @@
1
1
  import { type ExposedToolDescriptor } from '@jini-ai/core';
2
2
  import { type McpToolDef } from '../tool-protocol.js';
3
3
  /**
4
- * Request deadline for one delegated tool call, overriding `daemon-client.ts`'s 15 s default.
4
+ * Superseded 2026-11-03: human reading time is bounded by the run, not a wall-clock budget.
5
+ * The historical reasoning below explains the former paired deadlines; it is not current policy.
6
+ *
7
+ * Historical rationale for the former bounded deadline, overriding `daemon-client.ts`'s 15 s default.
5
8
  *
6
9
  * That default is sized for "a slow tool call" — a tool doing work. It is the wrong shape for this
7
10
  * route, which is the one path in the system where a registered handler may legitimately be waiting
@@ -27,7 +30,7 @@ import { type McpToolDef } from '../tool-protocol.js';
27
30
  * than fifteen seconds. The daemon-side alternative does not exist to lean on — `ToolDescriptor.timeoutMs`
28
31
  * is unset on every registration today, so `ToolExecutor` arms no timer of its own.
29
32
  */
30
- export declare const DEFAULT_DELEGATED_TOOL_TIMEOUT_MS: number;
33
+ export declare const DEFAULT_DELEGATED_TOOL_TIMEOUT_MS: null;
31
34
  export interface CreateExecuteDelegatedToolToolRequiredArgs {
32
35
  /** The one run this MCP server process — and therefore this tool instance — is scoped to. */
33
36
  readonly runId: string;
@@ -37,14 +40,12 @@ export interface CreateExecuteDelegatedToolToolOptions {
37
40
  /** Generates each call's `toolUseId`. @default node:crypto randomUUID */
38
41
  readonly generateToolUseId?: (required: Record<string, never>) => string;
39
42
  /**
40
- * Request deadline for one delegated tool call, in milliseconds. Set this to match the host's
41
- * own exchange total-lifetime ceiling plus headroom, so the exchange — not this request — is
42
- * what ends a call the human never answers.
43
+ * Optional finite request deadline in milliseconds. Omit for a run-lifetime human wait.
43
44
  *
44
45
  * Must be a finite number greater than zero; anything else falls back to the default rather
45
46
  * than arming a nonsensical (or immediately-firing) timer.
46
47
  *
47
- * @default DEFAULT_DELEGATED_TOOL_TIMEOUT_MS (6 min)
48
+ * @default DEFAULT_DELEGATED_TOOL_TIMEOUT_MS (no wall-clock deadline)
48
49
  */
49
50
  readonly delegatedToolTimeoutMs?: number;
50
51
  /** Emit v1 metadata only when the host has enabled daemon/runtime echo correlation. Default off. */
@@ -39,6 +39,7 @@ import { parseMcpToolInputSchema } from '../adapters/protocol.js';
39
39
  import { randomUUID } from 'node:crypto';
40
40
  import { postDaemonJson } from '../daemon-client.js';
41
41
  import { daemonCallOptions, requireString } from '../tool-protocol.js';
42
+ import { resolveMcpToolName } from '../tool-arguments.js';
42
43
  /** Shared by legacy gateways and concrete definitions for every MCP-capable agent CLI. */
43
44
  const APPROVAL_GUIDANCE = 'Never say an action has started until its tool result explicitly says it started. An approval-gated action waiting for Confirm has not started. While approval is pending, other writes are refused with awaiting_approval and started:false; reads may continue. Wait for the final result before reporting an outcome.';
44
45
  function isRecord(value) {
@@ -76,7 +77,10 @@ function unwrapMcpContentEnvelope(result) {
76
77
  return output;
77
78
  }
78
79
  /**
79
- * Request deadline for one delegated tool call, overriding `daemon-client.ts`'s 15 s default.
80
+ * Superseded 2026-11-03: human reading time is bounded by the run, not a wall-clock budget.
81
+ * The historical reasoning below explains the former paired deadlines; it is not current policy.
82
+ *
83
+ * Historical rationale for the former bounded deadline, overriding `daemon-client.ts`'s 15 s default.
80
84
  *
81
85
  * That default is sized for "a slow tool call" — a tool doing work. It is the wrong shape for this
82
86
  * route, which is the one path in the system where a registered handler may legitimately be waiting
@@ -102,7 +106,7 @@ function unwrapMcpContentEnvelope(result) {
102
106
  * than fifteen seconds. The daemon-side alternative does not exist to lean on — `ToolDescriptor.timeoutMs`
103
107
  * is unset on every registration today, so `ToolExecutor` arms no timer of its own.
104
108
  */
105
- export const DEFAULT_DELEGATED_TOOL_TIMEOUT_MS = 6 * 60 * 1000;
109
+ export const DEFAULT_DELEGATED_TOOL_TIMEOUT_MS = null;
106
110
  /**
107
111
  * Builds one gateway def. Both exported factories call this; there is no second copy of the
108
112
  * request body, the timeout rule, or the unwrap.
@@ -167,7 +171,7 @@ function createDelegatedGateway(variant, { runId }, options) {
167
171
  const body = {
168
172
  runId,
169
173
  toolUseId,
170
- toolId: toolId,
174
+ toolId: resolveMcpToolName({ name: toolId }, { isRegistered: candidate => ctx.diagnosticToolIds?.includes(candidate) ?? false }),
171
175
  // The schema tells the model to omit `input` for a tool that takes none, but most tool
172
176
  // handlers refuse an absent input ("input must be an object"). Sending `{}` makes the
173
177
  // schema's promise true instead of costing a red failed call and a retry.
@@ -266,6 +270,7 @@ export function createTypedDelegatedToolTool({ runId, tool }, options = {}) {
266
270
  description: `${descriptor.description ?? descriptor.id} ${APPROVAL_GUIDANCE}`,
267
271
  // Parse to check the MCP boundary, but publish the original schema, including extensions.
268
272
  inputSchema: descriptor.inputSchema,
273
+ argumentNormalization: { schemaInput: true },
269
274
  annotations: { ...gateway.annotations, title: descriptor.id },
270
275
  handler: ({ args, ctx }) => gateway.handler({ args: { toolId: descriptor.id, input: args }, ctx }),
271
276
  };
@@ -0,0 +1,5 @@
1
+ import type { McpToolDef } from '../tool-protocol.js';
2
+ /** A bounded, cancellable pause shared by all MCP clients; no daemon call or shell process.
3
+ * Long poll intervals use several calls, allowing the agent to check messages between waits. */
4
+ export declare const waitTool: McpToolDef;
5
+ //# sourceMappingURL=wait.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { setTimeout } from 'node:timers/promises';
2
+ /** A bounded, cancellable pause shared by all MCP clients; no daemon call or shell process.
3
+ * Long poll intervals use several calls, allowing the agent to check messages between waits. */
4
+ export const waitTool = {
5
+ name: 'wait_for',
6
+ description: 'Wait before polling a pending background job again. Pass seconds from poll_after_seconds, up to 60 per call; repeat for longer intervals. Returns waitedSeconds after the pause. Cancellation stops the timer. Use this instead of Bash or shell sleep. This tool does not check job status.',
7
+ inputSchema: { type: 'object', required: ['seconds'], additionalProperties: false,
8
+ properties: { seconds: { type: 'number', minimum: 0, maximum: 60 } } },
9
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10
+ async handler({ args, ctx }) {
11
+ // handleToolCall validates the duration before this handler is reached.
12
+ const seconds = args['seconds'];
13
+ await setTimeout(seconds * 1000, undefined, { signal: ctx.signal });
14
+ return { waitedSeconds: seconds };
15
+ },
16
+ };
17
+ //# sourceMappingURL=wait.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/mcp",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
4
4
  "description": "Model Context Protocol adapters: server config schema and IO, an MCP client, and agent-side MCP server installation.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -85,16 +85,16 @@
85
85
  "dependencies": {
86
86
  "@cfworker/json-schema": "^4.1.1",
87
87
  "@modelcontextprotocol/sdk": "^1.29.0",
88
- "@jini-ai/platform": "^0.6.17",
89
- "@jini-ai/core": "^0.6.17",
90
- "@jini-ai/protocol": "^0.6.17"
88
+ "@jini-ai/core": "^0.6.19",
89
+ "@jini-ai/platform": "^0.6.19",
90
+ "@jini-ai/protocol": "^0.6.19"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@cfworker/json-schema": "^4.1.1",
94
94
  "@modelcontextprotocol/sdk": "^1.29.0",
95
- "@jini-ai/core": "^0.6.17",
96
- "@jini-ai/platform": "^0.6.17",
97
- "@jini-ai/daemon": "^0.6.17"
95
+ "@jini-ai/daemon": "^0.6.19",
96
+ "@jini-ai/platform": "^0.6.19",
97
+ "@jini-ai/core": "^0.6.19"
98
98
  },
99
99
  "scripts": {
100
100
  "build": "tsc -p tsconfig.json && chmod +x dist/bin/serve.js",