mesh: POST /api/respond + MCP respond tool — reply to a message by packet id

This commit is contained in:
2026-09-25 15:32:34 +00:00
parent 88b617f8b7
commit c33f4613b5
6 changed files with 422 additions and 44 deletions
+61 -1
View File
@@ -11,6 +11,7 @@ import {
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
textPacketFrame,
waitFor,
} from './fake-bridge.js';
import { createMeshMcpServer } from './mcp.controller.js';
@@ -67,7 +68,7 @@ async function connectMcp(svc: MeshService): Promise<{
}
describe('MCP server tools', () => {
it('lists all nine tools with descriptions and input schemas', async () => {
it('lists all ten tools with descriptions and input schemas', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
@@ -82,6 +83,7 @@ describe('MCP server tools', () => {
'list_channels',
'list_nodes',
'mesh_status',
'respond',
'send_message',
]);
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
@@ -100,6 +102,13 @@ describe('MCP server tools', () => {
'text',
'from',
]);
const respond = tools.find((t) => t.name === 'respond')!;
expect(respond.inputSchema.required).toEqual(['messageId', 'content']);
expect(Object.keys(respond.inputSchema.properties ?? {})).toEqual([
'messageId',
'content',
]);
} finally {
await mcp.close();
}
@@ -196,6 +205,57 @@ describe('MCP server tools', () => {
}
});
it('respond routes the reply automatically and surfaces failures as tool errors', async () => {
const env = await connectedService();
try {
// rx DM from KAMIL on channel 3 (packet id 1)
env.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'ping', 3));
await waitFor(() => env.svc.messages().length === 1);
const mcp = await connectMcp(env.svc);
try {
const r1 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 1, content: 'pong' },
});
expect(r1.isError).not.toBe(true);
const reply = JSON.parse(toolText(r1)) as { id: number };
expect(reply.id).toBeGreaterThan(0);
// DM back to the original sender on the original channel
expect(env.svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM,
text: 'pong',
channel: 3,
});
const r2 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 999, content: 'x' },
});
expect(r2.isError).toBe(true);
expect(toolText(r2)).toContain('no message with id 999');
const r3 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 0, content: 'x' },
});
expect(r3.isError).toBe(true); // out-of-range rejected before the service sees it
const r4 = await mcp.client.callTool({
name: 'respond',
arguments: { messageId: 1, content: '' },
});
expect(r4.isError).toBe(true);
expect(toolText(r4)).toContain('content must be a non-empty string');
} finally {
await mcp.close();
}
} finally {
env.close();
}
});
it('inject_message records an rx entry without sending over RF', async () => {
const env = await connectedService();
try {