@jini-ai/mcp 0.3.3 → 0.4.0
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.
- package/CHANGELOG.md +151 -0
- package/README.md +97 -22
- package/dist/agent-install/install.d.ts +20 -4
- package/dist/agent-install/install.js +11 -11
- package/dist/bin/serve.d.ts +25 -13
- package/dist/bin/serve.js +42 -21
- package/dist/client/client.d.ts +28 -8
- package/dist/client/client.js +25 -21
- package/dist/core/config.d.ts +57 -18
- package/dist/core/config.js +76 -48
- package/dist/core/index.d.ts +1 -2
- package/dist/core/index.js +1 -2
- package/dist/core/install-info.d.ts +4 -1
- package/dist/core/install-info.js +5 -5
- package/dist/core/tokens.d.ts +39 -13
- package/dist/core/tokens.js +54 -24
- package/dist/federation/adapter.http.d.ts +67 -0
- package/dist/federation/adapter.http.js +414 -0
- package/dist/federation/approvals/call-confirmation.d.ts +23 -0
- package/dist/federation/approvals/call-confirmation.js +124 -0
- package/dist/federation/approvals/index.d.ts +4 -0
- package/dist/federation/approvals/index.js +4 -0
- package/dist/federation/approvals/ports.d.ts +107 -0
- package/dist/federation/approvals/ports.js +2 -0
- package/dist/federation/approvals/revocation.d.ts +61 -0
- package/dist/federation/approvals/revocation.js +52 -0
- package/dist/federation/bootstrap.d.ts +100 -0
- package/dist/federation/bootstrap.js +182 -0
- package/dist/federation/config.d.ts +68 -0
- package/dist/federation/config.js +40 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.js +14 -0
- package/dist/federation/mcp-protocol.d.ts +115 -0
- package/dist/federation/mcp-protocol.js +151 -0
- package/dist/federation/messages.d.ts +54 -0
- package/dist/federation/messages.js +35 -0
- package/dist/federation/ports.d.ts +287 -0
- package/dist/federation/ports.js +5 -0
- package/dist/federation/presets.d.ts +66 -0
- package/dist/federation/presets.js +30 -0
- package/dist/federation/refusal-notice.d.ts +198 -0
- package/dist/federation/refusal-notice.js +193 -0
- package/dist/federation/registrations.d.ts +171 -0
- package/dist/federation/registrations.js +181 -0
- package/dist/federation/reload.d.ts +112 -0
- package/dist/federation/reload.js +110 -0
- package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
- package/dist/federation/stdio/adapter.stdio.js +526 -0
- package/dist/federation/stdio/default-connect.d.ts +29 -0
- package/dist/federation/stdio/default-connect.js +92 -0
- package/dist/federation/stdio/index.d.ts +4 -0
- package/dist/federation/stdio/index.js +4 -0
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
- package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
- package/dist/federation/testing/adapter.memory.d.ts +177 -0
- package/dist/federation/testing/adapter.memory.js +189 -0
- package/dist/federation/testing/index.d.ts +3 -0
- package/dist/federation/testing/index.js +3 -0
- package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
- package/dist/federation/testing/tool-approvals.memory.js +38 -0
- package/dist/federation/tool-approvals.d.ts +97 -0
- package/dist/federation/tool-approvals.js +42 -0
- package/dist/federation/trust.d.ts +471 -0
- package/dist/federation/trust.js +707 -0
- package/dist/index.d.ts +175 -2
- package/dist/index.js +172 -2
- package/dist/server/daemon-client.d.ts +16 -4
- package/dist/server/daemon-client.js +41 -19
- package/dist/server/ports.d.ts +9 -0
- package/dist/server/ports.js +2 -0
- package/dist/server/resource-protocol.d.ts +14 -4
- package/dist/server/resource-protocol.js +6 -6
- package/dist/server/resources/active-resource.js +3 -3
- package/dist/server/sdk-transport.d.ts +11 -0
- package/dist/server/sdk-transport.js +31 -0
- package/dist/server/tool-protocol.d.ts +31 -9
- package/dist/server/tool-protocol.js +20 -18
- package/dist/server/tool-server.d.ts +60 -43
- package/dist/server/tool-server.js +51 -27
- package/dist/server/tools/component-catalog-tools.js +7 -7
- package/dist/server/tools/delegated-tool.d.ts +7 -4
- package/dist/server/tools/delegated-tool.js +14 -15
- package/dist/server/tools/run-tools.js +15 -15
- package/dist/server/tools/tool-catalog-tools.js +7 -7
- package/dist/tools/ask-choice/index.d.ts +123 -0
- package/dist/tools/ask-choice/index.js +98 -0
- package/dist/tools/ask-choice/pending.d.ts +11 -0
- package/dist/tools/ask-choice/pending.js +34 -0
- package/dist/tools/ask-choice/question.d.ts +16 -0
- package/dist/tools/ask-choice/question.js +60 -0
- package/dist/tools/ask-choice/schema.d.ts +91 -0
- package/dist/tools/ask-choice/schema.js +27 -0
- package/dist/tools/ask-choice/types.d.ts +106 -0
- package/dist/tools/ask-choice/types.js +2 -0
- package/package.json +43 -5
|
@@ -27,11 +27,14 @@ import { type McpToolDef } from '../tool-protocol.js';
|
|
|
27
27
|
* is unset on every registration today, so `ToolExecutor` arms no timer of its own.
|
|
28
28
|
*/
|
|
29
29
|
export declare const DEFAULT_DELEGATED_TOOL_TIMEOUT_MS: number;
|
|
30
|
-
export interface
|
|
30
|
+
export interface CreateExecuteDelegatedToolToolRequiredArgs {
|
|
31
31
|
/** The one run this MCP server process — and therefore this tool instance — is scoped to. */
|
|
32
32
|
readonly runId: string;
|
|
33
|
+
}
|
|
34
|
+
/** Optional correlation-id source and deadline override. */
|
|
35
|
+
export interface CreateExecuteDelegatedToolToolOptions {
|
|
33
36
|
/** Generates each call's `toolUseId`. @default node:crypto randomUUID */
|
|
34
|
-
readonly generateToolUseId?: () => string;
|
|
37
|
+
readonly generateToolUseId?: (required: Record<string, never>) => string;
|
|
35
38
|
/**
|
|
36
39
|
* Request deadline for one delegated tool call, in milliseconds. Set this to match the host's
|
|
37
40
|
* own exchange total-lifetime ceiling plus headroom, so the exchange — not this request — is
|
|
@@ -54,7 +57,7 @@ export interface CreateExecuteDelegatedToolToolOptions {
|
|
|
54
57
|
* registry — reads and writes alike. See {@link createExecuteReadonlyDelegatedToolTool} for what
|
|
55
58
|
* that costs under a host that gates on the annotation, and for the companion that answers it.
|
|
56
59
|
*/
|
|
57
|
-
export declare function createExecuteDelegatedToolTool(
|
|
60
|
+
export declare function createExecuteDelegatedToolTool(requiredArgs: CreateExecuteDelegatedToolToolRequiredArgs, options?: CreateExecuteDelegatedToolToolOptions): McpToolDef;
|
|
58
61
|
/**
|
|
59
62
|
* Builds the `execute_readonly_delegated_tool` companion for one specific `runId` — the same
|
|
60
63
|
* gateway, narrowed to tools whose registration declares them read-only, and annotated
|
|
@@ -83,5 +86,5 @@ export declare function createExecuteDelegatedToolTool(options: CreateExecuteDel
|
|
|
83
86
|
* Flipping the existing tool's annotation instead was rejected for the same reason: it would lie to
|
|
84
87
|
* every caller, including the ones the annotation exists to protect.
|
|
85
88
|
*/
|
|
86
|
-
export declare function createExecuteReadonlyDelegatedToolTool(
|
|
89
|
+
export declare function createExecuteReadonlyDelegatedToolTool(requiredArgs: CreateExecuteDelegatedToolToolRequiredArgs, options?: CreateExecuteDelegatedToolToolOptions): McpToolDef;
|
|
87
90
|
//# sourceMappingURL=delegated-tool.d.ts.map
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `execute_delegated_tool` and its read-only companion `execute_readonly_delegated_tool` — the
|
|
5
5
|
* MCP-callback half of gap 3's continuation transport (see
|
|
6
|
-
* `packages/daemon/
|
|
7
|
-
* package's own dated section in `
|
|
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
8
|
* Final Recommendation asked for exactly this: "inject the already-shipped MCP host into one
|
|
9
9
|
* MCP-capable CLI's launch config, prove a tool round-trip through the existing
|
|
10
10
|
* `delegated-tool-bridge.ts`." Where `../tools/run-tools.js`'s tools proxy generic
|
|
@@ -101,9 +101,8 @@ export const DEFAULT_DELEGATED_TOOL_TIMEOUT_MS = 6 * 60 * 1000;
|
|
|
101
101
|
* @returns The `McpToolDef` to host.
|
|
102
102
|
* @complexity O(1) at construction; each call is one HTTP round trip.
|
|
103
103
|
*/
|
|
104
|
-
function createDelegatedGateway(variant, options) {
|
|
105
|
-
const
|
|
106
|
-
const generateToolUseId = options.generateToolUseId ?? randomUUID;
|
|
104
|
+
function createDelegatedGateway(variant, { runId }, options) {
|
|
105
|
+
const generateToolUseId = options.generateToolUseId ?? ((_required) => randomUUID());
|
|
107
106
|
const requested = options.delegatedToolTimeoutMs;
|
|
108
107
|
const timeoutMs = typeof requested === 'number' && Number.isFinite(requested) && requested > 0
|
|
109
108
|
? requested
|
|
@@ -144,17 +143,17 @@ function createDelegatedGateway(variant, options) {
|
|
|
144
143
|
openWorldHint: false,
|
|
145
144
|
title: variant.title,
|
|
146
145
|
},
|
|
147
|
-
handler: async (args, ctx) => {
|
|
148
|
-
requireString(args.toolId, 'toolId');
|
|
146
|
+
handler: async ({ args, ctx }) => {
|
|
147
|
+
const toolId = requireString({ value: args.toolId, name: 'toolId' });
|
|
149
148
|
const body = {
|
|
150
149
|
runId,
|
|
151
|
-
toolUseId: generateToolUseId(),
|
|
152
|
-
toolId:
|
|
150
|
+
toolUseId: generateToolUseId({}),
|
|
151
|
+
toolId: toolId,
|
|
153
152
|
input: args.input,
|
|
154
153
|
...(variant.requireReadOnly === undefined ? {} : { requireReadOnly: variant.requireReadOnly }),
|
|
155
154
|
};
|
|
156
|
-
const data = await postDaemonJson(ctx.baseUrl, '/api/delegated-tool-calls', body, {
|
|
157
|
-
...daemonCallOptions(ctx),
|
|
155
|
+
const data = await postDaemonJson({ baseUrl: ctx.baseUrl, route: '/api/delegated-tool-calls', body: body }, {
|
|
156
|
+
...daemonCallOptions({ ctx }),
|
|
158
157
|
timeoutMs,
|
|
159
158
|
});
|
|
160
159
|
return unwrapMcpContentEnvelope(data.result);
|
|
@@ -171,14 +170,14 @@ function createDelegatedGateway(variant, options) {
|
|
|
171
170
|
* registry — reads and writes alike. See {@link createExecuteReadonlyDelegatedToolTool} for what
|
|
172
171
|
* that costs under a host that gates on the annotation, and for the companion that answers it.
|
|
173
172
|
*/
|
|
174
|
-
export function createExecuteDelegatedToolTool(options) {
|
|
173
|
+
export function createExecuteDelegatedToolTool(requiredArgs, options = {}) {
|
|
175
174
|
return createDelegatedGateway({
|
|
176
175
|
name: 'execute_delegated_tool',
|
|
177
176
|
title: 'Execute a Jini-registered tool',
|
|
178
177
|
description: 'Execute a Jini-registered tool (never an agent-vendor-specific tool name) against the current run, routed through the daemon\'s ToolExecutor deny-by-default gate — the same authorization/confirmation/audit path every other tool-execution mechanism in this host uses. On a completed outcome, returns {result} normally. Any other outcome surfaces as an HTTP error the caller must catch, not a status field in a 200 body: denied/confirmation-denied raise a 403 TOOL_OPERATION_DENIED; timed-out/cancelled/failed raise a 500 INTERNAL_ERROR.',
|
|
179
178
|
readOnlyHint: false,
|
|
180
179
|
idempotentHint: false,
|
|
181
|
-
}, options);
|
|
180
|
+
}, requiredArgs, options);
|
|
182
181
|
}
|
|
183
182
|
/**
|
|
184
183
|
* Builds the `execute_readonly_delegated_tool` companion for one specific `runId` — the same
|
|
@@ -208,7 +207,7 @@ export function createExecuteDelegatedToolTool(options) {
|
|
|
208
207
|
* Flipping the existing tool's annotation instead was rejected for the same reason: it would lie to
|
|
209
208
|
* every caller, including the ones the annotation exists to protect.
|
|
210
209
|
*/
|
|
211
|
-
export function createExecuteReadonlyDelegatedToolTool(options) {
|
|
210
|
+
export function createExecuteReadonlyDelegatedToolTool(requiredArgs, options = {}) {
|
|
212
211
|
return createDelegatedGateway({
|
|
213
212
|
name: 'execute_readonly_delegated_tool',
|
|
214
213
|
title: 'Execute a read-only Jini-registered tool',
|
|
@@ -219,6 +218,6 @@ export function createExecuteReadonlyDelegatedToolTool(options) {
|
|
|
219
218
|
// third convention for the same claim.
|
|
220
219
|
idempotentHint: true,
|
|
221
220
|
requireReadOnly: true,
|
|
222
|
-
}, options);
|
|
221
|
+
}, requiredArgs, options);
|
|
223
222
|
}
|
|
224
223
|
//# sourceMappingURL=delegated-tool.js.map
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `../daemon-client.js` — no separate authorization mechanism, no caching,
|
|
11
11
|
* no state: whatever `@jini-ai/http-kit`'s same-origin guard / bearer-auth
|
|
12
12
|
* middleware already enforces on the target route is the only gate a call
|
|
13
|
-
* here passes through (see `
|
|
13
|
+
* here passes through (see `archived provenance ledger`'s 2026-07-21 addition for the
|
|
14
14
|
* full origin-mapping and what was deliberately not ported).
|
|
15
15
|
*/
|
|
16
16
|
import { getDaemonJson, postDaemonJson } from '../daemon-client.js';
|
|
@@ -53,22 +53,22 @@ export const startRunTool = {
|
|
|
53
53
|
additionalProperties: false,
|
|
54
54
|
},
|
|
55
55
|
annotations: { ...WRITE_ANNOTATIONS, title: 'Start a run' },
|
|
56
|
-
handler: async (args, ctx) => {
|
|
57
|
-
requireString(args.contextRef, 'contextRef');
|
|
58
|
-
const body = { contextRef:
|
|
56
|
+
handler: async ({ args, ctx }) => {
|
|
57
|
+
const contextRef = requireString({ value: args.contextRef, name: 'contextRef' });
|
|
58
|
+
const body = { contextRef: contextRef };
|
|
59
59
|
const agentId = optionalNonEmptyString(args.agentId);
|
|
60
60
|
if (agentId !== undefined)
|
|
61
61
|
body.agentId = agentId;
|
|
62
62
|
const idempotencyKey = optionalNonEmptyString(args.idempotencyKey);
|
|
63
63
|
if (idempotencyKey !== undefined)
|
|
64
64
|
body.idempotencyKey = idempotencyKey;
|
|
65
|
-
return postDaemonJson(ctx.baseUrl, '/api/runs', body, daemonCallOptions(ctx));
|
|
65
|
+
return postDaemonJson({ baseUrl: ctx.baseUrl, route: '/api/runs', body: body }, daemonCallOptions({ ctx }));
|
|
66
66
|
},
|
|
67
67
|
};
|
|
68
68
|
/** `get_run` -> `GET /api/runs/:runId` (`packages/http/src/runs.ts`'s `runStatusRoute`). */
|
|
69
69
|
export const getRunTool = {
|
|
70
70
|
name: 'get_run',
|
|
71
|
-
description: 'Poll a run started by start_run. Returns {run} with the run\'s current state (queued|running|succeeded|failed|
|
|
71
|
+
description: 'Poll a run started by start_run. Returns {run} with the run\'s current state (queued|starting|running|succeeded|failed|cancelled, per @jini-ai/protocol\'s RunState).',
|
|
72
72
|
inputSchema: {
|
|
73
73
|
type: 'object',
|
|
74
74
|
properties: {
|
|
@@ -78,9 +78,9 @@ export const getRunTool = {
|
|
|
78
78
|
additionalProperties: false,
|
|
79
79
|
},
|
|
80
80
|
annotations: { ...READ_ANNOTATIONS, title: 'Check a run' },
|
|
81
|
-
handler: async (args, ctx) => {
|
|
82
|
-
requireString(args.runId, 'runId');
|
|
83
|
-
return getDaemonJson(ctx.baseUrl, `/api/runs/${encodeURIComponent(
|
|
81
|
+
handler: async ({ args, ctx }) => {
|
|
82
|
+
const runId = requireString({ value: args.runId, name: 'runId' });
|
|
83
|
+
return getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/runs/${encodeURIComponent(runId)}` }, daemonCallOptions({ ctx }));
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
86
|
/** `cancel_run` -> `POST /api/runs/:runId/cancel` (`packages/http/src/runs.ts`'s `runCancelRoute`). */
|
|
@@ -97,13 +97,13 @@ export const cancelRunTool = {
|
|
|
97
97
|
additionalProperties: false,
|
|
98
98
|
},
|
|
99
99
|
annotations: { ...WRITE_ANNOTATIONS, title: 'Cancel a run' },
|
|
100
|
-
handler: async (args, ctx) => {
|
|
101
|
-
requireString(args.runId, 'runId');
|
|
100
|
+
handler: async ({ args, ctx }) => {
|
|
101
|
+
const runId = requireString({ value: args.runId, name: 'runId' });
|
|
102
102
|
const body = {};
|
|
103
103
|
const reason = optionalNonEmptyString(args.reason);
|
|
104
104
|
if (reason !== undefined)
|
|
105
105
|
body.reason = reason;
|
|
106
|
-
return postDaemonJson(ctx.baseUrl, `/api/runs/${encodeURIComponent(
|
|
106
|
+
return postDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/runs/${encodeURIComponent(runId)}/cancel`, body: body }, daemonCallOptions({ ctx }));
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
/**
|
|
@@ -115,7 +115,7 @@ const ACTIVE_CONTEXT_NOT_SUPPORTED = 'get_active_context is not supported by thi
|
|
|
115
115
|
/** Reads `/api/active`, turning a 404 into {@link ACTIVE_CONTEXT_NOT_SUPPORTED}; every other failure is rethrown unchanged. */
|
|
116
116
|
async function fetchActiveContext(ctx) {
|
|
117
117
|
try {
|
|
118
|
-
return await getDaemonJson(ctx.baseUrl, '/api/active', daemonCallOptions(ctx));
|
|
118
|
+
return await getDaemonJson({ baseUrl: ctx.baseUrl, route: '/api/active' }, daemonCallOptions({ ctx }));
|
|
119
119
|
}
|
|
120
120
|
catch (err) {
|
|
121
121
|
if (err?.status === 404)
|
|
@@ -129,7 +129,7 @@ export const getActiveContextTool = {
|
|
|
129
129
|
description: 'The resource (resourceRef) plus optional detail the caller last recorded as its current focus via POST /api/active — a generic, product-neutral pointer (this kernel has no "project" or "file" noun; a host maps resourceRef to whatever domain object it manages). Returns {active:false} once the pointer has aged past its TTL (5 minutes, see ACTIVE_CONTEXT_TTL_MS in packages/http/src/active-context.ts) or was never set. On a host that does not support active context at all, the call fails with an explicit "not supported by this host" error instead — that means nothing is tracked here, not that the pointer expired.',
|
|
130
130
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
131
131
|
annotations: { ...READ_ANNOTATIONS, title: 'What is the caller focused on?' },
|
|
132
|
-
handler: async (
|
|
132
|
+
handler: async ({ ctx }) => {
|
|
133
133
|
const data = await fetchActiveContext(ctx);
|
|
134
134
|
if (data.active !== true) {
|
|
135
135
|
return {
|
|
@@ -146,7 +146,7 @@ export const listAgentsTool = {
|
|
|
146
146
|
description: 'List every agent def registered with this host\'s @jini-ai/agent-runtime — {id, name} pairs suitable for start_run\'s optional agentId argument. Static registration data only: this does not probe which agent binaries are actually installed on the host machine.',
|
|
147
147
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
148
148
|
annotations: { ...READ_ANNOTATIONS, title: 'List registered agents' },
|
|
149
|
-
handler: async (
|
|
149
|
+
handler: async ({ ctx }) => getDaemonJson({ baseUrl: ctx.baseUrl, route: '/api/agents' }, daemonCallOptions({ ctx })),
|
|
150
150
|
};
|
|
151
151
|
/** The full set of kernel-run tool defs this package ships, ready to pass as `createMcpToolServer`'s `tools` option. */
|
|
152
152
|
export const RUN_TOOLS = [
|
|
@@ -68,12 +68,12 @@ export const searchToolsTool = {
|
|
|
68
68
|
additionalProperties: false,
|
|
69
69
|
},
|
|
70
70
|
annotations: { ...READ_ANNOTATIONS, title: 'Search the tool catalog' },
|
|
71
|
-
handler: async (args, ctx) => {
|
|
72
|
-
requireString(args.query, 'query');
|
|
73
|
-
const params = new URLSearchParams({ q:
|
|
71
|
+
handler: async ({ args, ctx }) => {
|
|
72
|
+
const query = requireString({ value: args.query, name: 'query' });
|
|
73
|
+
const params = new URLSearchParams({ q: query });
|
|
74
74
|
if (typeof args.limit === 'number')
|
|
75
75
|
params.set('limit', String(args.limit));
|
|
76
|
-
const data = await getDaemonJson(ctx.baseUrl, `/api/tools/search?${params.toString()}
|
|
76
|
+
const data = await getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/tools/search?${params.toString()}` }, daemonCallOptions({ ctx }));
|
|
77
77
|
return data.hits;
|
|
78
78
|
},
|
|
79
79
|
};
|
|
@@ -90,9 +90,9 @@ export const describeToolTool = {
|
|
|
90
90
|
additionalProperties: false,
|
|
91
91
|
},
|
|
92
92
|
annotations: { ...READ_ANNOTATIONS, title: 'Describe a tool' },
|
|
93
|
-
handler: async (args, ctx) => {
|
|
94
|
-
requireString(args.id, 'id');
|
|
95
|
-
return getDaemonJson(ctx.baseUrl, `/api/tools/${encodeURIComponent(
|
|
93
|
+
handler: async ({ args, ctx }) => {
|
|
94
|
+
const id = requireString({ value: args.id, name: 'id' });
|
|
95
|
+
return getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/tools/${encodeURIComponent(id)}` }, daemonCallOptions({ ctx }));
|
|
96
96
|
},
|
|
97
97
|
};
|
|
98
98
|
/** Both tool-catalog discovery tools, ready to pass as `createMcpToolServer`'s `tools` option. */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { AskChoiceCall, AskChoiceExchangeStore, AskChoiceMessages, AskChoicePendingStore, AskChoicePolicy, AskChoicePresentation } from './types.js';
|
|
2
|
+
export type * from './types.js';
|
|
3
|
+
export { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
|
|
4
|
+
export { createAskChoiceAnswerTicketStore } from './pending.js';
|
|
5
|
+
/** Existing callback wire keys; the tool's identity is supplied by the host. */
|
|
6
|
+
export declare const ASK_CHOICE_ANSWER_TICKET_PARAM = "__askChoiceAnswerTicket";
|
|
7
|
+
/** Creates a choice tool without a CMS, renderer, permission system, clock, or global store.
|
|
8
|
+
* The host composes this descriptor/handler into its registry and mounts callbacks against the
|
|
9
|
+
* same exchange and pending stores. Policy and presentation are required, with no product defaults.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createAskChoiceTool({ toolId, description, permission, policy, presentation, pendingQuestions, surfaceExchanges, messages }: {
|
|
12
|
+
toolId: string;
|
|
13
|
+
description: string;
|
|
14
|
+
permission: string;
|
|
15
|
+
policy: AskChoicePolicy;
|
|
16
|
+
presentation: AskChoicePresentation;
|
|
17
|
+
pendingQuestions: AskChoicePendingStore;
|
|
18
|
+
surfaceExchanges: AskChoiceExchangeStore;
|
|
19
|
+
messages: AskChoiceMessages;
|
|
20
|
+
}): {
|
|
21
|
+
descriptor: {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
sideEffects: "none";
|
|
26
|
+
authorization: {
|
|
27
|
+
permission: string;
|
|
28
|
+
};
|
|
29
|
+
inputSchema: {
|
|
30
|
+
readonly type: "object";
|
|
31
|
+
readonly additionalProperties: false;
|
|
32
|
+
readonly required: readonly ["title"];
|
|
33
|
+
readonly anyOf: readonly [{
|
|
34
|
+
readonly required: readonly ["singleSelect"];
|
|
35
|
+
}, {
|
|
36
|
+
readonly required: readonly ["multiSelect"];
|
|
37
|
+
}];
|
|
38
|
+
readonly properties: {
|
|
39
|
+
readonly title: {
|
|
40
|
+
readonly type: "string";
|
|
41
|
+
};
|
|
42
|
+
readonly description: {
|
|
43
|
+
readonly type: "string";
|
|
44
|
+
};
|
|
45
|
+
readonly submitLabel: {
|
|
46
|
+
readonly type: "string";
|
|
47
|
+
};
|
|
48
|
+
readonly singleSelect: {
|
|
49
|
+
readonly type: "object";
|
|
50
|
+
readonly additionalProperties: false;
|
|
51
|
+
readonly required: readonly ["label", "options"];
|
|
52
|
+
readonly properties: {
|
|
53
|
+
readonly label: {
|
|
54
|
+
readonly type: "string";
|
|
55
|
+
};
|
|
56
|
+
readonly options: {
|
|
57
|
+
readonly type: "array";
|
|
58
|
+
readonly minItems: 1;
|
|
59
|
+
readonly items: {
|
|
60
|
+
readonly type: "object";
|
|
61
|
+
readonly additionalProperties: false;
|
|
62
|
+
readonly required: readonly ["value", "label"];
|
|
63
|
+
readonly properties: {
|
|
64
|
+
readonly value: {
|
|
65
|
+
readonly type: "string";
|
|
66
|
+
};
|
|
67
|
+
readonly label: {
|
|
68
|
+
readonly type: "string";
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
readonly multiSelect: {
|
|
76
|
+
readonly properties: {
|
|
77
|
+
readonly hint: {
|
|
78
|
+
readonly type: "string";
|
|
79
|
+
};
|
|
80
|
+
readonly label: {
|
|
81
|
+
readonly type: "string";
|
|
82
|
+
};
|
|
83
|
+
readonly options: {
|
|
84
|
+
readonly type: "array";
|
|
85
|
+
readonly minItems: 1;
|
|
86
|
+
readonly items: {
|
|
87
|
+
readonly type: "object";
|
|
88
|
+
readonly additionalProperties: false;
|
|
89
|
+
readonly required: readonly ["value", "label"];
|
|
90
|
+
readonly properties: {
|
|
91
|
+
readonly value: {
|
|
92
|
+
readonly type: "string";
|
|
93
|
+
};
|
|
94
|
+
readonly label: {
|
|
95
|
+
readonly type: "string";
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
readonly type: "object";
|
|
102
|
+
readonly additionalProperties: false;
|
|
103
|
+
readonly required: readonly ["label", "options"];
|
|
104
|
+
};
|
|
105
|
+
readonly choice: {
|
|
106
|
+
readonly type: "string";
|
|
107
|
+
readonly description: "Set by the rendered form; never by the model.";
|
|
108
|
+
};
|
|
109
|
+
readonly selections: {
|
|
110
|
+
readonly type: "array";
|
|
111
|
+
readonly items: {
|
|
112
|
+
readonly type: "string";
|
|
113
|
+
};
|
|
114
|
+
readonly description: "Set by the rendered form; never by the model.";
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
handler({ ctx }: {
|
|
120
|
+
ctx: AskChoiceCall;
|
|
121
|
+
}): Promise<Record<string, unknown>>;
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { parseQuestion, QuestionShapeError } from './question.js';
|
|
2
|
+
import { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
|
|
3
|
+
export { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
|
|
4
|
+
export { createAskChoiceAnswerTicketStore } from './pending.js';
|
|
5
|
+
/** Existing callback wire keys; the tool's identity is supplied by the host. */
|
|
6
|
+
export const ASK_CHOICE_ANSWER_TICKET_PARAM = '__askChoiceAnswerTicket';
|
|
7
|
+
const SURFACE_EXCHANGE_ID_PARAM = '__exchangeId';
|
|
8
|
+
const SURFACE_DISMISSED_PARAM = '__dismissed';
|
|
9
|
+
const SURFACE_TYPED_ANSWER_PARAM = '__typedAnswer';
|
|
10
|
+
function describeAnswer(params, messages) {
|
|
11
|
+
const choice = typeof params['choice'] === 'string' ? params['choice'] : undefined;
|
|
12
|
+
const selections = Array.isArray(params['selections']) ? params['selections'] : undefined;
|
|
13
|
+
if (choice === undefined && selections === undefined)
|
|
14
|
+
return { submitted: false, reason: 'no-answer', note: messages.noAnswer };
|
|
15
|
+
return { submitted: true, ...(choice === undefined ? {} : { choice }), ...(selections === undefined ? {} : { selections }), note: messages.submitted };
|
|
16
|
+
}
|
|
17
|
+
async function waitForAnswer({ exchange, ui, signal, messages }) {
|
|
18
|
+
const close = () => exchange.close({});
|
|
19
|
+
signal.addEventListener('abort', close, { once: true });
|
|
20
|
+
try {
|
|
21
|
+
if (signal.aborted) {
|
|
22
|
+
close();
|
|
23
|
+
return { submitted: false, reason: 'abandoned', note: messages.abandoned };
|
|
24
|
+
}
|
|
25
|
+
await exchange.send({ emission: { channel: 'mcp-ui', payload: { resource: ui } } });
|
|
26
|
+
const answer = await exchange.receive({});
|
|
27
|
+
if (answer.status !== 'received')
|
|
28
|
+
return { submitted: false, reason: answer.status, note: messages[answer.status] };
|
|
29
|
+
if (answer.params[SURFACE_DISMISSED_PARAM] === true)
|
|
30
|
+
return { submitted: false, reason: 'cancelled', note: messages.cancelled };
|
|
31
|
+
const typed = answer.params[SURFACE_TYPED_ANSWER_PARAM];
|
|
32
|
+
if (typeof typed === 'string' && typed.trim())
|
|
33
|
+
return { submitted: true, freeText: typed.trim(), note: messages.typed };
|
|
34
|
+
return describeAnswer(answer.params, messages);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
signal.removeEventListener('abort', close);
|
|
38
|
+
close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Creates a choice tool without a CMS, renderer, permission system, clock, or global store.
|
|
42
|
+
* The host composes this descriptor/handler into its registry and mounts callbacks against the
|
|
43
|
+
* same exchange and pending stores. Policy and presentation are required, with no product defaults.
|
|
44
|
+
*/
|
|
45
|
+
export function createAskChoiceTool({ toolId, description, permission, policy, presentation, pendingQuestions, surfaceExchanges, messages }) {
|
|
46
|
+
const descriptor = {
|
|
47
|
+
id: toolId, name: toolId, description, sideEffects: 'none',
|
|
48
|
+
authorization: { permission }, inputSchema: ASK_CHOICE_INPUT_SCHEMA,
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
descriptor,
|
|
52
|
+
async handler({ ctx }) {
|
|
53
|
+
await policy.authorize({ ctx, toolId });
|
|
54
|
+
const input = typeof ctx.input === 'object' && ctx.input !== null ? ctx.input : {};
|
|
55
|
+
const isAnswer = typeof input['title'] !== 'string' && (typeof input['choice'] === 'string' || Array.isArray(input['selections']));
|
|
56
|
+
if (isAnswer) {
|
|
57
|
+
const rawTicket = input[ASK_CHOICE_ANSWER_TICKET_PARAM];
|
|
58
|
+
const ticket = typeof rawTicket === 'string' ? rawTicket : undefined;
|
|
59
|
+
if (!pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: input })) {
|
|
60
|
+
throw policy.inputError({ message: messages.forgedAnswer });
|
|
61
|
+
}
|
|
62
|
+
return describeAnswer(input, messages);
|
|
63
|
+
}
|
|
64
|
+
let question;
|
|
65
|
+
try {
|
|
66
|
+
question = parseQuestion({ input, toolId });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!(error instanceof QuestionShapeError))
|
|
70
|
+
throw error;
|
|
71
|
+
throw policy.shapeError({ message: error.message, toolId, inputSchema: ASK_CHOICE_INPUT_SCHEMA });
|
|
72
|
+
}
|
|
73
|
+
if (ctx.signal.aborted)
|
|
74
|
+
return { submitted: false, reason: 'abandoned', note: messages.abandoned };
|
|
75
|
+
const exchange = ctx.emitSurface ? surfaceExchanges.open({ toolId, principalId: ctx.principalId, emit: ctx.emitSurface }) : undefined;
|
|
76
|
+
const ticket = exchange ? undefined : pendingQuestions.mint({ principalId: ctx.principalId, question });
|
|
77
|
+
const baseParams = exchange ? { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id } : { [ASK_CHOICE_ANSWER_TICKET_PARAM]: ticket };
|
|
78
|
+
let ui;
|
|
79
|
+
try {
|
|
80
|
+
ui = presentation.render({
|
|
81
|
+
toolName: toolId, principalId: ctx.principalId, question, baseParams,
|
|
82
|
+
}, { ...(exchange ? { cancelParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id, [SURFACE_DISMISSED_PARAM]: true } } : {}),
|
|
83
|
+
});
|
|
84
|
+
if (!exchange)
|
|
85
|
+
return presentation.buildResult({ modelText: messages.pending, ui });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
exchange?.close({});
|
|
89
|
+
// A form that was not delivered must not leave a redeemable fallback ticket behind.
|
|
90
|
+
if (ticket !== undefined)
|
|
91
|
+
pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: {} });
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
return waitForAnswer({ exchange, ui, signal: ctx.signal, messages });
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AskChoicePendingStore } from './types.js';
|
|
2
|
+
/** Optional in-memory adapter. Use one instance for all callbacks belonging to the same host.
|
|
3
|
+
* IDs must be unpredictable and unique; the caller owns their generation and the ticket lifetime.
|
|
4
|
+
* Expired tickets are swept on mint; redeem consumes even a mismatched or expired ticket.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createAskChoiceAnswerTicketStore({ now, newTicketId, ttlMs }: {
|
|
7
|
+
now: (args: Record<string, never>) => number;
|
|
8
|
+
newTicketId: (args: Record<string, never>) => string;
|
|
9
|
+
ttlMs: number;
|
|
10
|
+
}): AskChoicePendingStore;
|
|
11
|
+
//# sourceMappingURL=pending.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { matchesOptions } from './question.js';
|
|
2
|
+
/** Optional in-memory adapter. Use one instance for all callbacks belonging to the same host.
|
|
3
|
+
* IDs must be unpredictable and unique; the caller owns their generation and the ticket lifetime.
|
|
4
|
+
* Expired tickets are swept on mint; redeem consumes even a mismatched or expired ticket.
|
|
5
|
+
*/
|
|
6
|
+
export function createAskChoiceAnswerTicketStore({ now, newTicketId, ttlMs }) {
|
|
7
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0)
|
|
8
|
+
throw new Error('ttlMs must be a positive finite number');
|
|
9
|
+
const pending = new Map();
|
|
10
|
+
return {
|
|
11
|
+
mint({ principalId, question }) {
|
|
12
|
+
const nowMs = now({});
|
|
13
|
+
for (const [ticket, entry] of pending)
|
|
14
|
+
if (entry.expiresAtMs <= nowMs)
|
|
15
|
+
pending.delete(ticket);
|
|
16
|
+
const ticket = newTicketId({});
|
|
17
|
+
if (!ticket || pending.has(ticket))
|
|
18
|
+
throw new Error('Ticket ID must be non-empty and unique');
|
|
19
|
+
// Snapshot the displayed options so caller mutation cannot change what this ticket authorizes.
|
|
20
|
+
pending.set(ticket, { principalId, expiresAtMs: nowMs + ttlMs, question: structuredClone(question) });
|
|
21
|
+
return ticket;
|
|
22
|
+
},
|
|
23
|
+
redeem({ ticket, principalId, params }) {
|
|
24
|
+
if (ticket === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const entry = pending.get(ticket);
|
|
27
|
+
if (!entry)
|
|
28
|
+
return false;
|
|
29
|
+
pending.delete(ticket);
|
|
30
|
+
return entry.expiresAtMs > now({}) && entry.principalId === principalId && matchesOptions({ question: entry.question, params });
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=pending.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AskChoiceQuestion } from './types.js';
|
|
2
|
+
/** Internal marker: only malformed question content gets schema-retry decoration. */
|
|
3
|
+
export declare class QuestionShapeError extends Error {
|
|
4
|
+
constructor({ message }: {
|
|
5
|
+
message: string;
|
|
6
|
+
}, options?: ErrorOptions);
|
|
7
|
+
}
|
|
8
|
+
export declare function parseQuestion({ input, toolId }: {
|
|
9
|
+
input: Record<string, unknown>;
|
|
10
|
+
toolId: string;
|
|
11
|
+
}): AskChoiceQuestion;
|
|
12
|
+
export declare function matchesOptions({ question, params }: {
|
|
13
|
+
question: AskChoiceQuestion;
|
|
14
|
+
params: Record<string, unknown>;
|
|
15
|
+
}): boolean;
|
|
16
|
+
//# sourceMappingURL=question.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Internal marker: only malformed question content gets schema-retry decoration. */
|
|
2
|
+
export class QuestionShapeError extends Error {
|
|
3
|
+
constructor({ message }, options = {}) {
|
|
4
|
+
super(message, options);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
function readSelect(raw, field, toolId) {
|
|
8
|
+
if (raw === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
11
|
+
throw new QuestionShapeError({ message: `${toolId}: '${field}' must be an object.` });
|
|
12
|
+
}
|
|
13
|
+
const spec = raw;
|
|
14
|
+
if (typeof spec['label'] !== 'string' || !spec['label'] || !Array.isArray(spec['options']) || !spec['options'].length) {
|
|
15
|
+
throw new QuestionShapeError({ message: `${toolId}: '${field}' requires a non-empty 'label' and at least one option.` });
|
|
16
|
+
}
|
|
17
|
+
const options = spec['options'].map((entry) => {
|
|
18
|
+
const option = typeof entry === 'object' && entry !== null ? entry : {};
|
|
19
|
+
const value = option['value'];
|
|
20
|
+
const label = option['label'];
|
|
21
|
+
if (typeof value !== 'string' || !value || typeof label !== 'string' || !label) {
|
|
22
|
+
throw new QuestionShapeError({ message: `${toolId}: every '${field}' option requires a string 'value' and 'label'.` });
|
|
23
|
+
}
|
|
24
|
+
return { value, label };
|
|
25
|
+
});
|
|
26
|
+
return { label: spec['label'], options, ...(typeof spec['hint'] === 'string' ? { hint: spec['hint'] } : {}) };
|
|
27
|
+
}
|
|
28
|
+
export function parseQuestion({ input, toolId }) {
|
|
29
|
+
const title = input['title'];
|
|
30
|
+
if (typeof title !== 'string' || !title)
|
|
31
|
+
throw new QuestionShapeError({ message: `${toolId}: 'title' is required.` });
|
|
32
|
+
const singleSelect = readSelect(input['singleSelect'], 'singleSelect', toolId);
|
|
33
|
+
const multiSelect = readSelect(input['multiSelect'], 'multiSelect', toolId);
|
|
34
|
+
if (!singleSelect && !multiSelect) {
|
|
35
|
+
throw new QuestionShapeError({ message: `${toolId}: at least one of 'singleSelect' or 'multiSelect' is required.` });
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
title,
|
|
39
|
+
...(typeof input['description'] === 'string' ? { description: input['description'] } : {}),
|
|
40
|
+
...(typeof input['submitLabel'] === 'string' ? { submitLabel: input['submitLabel'] } : {}),
|
|
41
|
+
...(singleSelect ? { singleSelect } : {}), ...(multiSelect ? { multiSelect } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function matchesOptions({ question, params }) {
|
|
45
|
+
const choice = params['choice'];
|
|
46
|
+
if (question.singleSelect) {
|
|
47
|
+
if (!question.singleSelect.options.some(option => option.value === choice))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
else if (choice !== undefined)
|
|
51
|
+
return false;
|
|
52
|
+
const selections = params['selections'];
|
|
53
|
+
if (selections === undefined)
|
|
54
|
+
return true;
|
|
55
|
+
if (!question.multiSelect || !Array.isArray(selections))
|
|
56
|
+
return false;
|
|
57
|
+
const offered = new Set(question.multiSelect.options.map(option => option.value));
|
|
58
|
+
return selections.every(value => typeof value === 'string' && offered.has(value));
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=question.js.map
|