Files
nanoclaw/container/agent-runner/src/mcp-tools/core.ts
gavrielc e07158e194 fix(agent-runner): preserve thread_id when sending to current channel
send_file and send_message with an explicit `to` parameter were always
setting thread_id to null, causing files and messages to land in the
Discord channel root instead of the thread the session is bound to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:13:42 +03:00

262 lines
9.1 KiB
TypeScript

/**
* Core MCP tools: send_message, send_file, edit_message, add_reaction.
*
* All outbound tools resolve destinations via the local destination map
* (see destinations.ts). Agents reference destinations by name; the map
* translates name → routing tuple. Permission enforcement happens on
* the host side in delivery.ts via the agent_destinations table.
*/
import fs from 'fs';
import path from 'path';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
function destinationList(): string {
const all = getAllDestinations();
if (all.length === 0) return '(none)';
return all.map((d) => d.name).join(', ');
}
/**
* Resolve a destination name to routing fields.
*
* If `to` is omitted, use the session's default reply routing (channel +
* thread the conversation is in) — the agent replies in place.
*
* If `to` is specified, look up the named destination. If it resolves to
* the same channel the session is bound to, the session's thread_id is
* preserved so replies land in the correct thread. Otherwise thread_id
* is null (a cross-destination send starts a new conversation).
*/
function resolveRouting(
to: string | undefined,
):
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
| { error: string } {
if (!to) {
// Default: reply to whatever thread/channel this session is bound to.
const session = getSessionRouting();
if (session.channel_type && session.platform_id) {
return {
channel_type: session.channel_type,
platform_id: session.platform_id,
thread_id: session.thread_id,
resolvedName: '(current conversation)',
};
}
// No session routing (e.g., agent-shared or internal-only agent) —
// fall back to the legacy single-destination shortcut.
const all = getAllDestinations();
if (all.length === 0) return { error: 'No destinations configured.' };
if (all.length > 1) {
return {
error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`,
};
}
to = all[0].name;
}
const dest = findByName(to);
if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` };
if (dest.type === 'channel') {
// If the destination is the same channel the session is bound to,
// preserve the thread_id so replies land in the correct thread.
const session = getSessionRouting();
const threadId =
session.channel_type === dest.channelType && session.platform_id === dest.platformId
? session.thread_id
: null;
return {
channel_type: dest.channelType!,
platform_id: dest.platformId!,
thread_id: threadId,
resolvedName: to,
};
}
return { channel_type: 'agent', platform_id: dest.agentGroupId!, thread_id: null, resolvedName: to };
}
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description:
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
inputSchema: {
type: 'object' as const,
properties: {
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
text: { type: 'string', description: 'Message content' },
},
required: ['text'],
},
},
async handler(args) {
const text = args.text as string;
if (!text) return err('text is required');
const routing = resolveRouting(args.to as string | undefined);
if ('error' in routing) return err(routing.error);
const id = generateId();
const seq = writeMessageOut({
id,
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ text }),
});
log(`send_message: #${seq}${routing.resolvedName}`);
return ok(`Message sent to ${routing.resolvedName} (id: ${seq})`);
},
};
export const sendFile: McpToolDefinition = {
tool: {
name: 'send_file',
description: 'Send a file to a named destination. If you have only one destination, you can omit `to`.',
inputSchema: {
type: 'object' as const,
properties: {
to: { type: 'string', description: 'Destination name. Optional if you have only one destination.' },
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
text: { type: 'string', description: 'Optional accompanying message' },
filename: { type: 'string', description: 'Display name (default: basename of path)' },
},
required: ['path'],
},
},
async handler(args) {
const filePath = args.path as string;
if (!filePath) return err('path is required');
const routing = resolveRouting(args.to as string | undefined);
if ('error' in routing) return err(routing.error);
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
const id = generateId();
const filename = (args.filename as string) || path.basename(resolvedPath);
const outboxDir = path.join('/workspace/outbox', id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
writeMessageOut({
id,
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});
log(`send_file: ${id}${routing.resolvedName} (${filename})`);
return ok(`File sent to ${routing.resolvedName} (id: ${id}, filename: ${filename})`);
},
};
export const editMessage: McpToolDefinition = {
tool: {
name: 'edit_message',
description: 'Edit a previously sent message. Targets the same destination the original message was sent to.',
inputSchema: {
type: 'object' as const,
properties: {
messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' },
text: { type: 'string', description: 'New message content' },
},
required: ['messageId', 'text'],
},
},
async handler(args) {
const seq = Number(args.messageId);
const text = args.text as string;
if (!seq || !text) return err('messageId and text are required');
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
const routing = getRoutingBySeq(seq);
if (!routing || !routing.channel_type || !routing.platform_id) {
return err(`Cannot determine destination for message #${seq}`);
}
const id = generateId();
writeMessageOut({
id,
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ operation: 'edit', messageId: platformId, text }),
});
log(`edit_message: #${seq}${platformId}`);
return ok(`Message edit queued for #${seq}`);
},
};
export const addReaction: McpToolDefinition = {
tool: {
name: 'add_reaction',
description: 'Add an emoji reaction to a message.',
inputSchema: {
type: 'object' as const,
properties: {
messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' },
emoji: { type: 'string', description: 'Emoji name (e.g., thumbs_up, heart, check)' },
},
required: ['messageId', 'emoji'],
},
},
async handler(args) {
const seq = Number(args.messageId);
const emoji = args.emoji as string;
if (!seq || !emoji) return err('messageId and emoji are required');
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
const routing = getRoutingBySeq(seq);
if (!routing || !routing.channel_type || !routing.platform_id) {
return err(`Cannot determine destination for message #${seq}`);
}
const id = generateId();
writeMessageOut({
id,
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }),
});
log(`add_reaction: #${seq}${emoji} on ${platformId}`);
return ok(`Reaction queued for #${seq}`);
},
};
export const coreTools: McpToolDefinition[] = [sendMessage, sendFile, editMessage, addReaction];