@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/verb-mcp.d.ts +32 -0
- package/dist/verb-mcp.js +80 -0
- package/dist/verb-tools.d.ts +113 -0
- package/dist/verb-tools.js +408 -0
- package/package.json +54 -0
package/dist/index.d.ts
ADDED
|
@@ -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,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;
|
package/dist/verb-mcp.js
ADDED
|
@@ -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
|
+
}
|