@jini-ai/mcp 0.6.12 → 0.6.15-rc.2

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 (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +21 -3
  3. package/dist/agent-install/install.d.ts +11 -0
  4. package/dist/agent-install/install.js +21 -3
  5. package/dist/bin/serve.d.ts +0 -37
  6. package/dist/bin/serve.js +41 -1
  7. package/dist/core/config.js +3 -3
  8. package/dist/core/tokens.d.ts +5 -1
  9. package/dist/core/tokens.js +17 -4
  10. package/dist/federation/adapter.http.d.ts +6 -2
  11. package/dist/federation/adapter.http.js +34 -25
  12. package/dist/federation/approvals/revocation-reason.d.ts +3 -3
  13. package/dist/federation/approvals/revocation-reason.js +3 -3
  14. package/dist/federation/approvals/revocation.d.ts +2 -2
  15. package/dist/federation/approvals/revocation.js +1 -1
  16. package/dist/federation/ports.d.ts +18 -5
  17. package/dist/federation/registrations.d.ts +19 -4
  18. package/dist/federation/registrations.js +84 -22
  19. package/dist/federation/tool-approvals.d.ts +1 -1
  20. package/dist/federation/tool-approvals.js +11 -24
  21. package/dist/federation/trust.d.ts +40 -11
  22. package/dist/federation/trust.js +42 -9
  23. package/dist/server/adapters/protocol.d.ts +9 -0
  24. package/dist/server/adapters/protocol.js +9 -0
  25. package/dist/server/daemon-client.d.ts +13 -0
  26. package/dist/server/daemon-client.js +16 -3
  27. package/dist/server/message-relay.d.ts +1 -1
  28. package/dist/server/message-relay.js +1 -1
  29. package/dist/server/schema-ref-scope.d.ts +23 -0
  30. package/dist/server/schema-ref-scope.js +69 -0
  31. package/dist/server/stdio-hosting.d.ts +51 -0
  32. package/dist/server/stdio-hosting.js +216 -0
  33. package/dist/server/tool-arguments.d.ts +1 -1
  34. package/dist/server/tool-protocol.d.ts +6 -7
  35. package/dist/server/tool-protocol.js +20 -7
  36. package/dist/server/tool-server.d.ts +3 -1
  37. package/dist/server/tool-server.js +39 -4
  38. package/dist/server/tools/delegated-tool.js +2 -2
  39. package/dist/server/tools/tool-catalog-tools.js +7 -3
  40. package/package.json +7 -7
@@ -0,0 +1,51 @@
1
+ import { Writable, type Readable } from 'node:stream';
2
+ import type { McpTransportLike } from './ports.js';
3
+ export interface StdioHostingOptions {
4
+ readonly maxFrameBytes?: number;
5
+ readonly serial?: boolean;
6
+ readonly eof?: 'close' | 'drain';
7
+ }
8
+ /** Validate the opt-in limit before construction performs any I/O. */
9
+ export declare function validateFrameLimit({ maxFrameBytes }: StdioHostingOptions): void;
10
+ /** True when any hosting guard is requested; omitted options keep the legacy lifecycle. */
11
+ export declare function wantsStdioHosting({ maxFrameBytes, serial, eof }: StdioHostingOptions): boolean;
12
+ /**
13
+ * One run's guards. Stream failures and outgoing overflow are recorded and close the transport;
14
+ * the host's hold-open promise rejects with the first recorded failure.
15
+ * @complexity O(n) per n inbound bytes; O(r) bookkeeping for r accepted requests.
16
+ */
17
+ export declare function createStdioHosting({ stdin, stdout, options }: {
18
+ stdin: Readable;
19
+ stdout: Writable;
20
+ options: StdioHostingOptions;
21
+ }): {
22
+ /** The guarded pair handed to `createTransport` in place of the configured streams. */
23
+ streams: {
24
+ stdin: Readable;
25
+ stdout: Writable;
26
+ };
27
+ /** Bound outgoing frames on the SDK transport this run created. Narrow fakes have no send. */
28
+ guardTransport({ transport }: {
29
+ transport: McpTransportLike;
30
+ }): void;
31
+ /** Serial mode starts each handler only after the previous one settles and its response is written. */
32
+ track<T>({ task, signal }: {
33
+ task: () => Promise<T>;
34
+ signal: AbortSignal | undefined;
35
+ }): Promise<T>;
36
+ /** Settles once `result` settled and its response write completed, or the run stopped; never rejects. */
37
+ responseWritten({ result }: {
38
+ result: Promise<unknown>;
39
+ }): Promise<void>;
40
+ /** Install EOF handling once connected; a failure recorded during connect closes at once. */
41
+ watch({ close }: {
42
+ close: () => void;
43
+ }): void;
44
+ /** Idempotent detach; queued handlers that have not started are skipped.
45
+ * @returns The first recorded failure, which the run rejects with. */
46
+ dispose(): Promise<{
47
+ error: unknown;
48
+ } | undefined>;
49
+ };
50
+ export type StdioHosting = ReturnType<typeof createStdioHosting>;
51
+ //# sourceMappingURL=stdio-hosting.d.ts.map
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Private opt-in stdio guards for `createMcpToolServer`: per-frame byte caps, FIFO request
3
+ * execution through response writes, and EOF draining. The SDK keeps JSON parsing, dispatch and
4
+ * serialization; these guards only sit between it and the host-owned streams.
5
+ */
6
+ import { once } from 'node:events';
7
+ import { Transform, Writable } from 'node:stream';
8
+ import { setImmediate } from 'node:timers/promises';
9
+ import { serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
10
+ import { isJSONRPCErrorResponse, isJSONRPCResultResponse } from '@modelcontextprotocol/sdk/types.js';
11
+ import { resolveSdkTransport } from './sdk-transport.js';
12
+ /** Validate the opt-in limit before construction performs any I/O. */
13
+ export function validateFrameLimit({ maxFrameBytes }) {
14
+ if (maxFrameBytes === undefined)
15
+ return;
16
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
17
+ throw new RangeError('maxFrameBytes must be a positive safe integer');
18
+ }
19
+ }
20
+ /** True when any hosting guard is requested; omitted options keep the legacy lifecycle. */
21
+ export function wantsStdioHosting({ maxFrameBytes, serial, eof }) {
22
+ return maxFrameBytes !== undefined || serial === true || eof === 'drain';
23
+ }
24
+ /** Measure the SDK's actual serialization; an oversized response becomes a bounded error.
25
+ * @throws RangeError when a request/notification or the minimal replacement exceeds the cap.
26
+ */
27
+ function boundedMessage({ message, maxFrameBytes }) {
28
+ if (Buffer.byteLength(serializeMessage(message)) <= maxFrameBytes)
29
+ return message;
30
+ if (!isJSONRPCResultResponse(message) && !isJSONRPCErrorResponse(message)) {
31
+ throw new RangeError('MCP outgoing frame exceeds maxFrameBytes');
32
+ }
33
+ const replacement = { jsonrpc: '2.0', id: message.id, error: { code: -32603, message: 'Response exceeds maxFrameBytes' } };
34
+ if (Buffer.byteLength(serializeMessage(replacement)) > maxFrameBytes) {
35
+ throw new RangeError('MCP error response exceeds maxFrameBytes');
36
+ }
37
+ return replacement;
38
+ }
39
+ /** Count bytes per LF-terminated frame before the SDK's unbounded read buffer sees them.
40
+ * Chunks pass through unchanged; the SDK still owns CRLF handling and malformed-JSON policy.
41
+ */
42
+ function createFrameGuard({ maxFrameBytes }) {
43
+ let partial = 0;
44
+ const guard = new Transform({
45
+ transform(chunk, _encoding, callback) {
46
+ let start = 0;
47
+ for (let lf = chunk.indexOf(10); lf !== -1; lf = chunk.indexOf(10, start)) {
48
+ if (partial + lf + 1 - start > maxFrameBytes)
49
+ break;
50
+ partial = 0;
51
+ start = lf + 1;
52
+ }
53
+ partial += chunk.length - start;
54
+ callback(partial > maxFrameBytes ? new RangeError('MCP incoming frame exceeds maxFrameBytes') : null, chunk);
55
+ },
56
+ });
57
+ return { guard, hasTail: () => partial > 0 };
58
+ }
59
+ /** A high-water mark of 1 makes every SDK write wait for the host stdout's real write callback. */
60
+ function createWriteTracker({ stdout }) {
61
+ return new Writable({ highWaterMark: 1, write(chunk, encoding, callback) { stdout.write(chunk, encoding, callback); } });
62
+ }
63
+ /** Insert only the stream guards the options need; the host keeps ownership of stdin/stdout. */
64
+ function createGuardedStreams({ stdin, stdout, options: { maxFrameBytes, serial, eof } }) {
65
+ const drain = eof === 'drain';
66
+ const input = maxFrameBytes !== undefined || drain ? createFrameGuard({ maxFrameBytes: maxFrameBytes ?? Infinity }) : undefined;
67
+ const output = serial === true || drain ? createWriteTracker({ stdout }) : undefined;
68
+ if (input)
69
+ stdin.pipe(input.guard);
70
+ return { input, output, streams: { stdin: input?.guard ?? stdin, stdout: output ?? stdout } };
71
+ }
72
+ /**
73
+ * One run's guards. Stream failures and outgoing overflow are recorded and close the transport;
74
+ * the host's hold-open promise rejects with the first recorded failure.
75
+ * @complexity O(n) per n inbound bytes; O(r) bookkeeping for r accepted requests.
76
+ */
77
+ export function createStdioHosting({ stdin, stdout, options }) {
78
+ const { maxFrameBytes, serial = false } = options;
79
+ const { input, output, streams } = createGuardedStreams({ stdin, stdout, options });
80
+ const inFlight = new Set();
81
+ const removers = [];
82
+ let tail = Promise.resolve();
83
+ let failure;
84
+ let closeTransport;
85
+ let stopped = false;
86
+ const stop = new AbortController();
87
+ let disposed;
88
+ const listen = (emitter, event, fn) => {
89
+ emitter.on(event, fn);
90
+ removers.push(() => emitter.off(event, fn));
91
+ };
92
+ const fail = (error) => {
93
+ failure ??= { error };
94
+ closeTransport?.();
95
+ };
96
+ for (const stream of [stdin, stdout, input?.guard, output])
97
+ if (stream)
98
+ listen(stream, 'error', fail);
99
+ async function writesDone() {
100
+ while (output && output.writableLength > 0)
101
+ await once(output, 'drain');
102
+ }
103
+ /** Yield so SDK dispatch reaches handlers/sends, then wait until tracked writes complete. */
104
+ async function flushWrites() {
105
+ await setImmediate();
106
+ await writesDone();
107
+ }
108
+ async function drain() {
109
+ try {
110
+ await flushWrites();
111
+ while (inFlight.size > 0) {
112
+ await Promise.allSettled([...inFlight]);
113
+ await flushWrites();
114
+ }
115
+ if (input?.hasTail())
116
+ failure ??= { error: new Error('MCP stdin ended with an unterminated frame') };
117
+ }
118
+ catch (error) {
119
+ failure ??= { error };
120
+ }
121
+ closeTransport?.();
122
+ }
123
+ return {
124
+ /** The guarded pair handed to `createTransport` in place of the configured streams. */
125
+ streams,
126
+ /** Bound outgoing frames on the SDK transport this run created. Narrow fakes have no send. */
127
+ guardTransport({ transport }) {
128
+ const sdk = resolveSdkTransport({ transport });
129
+ if (maxFrameBytes === undefined || typeof sdk.send !== 'function')
130
+ return;
131
+ const send = sdk.send.bind(sdk);
132
+ sdk.send = (message, sendOptions) => {
133
+ let bounded;
134
+ try {
135
+ bounded = boundedMessage({ message, maxFrameBytes });
136
+ }
137
+ catch (error) {
138
+ fail(error);
139
+ throw error;
140
+ }
141
+ return send(bounded, sendOptions);
142
+ };
143
+ },
144
+ /** Serial mode starts each handler only after the previous one settles and its response is written. */
145
+ track({ task, signal }) {
146
+ // Behind another request, yield first so the SDK can send that request's response.
147
+ const behind = inFlight.size > 0;
148
+ const run = async () => {
149
+ if (serial)
150
+ await (behind ? flushWrites() : writesDone());
151
+ if (stopped || signal?.aborted)
152
+ throw new Error('MCP request cancelled');
153
+ return task();
154
+ };
155
+ const result = serial ? tail.then(run) : run();
156
+ const settled = result.then(() => { }, () => { });
157
+ inFlight.add(settled);
158
+ void settled.then(() => inFlight.delete(settled));
159
+ if (serial)
160
+ tail = settled;
161
+ return result;
162
+ },
163
+ /** Settles once `result` settled and its response write completed, or the run stopped; never rejects. */
164
+ async responseWritten({ result }) {
165
+ await result.then(() => { }, () => { });
166
+ await setImmediate();
167
+ // Abortable unlike writesDone: a write still pending at dispose never drains.
168
+ try {
169
+ while (output && output.writableLength > 0)
170
+ await once(output, 'drain', { signal: stop.signal });
171
+ }
172
+ catch { /* run stopped */ }
173
+ },
174
+ /** Install EOF handling once connected; a failure recorded during connect closes at once. */
175
+ watch({ close }) {
176
+ closeTransport = close;
177
+ if (failure)
178
+ return close();
179
+ if (options.eof !== 'drain' || !input) {
180
+ listen(stdin, 'end', close);
181
+ listen(stdin, 'close', close);
182
+ return;
183
+ }
184
+ // The guard ends only after the SDK consumed every buffered frame.
185
+ listen(input.guard, 'end', () => void drain());
186
+ listen(stdin, 'close', () => { if (!stdin.readableEnded)
187
+ close(); });
188
+ // Embedding transports may not consume the guarded readable; still observe its end.
189
+ input.guard.resume();
190
+ if (input.guard.readableEnded)
191
+ void drain();
192
+ },
193
+ /** Idempotent detach; queued handlers that have not started are skipped.
194
+ * @returns The first recorded failure, which the run rejects with. */
195
+ dispose() {
196
+ disposed ??= (async () => {
197
+ stopped = true;
198
+ stop.abort();
199
+ closeTransport = undefined;
200
+ if (input) {
201
+ stdin.unpipe(input.guard);
202
+ input.guard.destroy();
203
+ }
204
+ output?.destroy();
205
+ // One failed write errors this wrapper and then the borrowed stdout in later turns;
206
+ // keep observing until both have reported, then restore the host's own listeners.
207
+ await setImmediate();
208
+ for (const remove of removers)
209
+ remove();
210
+ return failure;
211
+ })();
212
+ return disposed;
213
+ },
214
+ };
215
+ }
216
+ //# sourceMappingURL=stdio-hosting.js.map
@@ -9,7 +9,7 @@
9
9
  * See docs/decisions/DR-005-proxy-argument-shapes.md.
10
10
  */
11
11
  import type { OutputUnit } from '@cfworker/json-schema';
12
- import type { Tool } from '@modelcontextprotocol/sdk/types.js';
12
+ import type { Tool } from './adapters/protocol.js';
13
13
  /** Argument normalization's input contract, shared with the dispatcher without a reverse import. */
14
14
  export interface McpToolArgumentContract {
15
15
  readonly inputSchema: Tool['inputSchema'];
@@ -47,6 +47,7 @@
47
47
  import { type ToolDescriptor } from '@jini-ai/core';
48
48
  import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
49
49
  import { type McpToolArgumentContract } from './tool-arguments.js';
50
+ import { type DaemonRequestOptions } from './daemon-client.js';
50
51
  /** What every tool handler receives alongside its parsed arguments. */
51
52
  export interface McpToolContext {
52
53
  /** Publishes a successfully described, host-owned descriptor in this server run only. */
@@ -87,16 +88,13 @@ export interface McpToolContext {
87
88
  *
88
89
  * @param ctx - The context handed to a tool or resource handler.
89
90
  * @returns Options carrying the injected `fetch` and, when present, the auth headers.
90
- * @complexity O(1).
91
+ * @complexity O(h) to copy h configured headers; no I/O.
91
92
  * @overallScore 100/100
92
93
  */
93
94
  export declare function daemonCallOptions({ ctx }: {
94
95
  ctx: McpToolContext;
95
- }): {
96
+ }, _options?: {}): DaemonRequestOptions & {
96
97
  fetchImpl: typeof fetch;
97
- headers?: Record<string, string>;
98
- signal?: AbortSignal;
99
- diagnosticToolIds?: readonly string[];
100
98
  };
101
99
  /**
102
100
  * One MCP tool a `createMcpToolServer` caller registers. `name` must be unique within a given
@@ -157,8 +155,9 @@ export declare function buildToolIndex({ tools }: {
157
155
  * not JSON-RPC errors. Schema validation runs before every handler unconditionally: a caller (an
158
156
  * agent, not this repo's own code) supplies `rawArgs`, so a handler must never be trusted to check
159
157
  * its own declared contract on the untrusted-input path. Both a validation failure's message and a
160
- * thrown error's message are passed through {@link sanitizeUntrustedText} before reaching the
161
- * result: either can end up echoing caller- or daemon-supplied text. The validation prefix uses the
158
+ * thrown error's message are sanitized before reaching the result: either can end up echoing
159
+ * caller- or daemon-supplied text. Handler errors reuse {@link safeDiagnostic} so a second
160
+ * pass retains slugs/tool IDs while masking the configured auth-header credential. The validation prefix uses the
162
161
  * registered tool name outside sanitization so long gateway names are not mistaken for secrets.
163
162
  */
164
163
  export declare function handleToolCall({ name, tools, ctx }: {
@@ -49,6 +49,8 @@ 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
51
  import { argumentShapeMessage, isRecord, normalizeToolArguments } from './tool-arguments.js';
52
+ import { safeDiagnostic } from './daemon-client.js';
53
+ import { scopeNestedSchemaRefs } from './schema-ref-scope.js';
52
54
  /**
53
55
  * Maps a tool context onto the `DaemonRequestOptions` every daemon call needs.
54
56
  *
@@ -60,10 +62,10 @@ import { argumentShapeMessage, isRecord, normalizeToolArguments } from './tool-a
60
62
  *
61
63
  * @param ctx - The context handed to a tool or resource handler.
62
64
  * @returns Options carrying the injected `fetch` and, when present, the auth headers.
63
- * @complexity O(1).
65
+ * @complexity O(h) to copy h configured headers; no I/O.
64
66
  * @overallScore 100/100
65
67
  */
66
- export function daemonCallOptions({ ctx }) {
68
+ export function daemonCallOptions({ ctx }, _options = {}) {
67
69
  return {
68
70
  fetchImpl: ctx.fetchImpl,
69
71
  ...(ctx.diagnosticToolIds !== undefined ? { diagnosticToolIds: ctx.diagnosticToolIds } : {}),
@@ -137,12 +139,22 @@ export function requireString({ value, name }) {
137
139
  }
138
140
  return value;
139
141
  }
142
+ /** Each tool's input schema as published and validated: embedded `$defs` refs scoped (see schema-ref-scope.ts). */
143
+ const wireSchemas = new WeakMap();
144
+ function wireInputSchema(tool) {
145
+ let schema = wireSchemas.get(tool);
146
+ if (schema === undefined) {
147
+ schema = scopeNestedSchemaRefs({ schema: tool.inputSchema });
148
+ wireSchemas.set(tool, schema);
149
+ }
150
+ return schema;
151
+ }
140
152
  /** Projects a tool list into the `Tool[]` shape `tools/list` returns. */
141
153
  export function toolsToList({ tools }) {
142
154
  return tools.map((tool) => ({
143
155
  name: tool.name,
144
156
  description: tool.description,
145
- inputSchema: tool.inputSchema,
157
+ inputSchema: wireInputSchema(tool),
146
158
  ...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}),
147
159
  }));
148
160
  }
@@ -166,7 +178,7 @@ function validatorForTool(tool) {
166
178
  return cached;
167
179
  // The validator adds internal URI fields while compiling. F0's published schemas are frozen;
168
180
  // compile a detached copy so validation cannot mutate the immutable wire contract.
169
- const validator = new Validator(structuredClone(tool.inputSchema), '2020-12', true);
181
+ const validator = new Validator(structuredClone(wireInputSchema(tool)), '2020-12', true);
170
182
  compiledValidators.set(tool, validator);
171
183
  return validator;
172
184
  }
@@ -179,8 +191,9 @@ function validatorForTool(tool) {
179
191
  * not JSON-RPC errors. Schema validation runs before every handler unconditionally: a caller (an
180
192
  * agent, not this repo's own code) supplies `rawArgs`, so a handler must never be trusted to check
181
193
  * its own declared contract on the untrusted-input path. Both a validation failure's message and a
182
- * thrown error's message are passed through {@link sanitizeUntrustedText} before reaching the
183
- * result: either can end up echoing caller- or daemon-supplied text. The validation prefix uses the
194
+ * thrown error's message are sanitized before reaching the result: either can end up echoing
195
+ * caller- or daemon-supplied text. Handler errors reuse {@link safeDiagnostic} so a second
196
+ * pass retains slugs/tool IDs while masking the configured auth-header credential. The validation prefix uses the
184
197
  * registered tool name outside sanitization so long gateway names are not mistaken for secrets.
185
198
  */
186
199
  export async function handleToolCall({ name, tools, ctx }, { rawArgs } = {}) {
@@ -211,7 +224,7 @@ export async function handleToolCall({ name, tools, ctx }, { rawArgs } = {}) {
211
224
  }
212
225
  catch (err) {
213
226
  const message = err instanceof Error ? err.message : String(err);
214
- return errorResult({ message: sanitizeUntrustedText({ text: message }, { diagnosticToolIds }) });
227
+ return errorResult({ message: safeDiagnostic({ text: message }, daemonCallOptions({ ctx })) });
215
228
  }
216
229
  }
217
230
  //# sourceMappingURL=tool-protocol.js.map
@@ -17,6 +17,7 @@ import { type McpResourceDef } from './resource-protocol.js';
17
17
  import { type McpToolDef } from './tool-protocol.js';
18
18
  import { type CreateExecuteDelegatedToolToolOptions } from './tools/delegated-tool.js';
19
19
  import { type McpMessageRelay } from './message-relay.js';
20
+ import { type StdioHostingOptions } from './stdio-hosting.js';
20
21
  /** Object-shaped server port. The default adapter translates it to the SDK interface. */
21
22
  export interface McpServerLike {
22
23
  setRequestHandler(args: {
@@ -68,7 +69,8 @@ export interface McpToolServerRequiredArgs {
68
69
  /** Resolves once when the returned handle runs. */
69
70
  readonly resolveBaseUrl: () => Promise<string> | string;
70
71
  }
71
- export interface McpToolServerOptions {
72
+ /** Opt-in stdio guards; omitting all three keeps SDK limits, concurrency and immediate EOF closure. */
73
+ export interface McpToolServerOptions extends StdioHostingOptions {
72
74
  /** Set only by relay-enabled spawns; native input engines incur no extra request or tool. */
73
75
  readonly messageRelay?: McpMessageRelay;
74
76
  /** Opt in to bounded, per-run typed publication after describe_tool. */
@@ -8,6 +8,7 @@ import { buildResourceIndex, handleResourceRead, resourcesToList } from './resou
8
8
  import { buildToolIndex, handleToolCall, toolsToList } from './tool-protocol.js';
9
9
  import { createTypedDelegatedToolTool } from './tools/delegated-tool.js';
10
10
  import { prependRunUserMessages } from './message-relay.js';
11
+ import { createStdioHosting, validateFrameLimit, wantsStdioHosting } from './stdio-hosting.js';
11
12
  /** Auto-exit an idle server after this long without tool activity. */
12
13
  const DEFAULT_IDLE_MS = 30 * 60 * 1000;
13
14
  // SDK objects remain behind these adapters; hosts implement the object-shaped ports above.
@@ -42,6 +43,20 @@ function stdioOptions(options) {
42
43
  ...(options.stdout === undefined ? {} : { stdout: options.stdout }),
43
44
  };
44
45
  }
46
+ /** Opt-in stdio guards for this run, or undefined to keep the legacy lifecycle. */
47
+ function hostingForOptions(options) {
48
+ if (!wantsStdioHosting(options))
49
+ return undefined;
50
+ return createStdioHosting({ stdin: options.stdin ?? process.stdin, stdout: options.stdout ?? process.stdout, options });
51
+ }
52
+ /** Hosted runs hand the guarded stream pair to the unchanged transport factory. */
53
+ function createHostedTransport({ createTransport, options, hosting }) {
54
+ if (!hosting)
55
+ return createTransport({}, stdioOptions(options));
56
+ const transport = createTransport({}, hosting.streams);
57
+ hosting.guardTransport({ transport });
58
+ return transport;
59
+ }
45
60
  /**
46
61
  * Own only the MCP projection/dispatch index. F0 owns immutable exposure, and the daemon catalog
47
62
  * supplies each descriptor; the temporary source below is not another definition catalog.
@@ -102,6 +117,7 @@ function createToolPublisher({ index, server, options }) {
102
117
  * handle's `run()`.
103
118
  */
104
119
  export function createMcpToolServer(requiredArgs, options = {}) {
120
+ validateFrameLimit(options);
105
121
  // Validate eagerly, but keep the mutable dispatch index inside run() so re-runs are isolated.
106
122
  buildToolIndex({ tools: requiredArgs.tools });
107
123
  const resources = options.resources ?? [];
@@ -127,7 +143,16 @@ export function createMcpToolServer(requiredArgs, options = {}) {
127
143
  idleMs: options.idleMs ?? DEFAULT_IDLE_MS,
128
144
  onIdle: () => closeTransportForIdle?.(),
129
145
  });
130
- const withActivity = (handler) => (...args) => idleExit.trackRequest({ fn: () => handler(...args) });
146
+ const hosting = hostingForOptions(options);
147
+ const withActivity = (handler) => (...args) => {
148
+ if (!hosting)
149
+ return idleExit.trackRequest({ fn: () => handler(...args) });
150
+ const result = hosting.track({ task: async () => handler(...args), signal: args[1]?.extra?.signal });
151
+ // Active from acceptance through the response write, not just the handler: a serial
152
+ // request queued behind a slow write must not let idle exit close the transport.
153
+ void idleExit.trackRequest({ fn: () => hosting.responseWritten({ result }) });
154
+ return result;
155
+ };
131
156
  server.setRequestHandler({
132
157
  schema: ListToolsRequestSchema,
133
158
  handler: withActivity(async () => ({ tools: toolsToList({ tools: [...toolIndex.values()] }) })),
@@ -152,7 +177,7 @@ export function createMcpToolServer(requiredArgs, options = {}) {
152
177
  handler: withActivity(async ({ request }) => handleResourceRead({ uri: request.params.uri, resources: resourceIndex, ctx })),
153
178
  });
154
179
  }
155
- const transport = createTransport({}, stdioOptions(options));
180
+ const transport = createHostedTransport({ createTransport, options, hosting });
156
181
  try {
157
182
  closeTransportForIdle = () => {
158
183
  void transport.close({}).catch(() => { });
@@ -167,7 +192,7 @@ export function createMcpToolServer(requiredArgs, options = {}) {
167
192
  sdkOnMessage?.({ message });
168
193
  };
169
194
  const stdin = options.stdin ?? process.stdin;
170
- await new Promise((resolve) => {
195
+ await new Promise((resolve, reject) => {
171
196
  const sdkOnClose = transport.onclose;
172
197
  let finished = false;
173
198
  const done = () => {
@@ -175,7 +200,11 @@ export function createMcpToolServer(requiredArgs, options = {}) {
175
200
  return;
176
201
  finished = true;
177
202
  idleExit.dispose({});
178
- resolve();
203
+ if (!hosting) {
204
+ resolve();
205
+ return;
206
+ }
207
+ void hosting.dispose().then(failure => (failure ? reject(failure.error) : resolve()));
179
208
  };
180
209
  transport.onclose = () => {
181
210
  sdkOnClose?.();
@@ -186,12 +215,18 @@ export function createMcpToolServer(requiredArgs, options = {}) {
186
215
  };
187
216
  // Hold the process open until the client disconnects (stdin EOF) — `connect()` only
188
217
  // starts the transport, it doesn't wait for the stream to close.
218
+ if (hosting) {
219
+ hosting.watch({ close: closeTransportForStdin });
220
+ return;
221
+ }
189
222
  stdin.once('end', closeTransportForStdin);
190
223
  stdin.once('close', closeTransportForStdin);
191
224
  });
192
225
  }
193
226
  finally {
194
227
  idleExit.dispose({});
228
+ if (hosting)
229
+ void hosting.dispose();
195
230
  closeTransportForIdle = null;
196
231
  }
197
232
  },
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { delegatedResultMarker } from '@jini-ai/protocol';
37
37
  import { batchOutcomeFor, isReadOnlyTool } from '@jini-ai/core';
38
- import { ToolSchema } from '@modelcontextprotocol/sdk/types.js';
38
+ 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';
@@ -248,7 +248,7 @@ export function createExecuteReadonlyDelegatedToolTool(requiredArgs, options = {
248
248
  */
249
249
  export function createTypedDelegatedToolTool({ runId, tool }, options = {}) {
250
250
  const { descriptor, wireName } = tool;
251
- const inputSchema = ToolSchema.shape.inputSchema.parse(descriptor.inputSchema);
251
+ const inputSchema = parseMcpToolInputSchema({ inputSchema: descriptor.inputSchema, toolId: descriptor.id });
252
252
  const readOnly = isReadOnlyTool({ descriptor });
253
253
  const gateway = readOnly
254
254
  ? createExecuteReadonlyDelegatedToolTool({ runId }, options)
@@ -96,7 +96,7 @@ export const searchToolsTool = {
96
96
  /** `describe_tool` -> `GET /api/tools/:id` (`packages/http/src/tool-catalog.ts`'s `toolCatalogDescribeRoute`). */
97
97
  export const describeToolTool = {
98
98
  name: 'describe_tool',
99
- description: 'Get the full descriptor for one tool id found via search_tools — description, input schema, readOnly and sideEffects. When dynamic publication is enabled, this also exposes that operation as a concrete tool in tools/list; call it with its own arguments. Legacy fallback: use execute_readonly_delegated_tool only when readOnly is true. Supply id (aliases: toolId or name). Paid for only on the candidates that survive search, per the tool catalog\'s staged-discovery design.',
99
+ description: 'Get the full descriptor for one tool id found via search_tools — description, input schema, readOnly and sideEffects. Schema-less descriptors are information only. When dynamic publication is enabled, descriptors with an input schema also expose that operation as a concrete tool in tools/list; call it with its own arguments. Legacy fallback: use execute_readonly_delegated_tool only when readOnly is true. Supply id (aliases: toolId or name). Paid for only on the candidates that survive search, per the tool catalog\'s staged-discovery design.',
100
100
  inputSchema: {
101
101
  type: 'object',
102
102
  properties: {
@@ -112,8 +112,12 @@ export const describeToolTool = {
112
112
  handler: async ({ args, ctx }) => {
113
113
  const id = requireString({ value: args.id, name: 'id' });
114
114
  const data = await getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/tools/${encodeURIComponent(id)}` }, daemonCallOptions({ ctx }));
115
- if (ctx.publishTool)
116
- await ctx.publishTool({ descriptor: describedDescriptor({ id, data }) });
115
+ if (ctx.publishTool) {
116
+ const descriptor = describedDescriptor({ id, data });
117
+ // A family can describe its members without owning an executable input contract.
118
+ if (descriptor.inputSchema != null)
119
+ await ctx.publishTool({ descriptor });
120
+ }
117
121
  return data;
118
122
  },
119
123
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/mcp",
3
- "version": "0.6.12",
3
+ "version": "0.6.15-rc.2",
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": {
@@ -83,12 +83,12 @@
83
83
  "registry": "https://registry.npmjs.org"
84
84
  },
85
85
  "dependencies": {
86
- "@jini-ai/core": "^0.6.12",
87
- "@jini-ai/protocol": "^0.6.12"
86
+ "@jini-ai/core": "^0.6.15-rc.2",
87
+ "@jini-ai/protocol": "^0.6.15-rc.2"
88
88
  },
89
89
  "peerDependencies": {
90
90
  "@cfworker/json-schema": "^4.1.1",
91
- "@jini-ai/platform": "^0.6.10",
91
+ "@jini-ai/platform": "^0.6.15-rc.2",
92
92
  "@modelcontextprotocol/sdk": "^1.29.0"
93
93
  },
94
94
  "peerDependenciesMeta": {
@@ -105,9 +105,9 @@
105
105
  "devDependencies": {
106
106
  "@cfworker/json-schema": "^4.1.1",
107
107
  "@modelcontextprotocol/sdk": "^1.29.0",
108
- "@jini-ai/daemon": "^0.6.12",
109
- "@jini-ai/platform": "^0.6.12",
110
- "@jini-ai/core": "^0.6.12"
108
+ "@jini-ai/core": "^0.6.15-rc.2",
109
+ "@jini-ai/platform": "^0.6.15-rc.2",
110
+ "@jini-ai/daemon": "^0.6.15-rc.2"
111
111
  },
112
112
  "scripts": {
113
113
  "build": "tsc -p tsconfig.json && chmod +x dist/bin/serve.js",