@canonmsg/agent-tools 0.2.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.
@@ -0,0 +1,2 @@
1
+ export { CANON_TOOL_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, normalizeVerbToolArgs, stampSendToTurnComplete, type ExecuteVerbToolOptions, type VerbExecutionContext, type VerbProjectionOptions, type VerbToolDefinition, type VerbToolResult, } from './verb-tools.js';
2
+ export { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer, } from './verb-mcp.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { CANON_TOOL_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, normalizeVerbToolArgs, stampSendToTurnComplete, } from './verb-tools.js';
2
+ export { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer, } from './verb-mcp.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * In-process MCP server exposing the canonical Canon verbs — used by
3
+ * canon-claude host sessions, and reusable by any Node runtime that can
4
+ * mount an MCP server instance.
5
+ *
6
+ * The Agent SDK accepts `{ type: 'sdk', name, instance }` in
7
+ * options.mcpServers, where instance is a real @modelcontextprotocol/sdk
8
+ * Server. That lets this mount reuse the exact JSON-Schema tool projections
9
+ * from verb-tools.ts (the SDK's own `tool()` helper wants Zod shapes — not
10
+ * needed here). Tools surface to the model as `mcp__canon__<verb>`;
11
+ * the claude host's tool-policy special-cases that prefix so HITL/read
12
+ * verbs never recursively raise Canon approval cards.
13
+ *
14
+ * This mount is a WAITING binding: its consumers' turns already block on
15
+ * native HITL (canUseTool approvals, AskUserQuestion input cards), so
16
+ * blocking interactive verbs poll to the intent-level result the contract
17
+ * promises — submitted / allow / deny / timeout — instead of returning the
18
+ * accepted half, and the projected descriptions advertise exactly that.
19
+ * Detached approvals still return pending immediately.
20
+ */
21
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
+ import type { CanonClient } from '@canonmsg/core';
23
+ import { type VerbExecutionContext } from './verb-tools.js';
24
+ /** MCP server name — verbs appear to the model as `mcp__canon__<verb>`. */
25
+ export declare const CANON_VERB_MCP_SERVER_NAME = "canon";
26
+ export declare function createCanonVerbMcpServer(getClient: () => CanonClient | null,
27
+ /**
28
+ * Lazy per-call execution context (host sessions are conversation-scoped:
29
+ * active conversation, current turn, turn responder). Read at call time —
30
+ * the session object outlives any single turn.
31
+ */
32
+ getContext?: () => VerbExecutionContext | undefined): McpServer;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * In-process MCP server exposing the canonical Canon verbs — used by
3
+ * canon-claude host sessions, and reusable by any Node runtime that can
4
+ * mount an MCP server instance.
5
+ *
6
+ * The Agent SDK accepts `{ type: 'sdk', name, instance }` in
7
+ * options.mcpServers, where instance is a real @modelcontextprotocol/sdk
8
+ * Server. That lets this mount reuse the exact JSON-Schema tool projections
9
+ * from verb-tools.ts (the SDK's own `tool()` helper wants Zod shapes — not
10
+ * needed here). Tools surface to the model as `mcp__canon__<verb>`;
11
+ * the claude host's tool-policy special-cases that prefix so HITL/read
12
+ * verbs never recursively raise Canon approval cards.
13
+ *
14
+ * This mount is a WAITING binding: its consumers' turns already block on
15
+ * native HITL (canUseTool approvals, AskUserQuestion input cards), so
16
+ * blocking interactive verbs poll to the intent-level result the contract
17
+ * promises — submitted / allow / deny / timeout — instead of returning the
18
+ * accepted half, and the projected descriptions advertise exactly that.
19
+ * Detached approvals still return pending immediately.
20
+ */
21
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
23
+ import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from './verb-tools.js';
24
+ /** MCP server name — verbs appear to the model as `mcp__canon__<verb>`. */
25
+ export const CANON_VERB_MCP_SERVER_NAME = 'canon';
26
+ export function createCanonVerbMcpServer(getClient,
27
+ /**
28
+ * Lazy per-call execution context (host sessions are conversation-scoped:
29
+ * active conversation, current turn, turn responder). Read at call time —
30
+ * the session object outlives any single turn.
31
+ */
32
+ getContext) {
33
+ // The Agent SDK's mcpServers option expects the high-level McpServer class,
34
+ // but its registerTool API wants Zod shapes — so the contract's JSON-Schema
35
+ // projections are installed directly on the underlying protocol server
36
+ // (safe: registerTool is never called, so McpServer installs no competing
37
+ // tool handlers).
38
+ const mcpServer = new McpServer({ name: CANON_VERB_MCP_SERVER_NAME, version: '1.0.0' }, { capabilities: { tools: {} } });
39
+ const server = mcpServer.server;
40
+ // The projection must tell the model what the dispatch below actually
41
+ // does: waiting posture (waitForResult: true), and conversation-scoped
42
+ // exactly when the binding supplies a context.
43
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
44
+ tools: canonVerbToolDefinitions({
45
+ interaction: 'waiting',
46
+ conversationScoped: Boolean(getContext),
47
+ }),
48
+ }));
49
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
50
+ const client = getClient();
51
+ if (!client) {
52
+ return {
53
+ content: [{ type: 'text', text: 'Canon not connected' }],
54
+ isError: true,
55
+ };
56
+ }
57
+ const name = request.params.name;
58
+ if (!isCanonToolVerb(name)) {
59
+ return {
60
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
61
+ isError: true,
62
+ };
63
+ }
64
+ const rawArgs = request.params.arguments && typeof request.params.arguments === 'object'
65
+ ? { ...request.params.arguments }
66
+ : {};
67
+ const verbArgs = name === 'send_to' ? stampSendToTurnComplete(rawArgs) : rawArgs;
68
+ const context = getContext?.();
69
+ const result = await executeCanonVerbTool(client, name, verbArgs, {
70
+ waitForResult: true,
71
+ signal: extra?.signal,
72
+ ...(context ? { context } : {}),
73
+ });
74
+ return {
75
+ content: result.content.map((item) => ({ type: 'text', text: item.text })),
76
+ ...(result.isError ? { isError: true } : {}),
77
+ };
78
+ });
79
+ return mcpServer;
80
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Canonical verb tools — shared projections of canon.verbs.v1 for runtime
3
+ * bindings (extracted once two real projections — the Claude channel server
4
+ * and the claude host in-process mount — proved the boundary).
5
+ *
6
+ * Tool definitions are PROJECTIONS of the contract: names from
7
+ * canonVerbToolName-free plain verb names (the MCP server supplies the
8
+ * namespace), input schemas extracted self-contained from the contract
9
+ * bundle (card verbs composed with the full canon.card.v1 document schema),
10
+ * and dispatch = project intent -> wire -> POST /agent/verbs/:verb.
11
+ * Developing a capability = add a verb to the contract + endpoint; this
12
+ * module picks it up by listing it below.
13
+ *
14
+ * Interactive-verb posture is a BINDING choice, declared twice in matching
15
+ * halves — VerbProjectionOptions shapes what the model is TOLD, and
16
+ * ExecuteVerbToolOptions shapes what the dispatch DOES:
17
+ * - waiting bindings (the claude host, whose turns already block on native
18
+ * HITL) poll the consume endpoints after acceptance and return the
19
+ * intent-level result (submitted / allow / deny / timeout) the contract
20
+ * promises;
21
+ * - non-waiting bindings (the channel server — an MCP side-channel whose
22
+ * tool calls must not block on a human for minutes) return the server's
23
+ * accepted half, explicitly documented, with the answer arriving as an
24
+ * inbound notification and check_approval covering detached decisions.
25
+ */
26
+ import { type CanonClient, type CanonVerbName } from '@canonmsg/core';
27
+ export declare const CANON_TOOL_VERBS: CanonVerbName[];
28
+ export interface VerbToolDefinition {
29
+ name: string;
30
+ description: string;
31
+ inputSchema: Record<string, unknown>;
32
+ }
33
+ /**
34
+ * How a binding projects the verbs to its model. Must match the dispatch
35
+ * half (ExecuteVerbToolOptions): interaction 'waiting' pairs with
36
+ * waitForResult, conversationScoped pairs with a VerbExecutionContext.
37
+ */
38
+ export interface VerbProjectionOptions {
39
+ /** Interactive-verb posture the descriptions advertise (default 'notify'). */
40
+ interaction?: 'waiting' | 'notify';
41
+ /**
42
+ * Whether the binding injects an active conversation. Unscoped projections
43
+ * (default) mark conversationId required from the model instead.
44
+ */
45
+ conversationScoped?: boolean;
46
+ }
47
+ /** MCP tool definitions projected from the contract, shaped per binding. */
48
+ export declare function canonVerbToolDefinitions(options?: VerbProjectionOptions): VerbToolDefinition[];
49
+ export declare function isCanonToolVerb(name: string): name is CanonVerbName;
50
+ /**
51
+ * Outbound sends from a Claude surface are completed turns: stamp
52
+ * turnSemantics 'turn_complete' into send_to messageOptions.metadata unless
53
+ * the caller already set turn semantics. Shared by the channel server and
54
+ * the host-mode MCP mount.
55
+ */
56
+ export declare function stampSendToTurnComplete(args: Record<string, unknown>): Record<string, unknown>;
57
+ export interface VerbToolResult {
58
+ content: Array<{
59
+ type: 'text';
60
+ text: string;
61
+ }>;
62
+ isError?: boolean;
63
+ }
64
+ /**
65
+ * Binding-supplied execution context. The semantic schemas deliberately mark
66
+ * conversationId/inputId/deadlines optional because the BINDING owns them —
67
+ * a model saying request_input({prompt}) is valid, and this context is what
68
+ * turns it into a complete server request. Conversation-scoped bindings (the
69
+ * claude host, one session per conversation) inject all three; unscoped
70
+ * bindings (the channel server) leave conversationId to the model and the
71
+ * projected schema marks it required instead.
72
+ */
73
+ export interface VerbExecutionContext {
74
+ /** Active conversation, injected when the intent omits conversationId. */
75
+ conversationId?: string;
76
+ /** Active turn id, stamped on interaction creates for correlation. */
77
+ turnId?: string;
78
+ /** Default responder (e.g. the author of the turn being served). */
79
+ responseUserId?: string;
80
+ }
81
+ export interface ExecuteVerbToolOptions {
82
+ /**
83
+ * Poll the consume endpoint after a blocking interactive verb is accepted
84
+ * and return the intent-level result instead of the accepted half.
85
+ */
86
+ waitForResult?: boolean;
87
+ pollMs?: number;
88
+ /** Hard cap on waiting regardless of the request's expiresAt. */
89
+ maxWaitMs?: number;
90
+ signal?: AbortSignal;
91
+ /** Binding execution context for context-bound intent fields. */
92
+ context?: VerbExecutionContext;
93
+ }
94
+ /**
95
+ * Complete a verb intent's context-bound fields before projection:
96
+ * conversationId/turnId/responseUserId from the binding context, generated
97
+ * request ids and default deadlines where the server requires them
98
+ * (blocking input/approval/card requests use Canon's five-minute HITL
99
+ * default; detached approvals use the long-lived 72-hour window; card ids are
100
+ * client-generated for cancellation/reconciliation). Returns
101
+ * the completed args, or an error string when the intent is unroutable
102
+ * without a conversation.
103
+ */
104
+ export declare function normalizeVerbToolArgs(verb: CanonVerbName, args: Record<string, unknown>, context: VerbExecutionContext | undefined, now?: number): {
105
+ args: Record<string, unknown>;
106
+ } | {
107
+ error: string;
108
+ };
109
+ /**
110
+ * Execute a verb tool call: byte-limit precheck, project the intent to the
111
+ * wire (json codec), POST to /agent/verbs/:verb, unwrap the result.
112
+ */
113
+ export declare function executeCanonVerbTool(client: CanonClient, verb: CanonVerbName, args: Record<string, unknown>, options?: ExecuteVerbToolOptions): Promise<VerbToolResult>;
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Canonical verb tools — shared projections of canon.verbs.v1 for runtime
3
+ * bindings (extracted once two real projections — the Claude channel server
4
+ * and the claude host in-process mount — proved the boundary).
5
+ *
6
+ * Tool definitions are PROJECTIONS of the contract: names from
7
+ * canonVerbToolName-free plain verb names (the MCP server supplies the
8
+ * namespace), input schemas extracted self-contained from the contract
9
+ * bundle (card verbs composed with the full canon.card.v1 document schema),
10
+ * and dispatch = project intent -> wire -> POST /agent/verbs/:verb.
11
+ * Developing a capability = add a verb to the contract + endpoint; this
12
+ * module picks it up by listing it below.
13
+ *
14
+ * Interactive-verb posture is a BINDING choice, declared twice in matching
15
+ * halves — VerbProjectionOptions shapes what the model is TOLD, and
16
+ * ExecuteVerbToolOptions shapes what the dispatch DOES:
17
+ * - waiting bindings (the claude host, whose turns already block on native
18
+ * HITL) poll the consume endpoints after acceptance and return the
19
+ * intent-level result (submitted / allow / deny / timeout) the contract
20
+ * promises;
21
+ * - non-waiting bindings (the channel server — an MCP side-channel whose
22
+ * tool calls must not block on a human for minutes) return the server's
23
+ * accepted half, explicitly documented, with the answer arriving as an
24
+ * inbound notification and check_approval covering detached decisions.
25
+ */
26
+ import { randomUUID } from 'node:crypto';
27
+ import { findVerbByteLimitViolations, getVerbInputSchema, projectVerbIntentToWire, VERB_LIMITS, } from '@canonmsg/core';
28
+ import { RUNTIME_CARD_JSON_SCHEMA_V1 } from '@canonmsg/rich-cards';
29
+ /** Verbs exposed to models, with binding-level descriptions. */
30
+ const CANON_VERB_TOOL_DESCRIPTIONS = {
31
+ send_to: 'Send a Canon message to another conversation or user. Supports a private '
32
+ + 'selfContext note (with sourceConversationId) so you recognize your own '
33
+ + 'cross-conversation transfer later. Admission-aware for user targets: '
34
+ + 'may return requested/pending (contact request) instead of messaged.',
35
+ request_input: 'Ask a human in a Canon conversation a structured question (input card).',
36
+ request_approval: 'Ask a human to allow/deny an action via a Canon approval card. Use '
37
+ + "mode 'detached' and poll with check_approval for long-lived approvals.",
38
+ check_approval: "Fetch the decision of a detached approval. 'unknown' is NOT a denial — "
39
+ + 'only resolved carries a decision.',
40
+ send_card: 'Display a canon.card.v1 card (no actions) in a Canon conversation — '
41
+ + 'fire-and-forget.',
42
+ request_card: 'Show an interactive canon.card.v1 card (>= 1 actions block).',
43
+ share_contact: "Share a contact card into a conversation. The shared user must be in "
44
+ + 'your contacts.',
45
+ react: 'Toggle an emoji reaction on a Canon message.',
46
+ forward: 'Forward an existing Canon message into another conversation, with an optional caption.',
47
+ create_group: 'Create a Canon group conversation. Directly-addable members join at '
48
+ + 'creation; approval-required members become pending invites '
49
+ + '(result.pending); policy-denied members are skipped (result.skipped).',
50
+ add_member: 'Add a member to a Canon group (approval-required members become a pending invite).',
51
+ remove_member: 'Remove a member from a Canon group (requires owner/admin role).',
52
+ leave_conversation: 'Leave a Canon group conversation.',
53
+ list_contacts: 'List your Canon contacts.',
54
+ list_contact_requests: 'List pending inbound contact requests (read-only awareness).',
55
+ list_conversations: 'List your Canon conversations (optionally limited).',
56
+ };
57
+ /**
58
+ * Posture-specific completion sentences for the blocking interactive verbs.
59
+ * A waiting binding returns the intent-level result inside the tool call; a
60
+ * notify binding returns the accepted half and the answer arrives later —
61
+ * telling the model the wrong one makes it either abandon answers it should
62
+ * consume or wait for notifications that already resolved.
63
+ */
64
+ const INTERACTION_POSTURE_NOTES = {
65
+ waiting: {
66
+ request_input: 'Waits for the human and returns the answer in this tool call '
67
+ + '(submitted / cancelled / timeout).',
68
+ request_approval: 'Blocking mode waits and returns allow / deny / timeout in this tool call.',
69
+ request_card: 'Waits for the human and returns the submission in this tool call.',
70
+ },
71
+ notify: {
72
+ request_input: 'Returns the accepted request; the answer arrives as an inbound channel notification.',
73
+ request_approval: 'Blocking-mode acceptance also resolves via an inbound notification.',
74
+ request_card: 'Returns the accepted request; the submission arrives as an inbound channel notification.',
75
+ },
76
+ };
77
+ export const CANON_TOOL_VERBS = Object.keys(CANON_VERB_TOOL_DESCRIPTIONS);
78
+ const CARD_VERBS = new Set(['send_card', 'request_card']);
79
+ /** MCP tool definitions projected from the contract, shaped per binding. */
80
+ export function canonVerbToolDefinitions(options = {}) {
81
+ const interaction = options.interaction ?? 'notify';
82
+ return CANON_TOOL_VERBS.map((verb) => {
83
+ let inputSchema = getVerbInputSchema(verb, CARD_VERBS.has(verb) ? { cardSchema: RUNTIME_CARD_JSON_SCHEMA_V1 } : undefined);
84
+ // The contract marks conversationId optional because bindings default it
85
+ // to the active conversation — a projection without one must require it
86
+ // from the model instead. Context-bound fields are either injected by
87
+ // the binding or required from the model, never silently absent.
88
+ if (!options.conversationScoped && isInteractionVerb(verb)) {
89
+ const required = new Set([...(inputSchema.required ?? [])]);
90
+ required.add('conversationId');
91
+ inputSchema = { ...inputSchema, required: [...required] };
92
+ }
93
+ const note = INTERACTION_POSTURE_NOTES[interaction][verb];
94
+ const base = CANON_VERB_TOOL_DESCRIPTIONS[verb] ?? verb;
95
+ return {
96
+ name: verb,
97
+ description: note ? `${base} ${note}` : base,
98
+ inputSchema,
99
+ };
100
+ });
101
+ }
102
+ export function isCanonToolVerb(name) {
103
+ return CANON_TOOL_VERBS.includes(name);
104
+ }
105
+ /**
106
+ * Outbound sends from a Claude surface are completed turns: stamp
107
+ * turnSemantics 'turn_complete' into send_to messageOptions.metadata unless
108
+ * the caller already set turn semantics. Shared by the channel server and
109
+ * the host-mode MCP mount.
110
+ */
111
+ export function stampSendToTurnComplete(args) {
112
+ const options = { ...(args.messageOptions ?? {}) };
113
+ options.metadata = {
114
+ turnSemantics: 'turn_complete',
115
+ ...(options.metadata ?? {}),
116
+ };
117
+ return { ...args, messageOptions: options };
118
+ }
119
+ /** Verbs whose intents carry context-bound fields the binding must complete. */
120
+ const INTERACTION_VERBS = new Set([
121
+ 'request_input',
122
+ 'request_approval',
123
+ 'send_card',
124
+ 'request_card',
125
+ ]);
126
+ function isInteractionVerb(verb) {
127
+ return INTERACTION_VERBS.has(verb);
128
+ }
129
+ /**
130
+ * Owner-only input requests (sudo/secret kinds, sensitive, isSecret
131
+ * questions — mirrors interactionInput.ts ownerOnlyResponse): the server
132
+ * REJECTS a non-owner explicit responder for these rather than overriding
133
+ * it, so the binding must not volunteer the turn author — omitting the
134
+ * responder lets the server route to the owner.
135
+ */
136
+ function isOwnerOnlyInputRequest(verb, args) {
137
+ if (verb !== 'request_input')
138
+ return false;
139
+ if (args.kind === 'sudo' || args.kind === 'secret')
140
+ return true;
141
+ if (args.sensitive === true)
142
+ return true;
143
+ const questions = args.questions;
144
+ return Array.isArray(questions)
145
+ && questions.some((question) => Boolean(question?.isSecret));
146
+ }
147
+ /** Back off long-lived defaults from the server ceiling to tolerate clock skew. */
148
+ const DEADLINE_SKEW_MARGIN_MS = 60_000;
149
+ const DEFAULT_INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
150
+ /**
151
+ * Complete a verb intent's context-bound fields before projection:
152
+ * conversationId/turnId/responseUserId from the binding context, generated
153
+ * request ids and default deadlines where the server requires them
154
+ * (blocking input/approval/card requests use Canon's five-minute HITL
155
+ * default; detached approvals use the long-lived 72-hour window; card ids are
156
+ * client-generated for cancellation/reconciliation). Returns
157
+ * the completed args, or an error string when the intent is unroutable
158
+ * without a conversation.
159
+ */
160
+ export function normalizeVerbToolArgs(verb, args, context, now = Date.now()) {
161
+ if (!INTERACTION_VERBS.has(verb))
162
+ return { args };
163
+ const completed = { ...args };
164
+ if (typeof completed.conversationId !== 'string' || !completed.conversationId) {
165
+ if (typeof context?.conversationId === 'string' && context.conversationId) {
166
+ completed.conversationId = context.conversationId;
167
+ }
168
+ else {
169
+ return {
170
+ error: `${verb} requires conversationId: this binding has no active `
171
+ + 'conversation to default to, so pass it explicitly.',
172
+ };
173
+ }
174
+ }
175
+ if (completed.turnId === undefined && context?.turnId) {
176
+ completed.turnId = context.turnId;
177
+ }
178
+ if (verb !== 'send_card'
179
+ && completed.responseUserId === undefined
180
+ && context?.responseUserId
181
+ && !isOwnerOnlyInputRequest(verb, completed)) {
182
+ completed.responseUserId = context.responseUserId;
183
+ }
184
+ const hasDeadline = completed.expiresAt !== undefined || completed.timeoutMs !== undefined;
185
+ // Request ids are generated CLIENT-side (the server would generate
186
+ // approval/card ids itself) so that a create whose RESPONSE is lost is
187
+ // still cancellable — the binding knows the id it must clean up even when
188
+ // the 201 never arrives.
189
+ if (verb === 'request_input') {
190
+ // kind is server-REQUIRED (normalizeKind throws on absence) — a bare
191
+ // request_input({prompt}) is a clarify question.
192
+ if (completed.kind === undefined)
193
+ completed.kind = 'clarify';
194
+ if (completed.inputId === undefined)
195
+ completed.inputId = `input_${randomUUID()}`;
196
+ if (!hasDeadline) {
197
+ completed.expiresAt = now + DEFAULT_INTERACTION_TIMEOUT_MS;
198
+ }
199
+ }
200
+ if (verb === 'request_approval') {
201
+ if (completed.approvalId === undefined)
202
+ completed.approvalId = `apr_${randomUUID()}`;
203
+ if (!hasDeadline) {
204
+ completed.expiresAt =
205
+ completed.mode === 'detached'
206
+ ? now + VERB_LIMITS.maxApprovalTimeoutMs - DEADLINE_SKEW_MARGIN_MS
207
+ : now + DEFAULT_INTERACTION_TIMEOUT_MS;
208
+ }
209
+ }
210
+ if (CARD_VERBS.has(verb) && completed.cardId === undefined) {
211
+ const card = completed.card;
212
+ completed.cardId =
213
+ (typeof card?.cardId === 'string' && card.cardId) || `card_${randomUUID()}`;
214
+ }
215
+ if (verb === 'request_card' && !hasDeadline) {
216
+ completed.expiresAt = now + DEFAULT_INTERACTION_TIMEOUT_MS;
217
+ }
218
+ return { args: completed };
219
+ }
220
+ const DEFAULT_POLL_MS = 1000;
221
+ const DEFAULT_MAX_WAIT_MS = 30 * 60 * 1000;
222
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
223
+ /** One consume-endpoint hit for the verb's interaction family. */
224
+ async function consumeInteractionOnce(client, verb, conversationId, requestId, cancel) {
225
+ if (verb === 'request_approval') {
226
+ return (await client.consumeRuntimeApprovalResponse({
227
+ conversationId,
228
+ approvalId: requestId,
229
+ ...(cancel ? { cancel } : {}),
230
+ }));
231
+ }
232
+ if (verb === 'request_card') {
233
+ return (await client.consumeRuntimeCardResponse({
234
+ conversationId,
235
+ cardId: requestId,
236
+ ...(cancel ? { cancel } : {}),
237
+ }));
238
+ }
239
+ return (await client.consumeRuntimeInputResponse({
240
+ conversationId,
241
+ inputId: requestId,
242
+ ...(cancel ? { cancel } : {}),
243
+ }));
244
+ }
245
+ /**
246
+ * Poll the interaction consume endpoint until the accepted request resolves,
247
+ * expires, or the wait budget runs out; returns the contract's intent-level
248
+ * result. Consume is poll-safe server-side (pending reads do not consume).
249
+ *
250
+ * A wait that ends WITHOUT a resolution does not just walk away — but the
251
+ * server is told the truth about WHY it ended:
252
+ * - turn abort or a local wait budget shorter than the deadline -> consume
253
+ * with cancel: true (the agent walked away; approvals fail closed to deny);
254
+ * - the request's own deadline -> a plain consume, which the server resolves
255
+ * as TIMEOUT with the same cleanup — nobody denied anything, and the
256
+ * receipt must not say they did. Falls back to cancel under clock skew.
257
+ * Either way the human never faces an actionable card for a dead turn, and
258
+ * both paths are race-safe — a response that committed first is returned
259
+ * instead of discarded, so the answer wins whenever one exists.
260
+ */
261
+ async function waitForInteractionResult(client, verb, conversationId, accepted, options) {
262
+ const requestId = String(accepted.requestId ?? '');
263
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
264
+ const horizon = Math.min(typeof accepted.expiresAt === 'number' ? accepted.expiresAt : Date.now() + DEFAULT_MAX_WAIT_MS, Date.now() + (options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS));
265
+ const timeoutResult = verb === 'request_approval'
266
+ ? { status: 'timeout', approvalId: requestId }
267
+ : verb === 'request_card'
268
+ ? { status: 'timeout', cardId: requestId }
269
+ : { status: 'timeout', inputId: requestId };
270
+ // Cancel-or-take: resolve the pending request as the family's cancelled
271
+ // result (deny for approvals — fail closed), or return the committed
272
+ // response if one raced in. Falls back to the local timeout result only
273
+ // when the cancel call itself fails.
274
+ const cancelOrTake = async () => {
275
+ try {
276
+ return await consumeInteractionOnce(client, verb, conversationId, requestId, true);
277
+ }
278
+ catch {
279
+ return timeoutResult;
280
+ }
281
+ };
282
+ // The natural deadline bound this wait: a plain consume lets the server
283
+ // resolve TIMEOUT (identical cleanup, honest attribution) instead of an
284
+ // agent cancel that approvals would record as a deny nobody made. If the
285
+ // server clock still sees it pending (skew), fall back to cancel.
286
+ const finishAtDeadline = async () => {
287
+ try {
288
+ const consumed = await consumeInteractionOnce(client, verb, conversationId, requestId, false);
289
+ if (consumed.status !== 'pending')
290
+ return consumed;
291
+ }
292
+ catch {
293
+ // fall through to cancel
294
+ }
295
+ return cancelOrTake();
296
+ };
297
+ const deadlineBoundsWait = typeof accepted.expiresAt === 'number' && horizon >= accepted.expiresAt;
298
+ while (Date.now() < horizon) {
299
+ if (options.signal?.aborted)
300
+ return cancelOrTake();
301
+ let consumed;
302
+ try {
303
+ consumed = await consumeInteractionOnce(client, verb, conversationId, requestId, false);
304
+ }
305
+ catch (error) {
306
+ if (error?.status === 404) {
307
+ // Definitive: no pending request and no replayable tombstone exists
308
+ // (consume replays resolved results, so 404 is never transient).
309
+ return timeoutResult;
310
+ }
311
+ // Transient consume failure: keep polling until the horizon.
312
+ await sleep(pollMs);
313
+ continue;
314
+ }
315
+ if (consumed.status !== 'pending')
316
+ return consumed;
317
+ await sleep(pollMs);
318
+ }
319
+ return deadlineBoundsWait ? finishAtDeadline() : cancelOrTake();
320
+ }
321
+ function statusLine(verb, value) {
322
+ const status = typeof value.status === 'string' ? value.status : 'ok';
323
+ const id = value.messageId ?? value.requestId ?? value.approvalId ?? value.cardId ?? value.conversationId;
324
+ return `${verb}: ${status}${id ? ` (${String(id)})` : ''}`;
325
+ }
326
+ /**
327
+ * Execute a verb tool call: byte-limit precheck, project the intent to the
328
+ * wire (json codec), POST to /agent/verbs/:verb, unwrap the result.
329
+ */
330
+ export async function executeCanonVerbTool(client, verb, args, options = {}) {
331
+ const normalized = normalizeVerbToolArgs(verb, args, options.context);
332
+ if ('error' in normalized) {
333
+ return { content: [{ type: 'text', text: `Invalid arguments: ${normalized.error}` }], isError: true };
334
+ }
335
+ args = normalized.args;
336
+ // canonContactId is a contact-card identity, not a wire field: resolve it
337
+ // to a targetUserId here (the two-step the contract documents) so the
338
+ // advertised send_to schema is honest in every binding.
339
+ if (verb === 'send_to' && typeof args.canonContactId === 'string') {
340
+ const resolved = await client.resolveAdmission({ canonContactId: args.canonContactId });
341
+ if (!resolved.resolvedTargetUserId) {
342
+ return {
343
+ content: [{
344
+ type: 'text',
345
+ text: `send_to: unavailable\n${JSON.stringify({ status: 'unavailable', reason: 'canonContactId could not be resolved' }, null, 2)}`,
346
+ }],
347
+ isError: true,
348
+ };
349
+ }
350
+ const { canonContactId: _resolved, ...rest } = args;
351
+ args = { ...rest, targetUserId: resolved.resolvedTargetUserId };
352
+ }
353
+ const violations = findVerbByteLimitViolations(verb, args);
354
+ if (violations.length > 0) {
355
+ return {
356
+ content: [{
357
+ type: 'text',
358
+ text: `Invalid arguments: ${violations.map((v) => `${v.path}: ${v.message}`).join('; ')}`,
359
+ }],
360
+ isError: true,
361
+ };
362
+ }
363
+ const wire = projectVerbIntentToWire(verb, args, { now: Date.now() });
364
+ // The interactive request ids are known BEFORE the call (client-generated
365
+ // in normalization): if the create commits but its response is lost, the
366
+ // request is cancelled best-effort instead of orphaning an actionable
367
+ // card the binding could never clean up.
368
+ const interactiveRequestId = verb === 'request_input'
369
+ ? args.inputId
370
+ : verb === 'request_approval'
371
+ ? args.approvalId
372
+ : verb === 'request_card'
373
+ ? args.cardId
374
+ : undefined;
375
+ let response;
376
+ try {
377
+ response = await client.executeVerbWire(wire);
378
+ }
379
+ catch (error) {
380
+ if (typeof interactiveRequestId === 'string' && typeof args.conversationId === 'string') {
381
+ try {
382
+ await consumeInteractionOnce(client, verb, args.conversationId, interactiveRequestId, true);
383
+ }
384
+ catch {
385
+ // Best-effort: the create may never have committed at all.
386
+ }
387
+ }
388
+ throw error;
389
+ }
390
+ let value = response.result.encoding === 'json'
391
+ ? response.result.value
392
+ : { status: 'encrypted', encoding: response.result.encoding };
393
+ const isBlockingInteractive = (verb === 'request_input' || verb === 'request_card'
394
+ || (verb === 'request_approval' && args.mode !== 'detached'))
395
+ && value.status === 'accepted'
396
+ // The server marks actions-free display cards interactive:false — there
397
+ // is no pending node to wait on.
398
+ && value.interactive !== false;
399
+ if (isBlockingInteractive && options.waitForResult && typeof args.conversationId === 'string') {
400
+ value = await waitForInteractionResult(client, verb, args.conversationId, value, options);
401
+ }
402
+ return {
403
+ content: [{
404
+ type: 'text',
405
+ text: `${statusLine(verb, value)}\n${JSON.stringify(value, null, 2)}`,
406
+ }],
407
+ };
408
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@canonmsg/agent-tools",
3
+ "version": "0.2.0",
4
+ "description": "Canonical Canon verb tools — shared projections of canon.verbs.v1 for runtime bindings",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../rich-cards",
19
+ "build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
20
+ "dev": "tsc --watch",
21
+ "test": "vitest run",
22
+ "prepack": "npm run build"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "keywords": [
28
+ "canon",
29
+ "verbs",
30
+ "agent-tools",
31
+ "mcp"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/HeyBobChan/canon",
36
+ "directory": "packages/agent-tools"
37
+ },
38
+ "homepage": "https://github.com/HeyBobChan/canon/tree/main/packages/agent-tools",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@canonmsg/core": "^4.3.0",
44
+ "@canonmsg/rich-cards": "^0.8.1",
45
+ "@modelcontextprotocol/sdk": "^1.29.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.0.0",
49
+ "ajv": "^8.20.0",
50
+ "typescript": "~5.7.0",
51
+ "vitest": "^4.1.8"
52
+ },
53
+ "license": "MIT"
54
+ }