@canonmsg/agent-tools 0.6.0 → 0.7.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/README.md +6 -1
- package/dist/communication-tool.d.ts +20 -0
- package/dist/communication-tool.js +259 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/verb-mcp.d.ts +3 -12
- package/dist/verb-mcp.js +20 -86
- package/dist/verb-tools.d.ts +0 -19
- package/dist/verb-tools.js +1 -72
- package/package.json +3 -3
- package/dist/owner-bound-communication.d.ts +0 -36
- package/dist/owner-bound-communication.js +0 -163
package/README.md
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Model-facing tool definitions for the canonical Canon verbs, plus the dispatch that executes them.
|
|
4
4
|
|
|
5
|
-
This is the
|
|
5
|
+
This is the model-facing projection of Canon's verb layer. Everyday outbound
|
|
6
|
+
communication uses one `communicate` tool for existing messages, new direct
|
|
7
|
+
conversations, group creation, and forwarding. Interaction, card, reaction,
|
|
8
|
+
contact-sharing, group-management, read, and `no_reply` verbs remain separate
|
|
9
|
+
where their model interaction is materially different. Everything dispatches
|
|
10
|
+
through the canonical `POST /agent/verbs/:verb` endpoints.
|
|
6
11
|
|
|
7
12
|
Use it if you are binding Canon into an LLM runtime that speaks tools (an MCP server, or an in-process tool mount). If you are writing an agent, use [`@canonmsg/agent-sdk`](https://www.npmjs.com/package/@canonmsg/agent-sdk) instead — it wraps this layer for you.
|
|
8
13
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CanonClient, type CommunicateInput } from '@canonmsg/core';
|
|
2
|
+
import type { VerbToolDefinition, VerbToolResult } from './verb-tools.js';
|
|
3
|
+
export declare const CANON_COMMUNICATE_TOOL_NAME = "communicate";
|
|
4
|
+
/**
|
|
5
|
+
* The complete model-facing communication surface. It is exported separately
|
|
6
|
+
* from the in-conversation verb list so each runtime can opt into it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function canonCommunicateToolDefinition(name?: string): VerbToolDefinition;
|
|
9
|
+
/** Runtime validation mirrors the strict schema for bindings that dispatch directly. */
|
|
10
|
+
export declare function parseCommunicateToolInput(value: unknown): CommunicateInput;
|
|
11
|
+
export declare function executeCanonCommunicateTool(client: CanonClient, args: unknown): Promise<VerbToolResult>;
|
|
12
|
+
export interface CanonCommunicationBinding {
|
|
13
|
+
tools: ReadonlyArray<VerbToolDefinition>;
|
|
14
|
+
isToolName(name: string): boolean;
|
|
15
|
+
execute(client: CanonClient, toolName: string, args: unknown): Promise<VerbToolResult>;
|
|
16
|
+
}
|
|
17
|
+
/** A one-tool binding that runtimes may mount without adding any other tools. */
|
|
18
|
+
export declare function createCanonCommunicationBinding(options?: {
|
|
19
|
+
toolName?: string;
|
|
20
|
+
}): CanonCommunicationBinding;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { parseDirectConversationSelection, VERB_LIMITS, } from '@canonmsg/core';
|
|
2
|
+
export const CANON_COMMUNICATE_TOOL_NAME = 'communicate';
|
|
3
|
+
const NON_EMPTY_ID = { type: 'string', minLength: 1, maxLength: 160 };
|
|
4
|
+
const MESSAGE_TEXT = { type: 'string', minLength: 1, maxLength: 4096 };
|
|
5
|
+
const OPTIONAL_CAPTION = { type: 'string', maxLength: 4096 };
|
|
6
|
+
const DIRECT_SELECTION_SCHEMA = {
|
|
7
|
+
oneOf: [
|
|
8
|
+
{
|
|
9
|
+
type: 'object',
|
|
10
|
+
required: ['mode'],
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
properties: { mode: { const: 'latest_or_new' } },
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['mode'],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
properties: { mode: { const: 'new' } },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: 'object',
|
|
22
|
+
required: ['mode', 'conversationId'],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
properties: {
|
|
25
|
+
mode: { const: 'specific' },
|
|
26
|
+
conversationId: NON_EMPTY_ID,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The complete model-facing communication surface. It is exported separately
|
|
33
|
+
* from the in-conversation verb list so each runtime can opt into it.
|
|
34
|
+
*/
|
|
35
|
+
export function canonCommunicateToolDefinition(name = CANON_COMMUNICATE_TOOL_NAME) {
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
description: 'Communicate in Canon. Message a known conversation, start/continue a '
|
|
39
|
+
+ 'direct conversation, create a group, or forward an exact message. Canon '
|
|
40
|
+
+ 'enforces the recipient and agent-deployer policies; a new direct '
|
|
41
|
+
+ 'conversation or group member may require approval.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
oneOf: [
|
|
45
|
+
{
|
|
46
|
+
type: 'object',
|
|
47
|
+
required: ['action', 'conversationId', 'text'],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
properties: {
|
|
50
|
+
action: { const: 'message_existing' },
|
|
51
|
+
conversationId: NON_EMPTY_ID,
|
|
52
|
+
text: MESSAGE_TEXT,
|
|
53
|
+
messageId: NON_EMPTY_ID,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'object',
|
|
58
|
+
required: ['action', 'principalId', 'text'],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
properties: {
|
|
61
|
+
action: { const: 'start_direct' },
|
|
62
|
+
principalId: NON_EMPTY_ID,
|
|
63
|
+
text: MESSAGE_TEXT,
|
|
64
|
+
selection: DIRECT_SELECTION_SCHEMA,
|
|
65
|
+
messageId: NON_EMPTY_ID,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: 'object',
|
|
70
|
+
required: ['action', 'name', 'memberIds'],
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
properties: {
|
|
73
|
+
action: { const: 'create_group' },
|
|
74
|
+
name: { type: 'string', minLength: 1, maxLength: VERB_LIMITS.groupNameChars },
|
|
75
|
+
memberIds: {
|
|
76
|
+
type: 'array',
|
|
77
|
+
minItems: 1,
|
|
78
|
+
maxItems: VERB_LIMITS.groupMembers - 1,
|
|
79
|
+
uniqueItems: true,
|
|
80
|
+
items: NON_EMPTY_ID,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: 'object',
|
|
86
|
+
required: [
|
|
87
|
+
'action',
|
|
88
|
+
'sourceConversationId',
|
|
89
|
+
'targetConversationId',
|
|
90
|
+
'messageId',
|
|
91
|
+
],
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
properties: {
|
|
94
|
+
action: { const: 'forward_message' },
|
|
95
|
+
sourceConversationId: NON_EMPTY_ID,
|
|
96
|
+
targetConversationId: NON_EMPTY_ID,
|
|
97
|
+
messageId: NON_EMPTY_ID,
|
|
98
|
+
text: OPTIONAL_CAPTION,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function isRecord(value) {
|
|
106
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
107
|
+
}
|
|
108
|
+
function readRequiredString(value) {
|
|
109
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
110
|
+
}
|
|
111
|
+
function hasOnlyKeys(record, allowed) {
|
|
112
|
+
return Object.keys(record).every((key) => allowed.has(key));
|
|
113
|
+
}
|
|
114
|
+
/** Runtime validation mirrors the strict schema for bindings that dispatch directly. */
|
|
115
|
+
export function parseCommunicateToolInput(value) {
|
|
116
|
+
if (!isRecord(value))
|
|
117
|
+
throw new Error('communicate arguments must be an object');
|
|
118
|
+
if (value.action === 'create_group') {
|
|
119
|
+
if (!hasOnlyKeys(value, new Set(['action', 'name', 'memberIds']))) {
|
|
120
|
+
throw new Error('create_group contains unsupported fields');
|
|
121
|
+
}
|
|
122
|
+
const name = readRequiredString(value.name);
|
|
123
|
+
if (!name)
|
|
124
|
+
throw new Error('communicate.name is required');
|
|
125
|
+
if (!Array.isArray(value.memberIds)
|
|
126
|
+
|| value.memberIds.length === 0
|
|
127
|
+
|| !value.memberIds.every((entry) => readRequiredString(entry) !== null)) {
|
|
128
|
+
throw new Error('communicate.memberIds must contain at least one principal id');
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
action: 'create_group',
|
|
132
|
+
name,
|
|
133
|
+
memberIds: [...new Set(value.memberIds)],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (value.action === 'forward_message') {
|
|
137
|
+
if (!hasOnlyKeys(value, new Set([
|
|
138
|
+
'action',
|
|
139
|
+
'sourceConversationId',
|
|
140
|
+
'targetConversationId',
|
|
141
|
+
'messageId',
|
|
142
|
+
'text',
|
|
143
|
+
]))) {
|
|
144
|
+
throw new Error('forward_message contains unsupported fields');
|
|
145
|
+
}
|
|
146
|
+
const sourceConversationId = readRequiredString(value.sourceConversationId);
|
|
147
|
+
const targetConversationId = readRequiredString(value.targetConversationId);
|
|
148
|
+
const messageId = readRequiredString(value.messageId);
|
|
149
|
+
if (!sourceConversationId)
|
|
150
|
+
throw new Error('communicate.sourceConversationId is required');
|
|
151
|
+
if (!targetConversationId)
|
|
152
|
+
throw new Error('communicate.targetConversationId is required');
|
|
153
|
+
if (!messageId)
|
|
154
|
+
throw new Error('communicate.messageId is required');
|
|
155
|
+
if (value.text !== undefined && typeof value.text !== 'string') {
|
|
156
|
+
throw new Error('communicate.text must be a string');
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
action: 'forward_message',
|
|
160
|
+
sourceConversationId,
|
|
161
|
+
targetConversationId,
|
|
162
|
+
messageId,
|
|
163
|
+
...(value.text !== undefined ? { text: value.text } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const text = readRequiredString(value.text);
|
|
167
|
+
const messageId = value.messageId === undefined
|
|
168
|
+
? undefined
|
|
169
|
+
: readRequiredString(value.messageId);
|
|
170
|
+
if (!text)
|
|
171
|
+
throw new Error('communicate.text is required');
|
|
172
|
+
if (value.messageId !== undefined && !messageId) {
|
|
173
|
+
throw new Error('communicate.messageId must be a non-empty string');
|
|
174
|
+
}
|
|
175
|
+
if (value.action === 'message_existing') {
|
|
176
|
+
if (!hasOnlyKeys(value, new Set(['action', 'conversationId', 'text', 'messageId']))) {
|
|
177
|
+
throw new Error('message_existing contains unsupported fields');
|
|
178
|
+
}
|
|
179
|
+
const conversationId = readRequiredString(value.conversationId);
|
|
180
|
+
if (!conversationId)
|
|
181
|
+
throw new Error('communicate.conversationId is required');
|
|
182
|
+
return {
|
|
183
|
+
action: 'message_existing',
|
|
184
|
+
conversationId,
|
|
185
|
+
text,
|
|
186
|
+
...(messageId ? { messageId } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (value.action === 'start_direct') {
|
|
190
|
+
if (!hasOnlyKeys(value, new Set(['action', 'principalId', 'text', 'selection', 'messageId']))) {
|
|
191
|
+
throw new Error('start_direct contains unsupported fields');
|
|
192
|
+
}
|
|
193
|
+
const principalId = readRequiredString(value.principalId);
|
|
194
|
+
if (!principalId)
|
|
195
|
+
throw new Error('communicate.principalId is required');
|
|
196
|
+
const selection = value.selection === undefined
|
|
197
|
+
? undefined
|
|
198
|
+
: parseDirectConversationSelection(value.selection);
|
|
199
|
+
return {
|
|
200
|
+
action: 'start_direct',
|
|
201
|
+
principalId,
|
|
202
|
+
text,
|
|
203
|
+
...(selection ? { selection } : {}),
|
|
204
|
+
...(messageId ? { messageId } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
throw new Error('communicate.action is invalid');
|
|
208
|
+
}
|
|
209
|
+
function renderResult(result) {
|
|
210
|
+
const id = result.status === 'messaged'
|
|
211
|
+
? result.messageId
|
|
212
|
+
: result.status === 'forwarded'
|
|
213
|
+
? result.messageId
|
|
214
|
+
: result.status === 'created'
|
|
215
|
+
? result.conversationId
|
|
216
|
+
: result.status === 'requested'
|
|
217
|
+
|| result.status === 'pending'
|
|
218
|
+
? result.requestId
|
|
219
|
+
: undefined;
|
|
220
|
+
return {
|
|
221
|
+
content: [{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text: `communicate: ${result.status}${id ? ` (${id})` : ''}\n${JSON.stringify(result, null, 2)}`,
|
|
224
|
+
}],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function executeCanonCommunicateTool(client, args) {
|
|
228
|
+
let input;
|
|
229
|
+
try {
|
|
230
|
+
input = parseCommunicateToolInput(args);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{
|
|
235
|
+
type: 'text',
|
|
236
|
+
text: `Invalid communicate arguments: ${error.message}`,
|
|
237
|
+
}],
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return renderResult(await client.communicate(input));
|
|
242
|
+
}
|
|
243
|
+
/** A one-tool binding that runtimes may mount without adding any other tools. */
|
|
244
|
+
export function createCanonCommunicationBinding(options = {}) {
|
|
245
|
+
const toolName = options.toolName?.trim() || CANON_COMMUNICATE_TOOL_NAME;
|
|
246
|
+
return {
|
|
247
|
+
tools: [canonCommunicateToolDefinition(toolName)],
|
|
248
|
+
isToolName: (name) => name === toolName,
|
|
249
|
+
execute: (client, name, args) => {
|
|
250
|
+
if (name !== toolName) {
|
|
251
|
+
return Promise.resolve({
|
|
252
|
+
content: [{ type: 'text', text: `Unsupported Canon communication tool: ${name}` }],
|
|
253
|
+
isError: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return executeCanonCommunicateTool(client, args);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { CANON_TOOL_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, normalizeVerbToolArgs,
|
|
2
|
-
export { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer,
|
|
3
|
-
export {
|
|
1
|
+
export { CANON_TOOL_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, normalizeVerbToolArgs, type ExecuteVerbToolOptions, type VerbExecutionContext, type VerbProjectionOptions, type VerbToolDefinition, type VerbToolResult, } from './verb-tools.js';
|
|
2
|
+
export { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer, type CanonVerbMcpServerOptions, } from './verb-mcp.js';
|
|
3
|
+
export { CANON_COMMUNICATE_TOOL_NAME, canonCommunicateToolDefinition, createCanonCommunicationBinding, executeCanonCommunicateTool, parseCommunicateToolInput, type CanonCommunicationBinding, } from './communication-tool.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { CANON_TOOL_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, normalizeVerbToolArgs,
|
|
2
|
-
export { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer,
|
|
3
|
-
export {
|
|
1
|
+
export { CANON_TOOL_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, normalizeVerbToolArgs, } from './verb-tools.js';
|
|
2
|
+
export { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer, } from './verb-mcp.js';
|
|
3
|
+
export { CANON_COMMUNICATE_TOOL_NAME, canonCommunicateToolDefinition, createCanonCommunicationBinding, executeCanonCommunicateTool, parseCommunicateToolInput, } from './communication-tool.js';
|
package/dist/verb-mcp.d.ts
CHANGED
|
@@ -21,17 +21,14 @@
|
|
|
21
21
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
22
22
|
import type { CanonClient, CanonVerbName } from '@canonmsg/core';
|
|
23
23
|
import { type VerbExecutionContext } from './verb-tools.js';
|
|
24
|
-
import { type OwnerBoundCanonTurnContext } from './owner-bound-communication.js';
|
|
25
24
|
/** MCP server name — verbs appear to the model as `mcp__canon__<verb>`. */
|
|
26
25
|
export declare const CANON_VERB_MCP_SERVER_NAME = "canon";
|
|
27
26
|
export interface CanonVerbMcpServerOptions {
|
|
28
27
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* Add the compact principal-neutral `communicate` tool. The broad `send_to`
|
|
29
|
+
* verb is never model-visible; all conversation-scoped verbs remain unchanged.
|
|
31
30
|
*/
|
|
32
|
-
|
|
33
|
-
getContext: () => OwnerBoundCanonTurnContext | null | undefined;
|
|
34
|
-
};
|
|
31
|
+
communication?: boolean;
|
|
35
32
|
}
|
|
36
33
|
export declare function createCanonVerbMcpServer(getClient: () => CanonClient | null,
|
|
37
34
|
/**
|
|
@@ -48,9 +45,3 @@ getContext?: () => VerbExecutionContext | undefined,
|
|
|
48
45
|
* the model" rather than "silence never happens".
|
|
49
46
|
*/
|
|
50
47
|
onVerbCall?: (verb: CanonVerbName, args: Record<string, unknown>, context: VerbExecutionContext | undefined) => void, options?: CanonVerbMcpServerOptions): McpServer;
|
|
51
|
-
/**
|
|
52
|
-
* In-process MCP projection for a foreground owner turn. Unlike the full
|
|
53
|
-
* canonical server, this exposes only the admission-aware communication
|
|
54
|
-
* surface and rejects calls when the host cannot prove owner/source context.
|
|
55
|
-
*/
|
|
56
|
-
export declare function createOwnerBoundCanonCommunicationMcpServer(getClient: () => CanonClient | null, getContext: () => OwnerBoundCanonTurnContext | null | undefined, onVerbCall?: (verb: CanonVerbName, args: Record<string, unknown>) => void): McpServer;
|
package/dist/verb-mcp.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
22
22
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
23
|
-
import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb,
|
|
24
|
-
import {
|
|
23
|
+
import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, } from './verb-tools.js';
|
|
24
|
+
import { createCanonCommunicationBinding } from './communication-tool.js';
|
|
25
25
|
/** MCP server name — verbs appear to the model as `mcp__canon__<verb>`. */
|
|
26
26
|
export const CANON_VERB_MCP_SERVER_NAME = 'canon';
|
|
27
27
|
export function createCanonVerbMcpServer(getClient,
|
|
@@ -46,27 +46,19 @@ onVerbCall, options = {}) {
|
|
|
46
46
|
// tool handlers).
|
|
47
47
|
const mcpServer = new McpServer({ name: CANON_VERB_MCP_SERVER_NAME, version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
48
48
|
const server = mcpServer.server;
|
|
49
|
+
const communication = createCanonCommunicationBinding();
|
|
49
50
|
// The projection must tell the model what the dispatch below actually
|
|
50
51
|
// does: waiting posture (waitForResult: true), and conversation-scoped
|
|
51
52
|
// exactly when the binding supplies a context.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return { tools: canonical };
|
|
62
|
-
const ownerVerbs = new Set(OWNER_BOUND_CANON_COMMUNICATION_VERBS);
|
|
63
|
-
return {
|
|
64
|
-
tools: [
|
|
65
|
-
...canonical.filter((definition) => !ownerVerbs.has(definition.name)),
|
|
66
|
-
...ownerBinding.tools,
|
|
67
|
-
],
|
|
68
|
-
};
|
|
69
|
-
});
|
|
53
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
54
|
+
tools: [
|
|
55
|
+
...canonVerbToolDefinitions({
|
|
56
|
+
interaction: 'waiting',
|
|
57
|
+
conversationScoped: Boolean(getContext),
|
|
58
|
+
}),
|
|
59
|
+
...(options.communication ? communication.tools : []),
|
|
60
|
+
],
|
|
61
|
+
}));
|
|
70
62
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
71
63
|
const client = getClient();
|
|
72
64
|
if (!client) {
|
|
@@ -76,6 +68,13 @@ onVerbCall, options = {}) {
|
|
|
76
68
|
};
|
|
77
69
|
}
|
|
78
70
|
const name = request.params.name;
|
|
71
|
+
if (options.communication && communication.isToolName(name)) {
|
|
72
|
+
const result = await communication.execute(client, name, request.params.arguments);
|
|
73
|
+
return {
|
|
74
|
+
content: result.content.map((item) => ({ type: 'text', text: item.text })),
|
|
75
|
+
...(result.isError ? { isError: true } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
79
78
|
if (!isCanonToolVerb(name)) {
|
|
80
79
|
return {
|
|
81
80
|
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
@@ -85,25 +84,7 @@ onVerbCall, options = {}) {
|
|
|
85
84
|
const rawArgs = request.params.arguments && typeof request.params.arguments === 'object'
|
|
86
85
|
? { ...request.params.arguments }
|
|
87
86
|
: {};
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
onVerbCall?.(name, rawArgs, getContext?.());
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
// A host bookkeeping bug must never break tool dispatch.
|
|
94
|
-
}
|
|
95
|
-
const result = await ownerBinding.execute({
|
|
96
|
-
client,
|
|
97
|
-
toolName: name,
|
|
98
|
-
arguments: rawArgs,
|
|
99
|
-
context: options.ownerBoundCommunication.getContext(),
|
|
100
|
-
});
|
|
101
|
-
return {
|
|
102
|
-
content: result.content.map((item) => ({ type: 'text', text: item.text })),
|
|
103
|
-
...(result.isError ? { isError: true } : {}),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
const verbArgs = name === 'send_to' ? stampSendToTurnComplete(rawArgs) : rawArgs;
|
|
87
|
+
const verbArgs = rawArgs;
|
|
107
88
|
const context = getContext?.();
|
|
108
89
|
try {
|
|
109
90
|
onVerbCall?.(name, verbArgs, context);
|
|
@@ -123,50 +104,3 @@ onVerbCall, options = {}) {
|
|
|
123
104
|
});
|
|
124
105
|
return mcpServer;
|
|
125
106
|
}
|
|
126
|
-
/**
|
|
127
|
-
* In-process MCP projection for a foreground owner turn. Unlike the full
|
|
128
|
-
* canonical server, this exposes only the admission-aware communication
|
|
129
|
-
* surface and rejects calls when the host cannot prove owner/source context.
|
|
130
|
-
*/
|
|
131
|
-
export function createOwnerBoundCanonCommunicationMcpServer(getClient, getContext, onVerbCall) {
|
|
132
|
-
const binding = createOwnerBoundCanonCommunicationBinding();
|
|
133
|
-
const mcpServer = new McpServer({ name: CANON_VERB_MCP_SERVER_NAME, version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
134
|
-
const server = mcpServer.server;
|
|
135
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: binding.tools }));
|
|
136
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
137
|
-
const client = getClient();
|
|
138
|
-
if (!client) {
|
|
139
|
-
return {
|
|
140
|
-
content: [{ type: 'text', text: 'Canon not connected' }],
|
|
141
|
-
isError: true,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
const verb = binding.verbForToolName(request.params.name);
|
|
145
|
-
if (!verb) {
|
|
146
|
-
return {
|
|
147
|
-
content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }],
|
|
148
|
-
isError: true,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
const args = request.params.arguments && typeof request.params.arguments === 'object'
|
|
152
|
-
? { ...request.params.arguments }
|
|
153
|
-
: {};
|
|
154
|
-
try {
|
|
155
|
-
onVerbCall?.(verb, args);
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
// Host bookkeeping cannot break dispatch.
|
|
159
|
-
}
|
|
160
|
-
const result = await binding.execute({
|
|
161
|
-
client,
|
|
162
|
-
toolName: request.params.name,
|
|
163
|
-
arguments: args,
|
|
164
|
-
context: getContext(),
|
|
165
|
-
});
|
|
166
|
-
return {
|
|
167
|
-
content: result.content.map((item) => ({ type: 'text', text: item.text })),
|
|
168
|
-
...(result.isError ? { isError: true } : {}),
|
|
169
|
-
};
|
|
170
|
-
});
|
|
171
|
-
return mcpServer;
|
|
172
|
-
}
|
package/dist/verb-tools.d.ts
CHANGED
|
@@ -47,13 +47,6 @@ export interface VerbProjectionOptions {
|
|
|
47
47
|
/** MCP tool definitions projected from the contract, shaped per binding. */
|
|
48
48
|
export declare function canonVerbToolDefinitions(options?: VerbProjectionOptions): VerbToolDefinition[];
|
|
49
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
50
|
export interface VerbToolResult {
|
|
58
51
|
content: Array<{
|
|
59
52
|
type: 'text';
|
|
@@ -79,18 +72,6 @@ export interface VerbExecutionContext {
|
|
|
79
72
|
responseUserId?: string;
|
|
80
73
|
/** Canon message id of the inbound message that triggered this turn. */
|
|
81
74
|
sourceMessageId?: string;
|
|
82
|
-
/**
|
|
83
|
-
* Trusted provenance for a host-bound send_to projection. When present,
|
|
84
|
-
* these fields replace any same-named model-authored values. Bindings that
|
|
85
|
-
* expose the full canonical schema simply omit it and retain existing
|
|
86
|
-
* unscoped send_to behavior.
|
|
87
|
-
*/
|
|
88
|
-
sendTo?: {
|
|
89
|
-
sourceConversationId: string;
|
|
90
|
-
sourceMessageId?: string;
|
|
91
|
-
turnId?: string;
|
|
92
|
-
turnSemantics: 'turn_complete';
|
|
93
|
-
};
|
|
94
75
|
}
|
|
95
76
|
export interface ExecuteVerbToolOptions {
|
|
96
77
|
/**
|
package/dist/verb-tools.js
CHANGED
|
@@ -28,10 +28,6 @@ import { findVerbByteLimitViolations, getVerbInputSchema, projectVerbIntentToWir
|
|
|
28
28
|
import { RUNTIME_CARD_JSON_SCHEMA_V1 } from '@canonmsg/rich-cards';
|
|
29
29
|
/** Verbs exposed to models, with binding-level descriptions. */
|
|
30
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
31
|
request_input: 'Ask a human in a Canon conversation a structured question (input card).',
|
|
36
32
|
request_approval: 'Ask a human to allow/deny an action via a Canon approval card. Use '
|
|
37
33
|
+ "mode 'detached' and poll with check_approval for long-lived approvals.",
|
|
@@ -43,17 +39,12 @@ const CANON_VERB_TOOL_DESCRIPTIONS = {
|
|
|
43
39
|
share_contact: "Share a contact card into a conversation. The shared user must be in "
|
|
44
40
|
+ 'your contacts.',
|
|
45
41
|
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; policy approval and agent-owner setup become pending invites '
|
|
49
|
-
+ '(result.pending); hard-denied members are skipped (result.skipped).',
|
|
50
42
|
add_member: 'Add a member to a Canon group (policy approval and/or agent-owner setup may return a pending invite).',
|
|
51
43
|
remove_member: 'Remove a member from a Canon group (requires owner/admin role).',
|
|
52
44
|
leave_conversation: 'Leave a Canon group conversation.',
|
|
53
45
|
list_contacts: 'List your Canon contacts.',
|
|
54
|
-
list_contact_requests: 'List
|
|
46
|
+
list_contact_requests: 'List pending inbound contact requests (read-only awareness).',
|
|
55
47
|
list_conversations: 'List your Canon conversations (optionally limited).',
|
|
56
|
-
cancel_contact_request: 'Cancel your own pending direct-message contact request.',
|
|
57
48
|
no_reply: 'End your turn without posting anything to the conversation. Use it in '
|
|
58
49
|
+ 'groups when you have nothing to add — no message is created, so no '
|
|
59
50
|
+ 'other member or agent is triggered. Optional private reason (logged, '
|
|
@@ -125,20 +116,6 @@ export function canonVerbToolDefinitions(options = {}) {
|
|
|
125
116
|
export function isCanonToolVerb(name) {
|
|
126
117
|
return CANON_TOOL_VERBS.includes(name);
|
|
127
118
|
}
|
|
128
|
-
/**
|
|
129
|
-
* Outbound sends from a Claude surface are completed turns: stamp
|
|
130
|
-
* turnSemantics 'turn_complete' into send_to messageOptions.metadata unless
|
|
131
|
-
* the caller already set turn semantics. Shared by the channel server and
|
|
132
|
-
* the host-mode MCP mount.
|
|
133
|
-
*/
|
|
134
|
-
export function stampSendToTurnComplete(args) {
|
|
135
|
-
const options = { ...(args.messageOptions ?? {}) };
|
|
136
|
-
options.metadata = {
|
|
137
|
-
turnSemantics: 'turn_complete',
|
|
138
|
-
...(options.metadata ?? {}),
|
|
139
|
-
};
|
|
140
|
-
return { ...args, messageOptions: options };
|
|
141
|
-
}
|
|
142
119
|
/** Verbs whose intents carry context-bound fields the binding must complete. */
|
|
143
120
|
const INTERACTION_VERBS = new Set([
|
|
144
121
|
'request_input',
|
|
@@ -181,34 +158,6 @@ const DEFAULT_INTERACTION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
181
158
|
* without a conversation.
|
|
182
159
|
*/
|
|
183
160
|
export function normalizeVerbToolArgs(verb, args, context, now = Date.now()) {
|
|
184
|
-
if (verb === 'send_to' && context?.sendTo) {
|
|
185
|
-
const messageOptions = isRecord(args.messageOptions) ? { ...args.messageOptions } : {};
|
|
186
|
-
const metadata = isRecord(messageOptions.metadata) ? { ...messageOptions.metadata } : {};
|
|
187
|
-
// These names are host-owned on a bound projection. Delete first so an
|
|
188
|
-
// absent optional trusted value cannot leave a forged model value behind.
|
|
189
|
-
delete metadata.sourceConversationId;
|
|
190
|
-
delete metadata.sourceMessageId;
|
|
191
|
-
delete metadata.turnId;
|
|
192
|
-
delete metadata.turnSemantics;
|
|
193
|
-
return {
|
|
194
|
-
args: {
|
|
195
|
-
...args,
|
|
196
|
-
sourceConversationId: context.sendTo.sourceConversationId,
|
|
197
|
-
messageOptions: {
|
|
198
|
-
...messageOptions,
|
|
199
|
-
metadata: {
|
|
200
|
-
...metadata,
|
|
201
|
-
sourceConversationId: context.sendTo.sourceConversationId,
|
|
202
|
-
...(context.sendTo.sourceMessageId
|
|
203
|
-
? { sourceMessageId: context.sendTo.sourceMessageId }
|
|
204
|
-
: {}),
|
|
205
|
-
...(context.sendTo.turnId ? { turnId: context.sendTo.turnId } : {}),
|
|
206
|
-
turnSemantics: context.sendTo.turnSemantics,
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
161
|
if (verb === 'no_reply') {
|
|
213
162
|
const completed = { ...args };
|
|
214
163
|
// messageId is binding-owned (like turnId on interaction verbs): the
|
|
@@ -289,9 +238,6 @@ export function normalizeVerbToolArgs(verb, args, context, now = Date.now()) {
|
|
|
289
238
|
}
|
|
290
239
|
return { args: completed };
|
|
291
240
|
}
|
|
292
|
-
function isRecord(value) {
|
|
293
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
294
|
-
}
|
|
295
241
|
const DEFAULT_POLL_MS = 1000;
|
|
296
242
|
const DEFAULT_MAX_WAIT_MS = 30 * 60 * 1000;
|
|
297
243
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -408,23 +354,6 @@ export async function executeCanonVerbTool(client, verb, args, options = {}) {
|
|
|
408
354
|
return { content: [{ type: 'text', text: `Invalid arguments: ${normalized.error}` }], isError: true };
|
|
409
355
|
}
|
|
410
356
|
args = normalized.args;
|
|
411
|
-
// canonContactId is a contact-card identity, not a wire field: resolve it
|
|
412
|
-
// to a targetUserId here (the two-step the contract documents) so the
|
|
413
|
-
// advertised send_to schema is honest in every binding.
|
|
414
|
-
if (verb === 'send_to' && typeof args.canonContactId === 'string') {
|
|
415
|
-
const resolved = await client.resolveAdmission({ canonContactId: args.canonContactId });
|
|
416
|
-
if (!resolved.resolvedTargetUserId) {
|
|
417
|
-
return {
|
|
418
|
-
content: [{
|
|
419
|
-
type: 'text',
|
|
420
|
-
text: `send_to: unavailable\n${JSON.stringify({ status: 'unavailable', reason: 'canonContactId could not be resolved' }, null, 2)}`,
|
|
421
|
-
}],
|
|
422
|
-
isError: true,
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
const { canonContactId: _resolved, ...rest } = args;
|
|
426
|
-
args = { ...rest, targetUserId: resolved.resolvedTargetUserId };
|
|
427
|
-
}
|
|
428
357
|
const violations = findVerbByteLimitViolations(verb, args);
|
|
429
358
|
if (violations.length > 0) {
|
|
430
359
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Canonical Canon verb tools — shared projections of canon.verbs.v1 for runtime bindings",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@canonmsg/core": "^
|
|
44
|
-
"@canonmsg/rich-cards": "^0.10.
|
|
43
|
+
"@canonmsg/core": "^11.0.0",
|
|
44
|
+
"@canonmsg/rich-cards": "^0.10.3",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { CanonClient } from '@canonmsg/core';
|
|
2
|
-
import { type VerbToolDefinition, type VerbToolResult } from './verb-tools.js';
|
|
3
|
-
export declare const OWNER_BOUND_CANON_COMMUNICATION_VERBS: readonly ["send_to", "list_contacts", "list_contact_requests", "list_conversations", "cancel_contact_request"];
|
|
4
|
-
export type OwnerBoundCanonCommunicationVerb = (typeof OWNER_BOUND_CANON_COMMUNICATION_VERBS)[number];
|
|
5
|
-
export interface OwnerBoundCanonContactTarget {
|
|
6
|
-
targetUserId: string;
|
|
7
|
-
canonContactId?: string;
|
|
8
|
-
sourceCardMessageId?: string;
|
|
9
|
-
}
|
|
10
|
-
/** Trusted per-turn state supplied by a Canon host, never by the model. */
|
|
11
|
-
export interface OwnerBoundCanonTurnContext {
|
|
12
|
-
isOwnerTurn: boolean;
|
|
13
|
-
conversationId: string;
|
|
14
|
-
sourceMessageId: string;
|
|
15
|
-
turnId?: string;
|
|
16
|
-
replyContactTarget?: OwnerBoundCanonContactTarget;
|
|
17
|
-
}
|
|
18
|
-
export type OwnerBoundCanonCommunicationToolNames = Record<OwnerBoundCanonCommunicationVerb, string>;
|
|
19
|
-
export interface OwnerBoundCanonCommunicationBinding {
|
|
20
|
-
tools: ReadonlyArray<VerbToolDefinition>;
|
|
21
|
-
verbForToolName(toolName: string): OwnerBoundCanonCommunicationVerb | null;
|
|
22
|
-
execute(input: {
|
|
23
|
-
client: CanonClient;
|
|
24
|
-
toolName: string;
|
|
25
|
-
arguments: unknown;
|
|
26
|
-
context: OwnerBoundCanonTurnContext | null | undefined;
|
|
27
|
-
}): Promise<VerbToolResult>;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Build the smallest owner-authorized Canon communication surface shared by
|
|
31
|
-
* model hosts. The projection, name mapping, validation, target binding, and
|
|
32
|
-
* trusted source injection live here so integrations cannot drift.
|
|
33
|
-
*/
|
|
34
|
-
export declare function createOwnerBoundCanonCommunicationBinding(options?: {
|
|
35
|
-
toolNames?: Partial<OwnerBoundCanonCommunicationToolNames>;
|
|
36
|
-
}): OwnerBoundCanonCommunicationBinding;
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { canonVerbToolDefinitions, executeCanonVerbTool, } from './verb-tools.js';
|
|
2
|
-
export const OWNER_BOUND_CANON_COMMUNICATION_VERBS = [
|
|
3
|
-
'send_to',
|
|
4
|
-
'list_contacts',
|
|
5
|
-
'list_contact_requests',
|
|
6
|
-
'list_conversations',
|
|
7
|
-
'cancel_contact_request',
|
|
8
|
-
];
|
|
9
|
-
const DEFAULT_TOOL_NAMES = {
|
|
10
|
-
send_to: 'send_to',
|
|
11
|
-
list_contacts: 'list_contacts',
|
|
12
|
-
list_contact_requests: 'list_contact_requests',
|
|
13
|
-
list_conversations: 'list_conversations',
|
|
14
|
-
cancel_contact_request: 'cancel_contact_request',
|
|
15
|
-
};
|
|
16
|
-
const CANONICAL_DEFINITIONS = new Map(canonVerbToolDefinitions().map((definition) => [definition.name, definition]));
|
|
17
|
-
function isRecord(value) {
|
|
18
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
19
|
-
}
|
|
20
|
-
function readNonEmptyString(value) {
|
|
21
|
-
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
22
|
-
}
|
|
23
|
-
function canonicalProperty(verb, property) {
|
|
24
|
-
const properties = CANONICAL_DEFINITIONS.get(verb)?.inputSchema.properties;
|
|
25
|
-
if (!isRecord(properties))
|
|
26
|
-
return {};
|
|
27
|
-
return isRecord(properties[property]) ? properties[property] : {};
|
|
28
|
-
}
|
|
29
|
-
function narrowSendToDefinition(name) {
|
|
30
|
-
const canonical = CANONICAL_DEFINITIONS.get('send_to');
|
|
31
|
-
return {
|
|
32
|
-
name,
|
|
33
|
-
description: `${canonical?.description ?? 'Send a Canon message.'} `
|
|
34
|
-
+ 'This owner-bound projection accepts only one contact target and visible message text. '
|
|
35
|
-
+ 'Canon supplies trusted source routing and may replace the target with the contact card '
|
|
36
|
-
+ 'replied to by the owner. requested and pending are final results for this call; do not retry.',
|
|
37
|
-
inputSchema: {
|
|
38
|
-
type: 'object',
|
|
39
|
-
additionalProperties: false,
|
|
40
|
-
properties: {
|
|
41
|
-
targetUserId: {
|
|
42
|
-
...canonicalProperty('send_to', 'targetUserId'),
|
|
43
|
-
description: 'Target Canon user id. Use exactly one target field.',
|
|
44
|
-
},
|
|
45
|
-
canonContactId: {
|
|
46
|
-
...canonicalProperty('send_to', 'canonContactId'),
|
|
47
|
-
description: 'Stable id from a Canon contact card. Use exactly one target field.',
|
|
48
|
-
},
|
|
49
|
-
text: {
|
|
50
|
-
...canonicalProperty('send_to', 'text'),
|
|
51
|
-
minLength: 1,
|
|
52
|
-
description: 'Visible first message to the target.',
|
|
53
|
-
},
|
|
54
|
-
requestMessage: {
|
|
55
|
-
...canonicalProperty('send_to', 'requestMessage'),
|
|
56
|
-
description: 'Optional note shown to the target human owner when approval is required.',
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
required: ['text'],
|
|
60
|
-
oneOf: [
|
|
61
|
-
{ required: ['targetUserId'], not: { required: ['canonContactId'] } },
|
|
62
|
-
{ required: ['canonContactId'], not: { required: ['targetUserId'] } },
|
|
63
|
-
],
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
function parseNarrowSendToArguments(value, boundTarget) {
|
|
68
|
-
if (!isRecord(value))
|
|
69
|
-
return null;
|
|
70
|
-
const allowed = new Set(['targetUserId', 'canonContactId', 'text', 'requestMessage']);
|
|
71
|
-
if (Object.keys(value).some((key) => !allowed.has(key)))
|
|
72
|
-
return null;
|
|
73
|
-
const targetUserId = readNonEmptyString(value.targetUserId);
|
|
74
|
-
const canonContactId = readNonEmptyString(value.canonContactId);
|
|
75
|
-
const text = readNonEmptyString(value.text);
|
|
76
|
-
const requestMessage = readNonEmptyString(value.requestMessage);
|
|
77
|
-
if (!text || (!boundTarget && ((!targetUserId && !canonContactId) || (targetUserId && canonContactId)))) {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
return {
|
|
81
|
-
...(boundTarget?.canonContactId
|
|
82
|
-
? { canonContactId: boundTarget.canonContactId }
|
|
83
|
-
: boundTarget
|
|
84
|
-
? { targetUserId: boundTarget.targetUserId }
|
|
85
|
-
: targetUserId
|
|
86
|
-
? { targetUserId }
|
|
87
|
-
: { canonContactId }),
|
|
88
|
-
text,
|
|
89
|
-
...(requestMessage ? { requestMessage } : {}),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
function invalidResult(message) {
|
|
93
|
-
return {
|
|
94
|
-
content: [{ type: 'text', text: message }],
|
|
95
|
-
isError: true,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Build the smallest owner-authorized Canon communication surface shared by
|
|
100
|
-
* model hosts. The projection, name mapping, validation, target binding, and
|
|
101
|
-
* trusted source injection live here so integrations cannot drift.
|
|
102
|
-
*/
|
|
103
|
-
export function createOwnerBoundCanonCommunicationBinding(options = {}) {
|
|
104
|
-
const toolNames = { ...DEFAULT_TOOL_NAMES, ...options.toolNames };
|
|
105
|
-
const verbByToolName = new Map();
|
|
106
|
-
for (const verb of OWNER_BOUND_CANON_COMMUNICATION_VERBS) {
|
|
107
|
-
const name = toolNames[verb];
|
|
108
|
-
if (!name || verbByToolName.has(name)) {
|
|
109
|
-
throw new Error(`Owner-bound Canon communication tool name is missing or duplicated: ${name}`);
|
|
110
|
-
}
|
|
111
|
-
verbByToolName.set(name, verb);
|
|
112
|
-
}
|
|
113
|
-
const tools = OWNER_BOUND_CANON_COMMUNICATION_VERBS.map((verb) => {
|
|
114
|
-
if (verb === 'send_to')
|
|
115
|
-
return narrowSendToDefinition(toolNames[verb]);
|
|
116
|
-
const canonical = CANONICAL_DEFINITIONS.get(verb);
|
|
117
|
-
if (!canonical)
|
|
118
|
-
throw new Error(`Missing canonical Canon verb definition: ${verb}`);
|
|
119
|
-
return {
|
|
120
|
-
...canonical,
|
|
121
|
-
name: toolNames[verb],
|
|
122
|
-
description: verb === 'cancel_contact_request'
|
|
123
|
-
? `${canonical.description} Only a pending outbound DM request can be cancelled.`
|
|
124
|
-
: canonical.description,
|
|
125
|
-
};
|
|
126
|
-
});
|
|
127
|
-
return {
|
|
128
|
-
tools,
|
|
129
|
-
verbForToolName: (toolName) => verbByToolName.get(toolName) ?? null,
|
|
130
|
-
execute: async ({ client, toolName, arguments: rawArguments, context }) => {
|
|
131
|
-
const verb = verbByToolName.get(toolName);
|
|
132
|
-
if (!verb)
|
|
133
|
-
return invalidResult(`Unsupported Canon communication tool: ${toolName}`);
|
|
134
|
-
if (!context?.isOwnerTurn) {
|
|
135
|
-
return invalidResult('Canon communication tools require an owner-authored foreground turn.');
|
|
136
|
-
}
|
|
137
|
-
if (!context.conversationId || !context.sourceMessageId) {
|
|
138
|
-
return invalidResult('Canon communication tools require trusted source conversation and message context.');
|
|
139
|
-
}
|
|
140
|
-
if (!isRecord(rawArguments))
|
|
141
|
-
return invalidResult(`Invalid ${verb} arguments.`);
|
|
142
|
-
let args = { ...rawArguments };
|
|
143
|
-
let executionContext;
|
|
144
|
-
if (verb === 'send_to') {
|
|
145
|
-
const parsed = parseNarrowSendToArguments(rawArguments, context.replyContactTarget);
|
|
146
|
-
if (!parsed)
|
|
147
|
-
return invalidResult('Invalid send_to arguments.');
|
|
148
|
-
args = parsed;
|
|
149
|
-
executionContext = {
|
|
150
|
-
sendTo: {
|
|
151
|
-
sourceConversationId: context.conversationId,
|
|
152
|
-
sourceMessageId: context.sourceMessageId,
|
|
153
|
-
...(context.turnId ? { turnId: context.turnId } : {}),
|
|
154
|
-
turnSemantics: 'turn_complete',
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return executeCanonVerbTool(client, verb, args, {
|
|
159
|
-
...(executionContext ? { context: executionContext } : {}),
|
|
160
|
-
});
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
}
|