import { fromBinary } from '@bufbuild/protobuf'; import * as P from '@meshtastic/protobufs'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import type * as net from 'node:net'; import { describe, expect, it } from 'vitest'; import { BROADCAST_ADDR, extractFrames } from './codec.js'; import { KAMIL_NUM, kamilNodeInfo, nodeInfoFrame, startBridge, telemetryPacketFrame, textPacketFrame, waitFor, } from './fake-bridge.js'; import { createMeshMcpServer } from './mcp.controller.js'; import { MeshService } from './mesh.service.js'; // callTool returns CallToolResult | a legacy compatibility shape; we only ever get the former type ToolOutcome = { content: { type: string; text?: string }[] }; function toolText(r: Awaited>): string { const c = (r as ToolOutcome).content[0]; if (!c || c.type !== 'text') throw new Error('expected a text content block'); return c.text ?? ''; } async function connectedService(): Promise<{ svc: MeshService; sockets: net.Socket[]; close: () => void; }> { const bridge = await startBridge(); process.env.MESH_HOST = '127.0.0.1'; process.env.MESH_PORT = String(bridge.port); const svc = new MeshService(); svc.onModuleInit(); await waitFor(() => bridge.sockets.length === 1 && svc.status().connected); return { svc, sockets: bridge.sockets, close: () => { svc.onModuleDestroy(); bridge.close(); }, }; } // Drive the MCP server over an in-memory transport pair (no HTTP involved) async function connectMcp(svc: MeshService): Promise<{ client: Client; close: () => Promise; }> { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = createMeshMcpServer(svc); await server.connect(serverTransport); const client = new Client({ name: 'mesh-bot-test', version: '0.0.0' }); await client.connect(clientTransport); return { client, close: async () => { await client.close(); await server.close(); }, }; } describe('MCP server tools', () => { it('lists all ten tools with descriptions and input schemas', async () => { const env = await connectedService(); try { const mcp = await connectMcp(env.svc); try { const { tools } = await mcp.client.listTools(); expect(tools.map((t) => t.name).sort()).toEqual([ 'device_info', 'get_messages', 'get_node', 'get_telemetry', 'inject_message', 'list_channels', 'list_nodes', 'mesh_status', 'respond', 'send_message', ]); for (const t of tools) expect(t.description?.length).toBeGreaterThan(0); const send = tools.find((t) => t.name === 'send_message')!; expect(send.inputSchema.required).toEqual(['text']); expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([ 'text', 'to', 'replyId', ]); const inject = tools.find((t) => t.name === 'inject_message')!; expect(inject.inputSchema.required).toEqual(['text']); expect(Object.keys(inject.inputSchema.properties ?? {})).toEqual([ '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(); } } finally { env.close(); } }); it('mesh_status returns the bridge connection state', async () => { const env = await connectedService(); try { const mcp = await connectMcp(env.svc); try { const r = await mcp.client.callTool({ name: 'mesh_status', arguments: {}, }); expect(r.isError).not.toBe(true); expect(JSON.parse(toolText(r))).toEqual({ connected: true, myNodeNum: null, host: '127.0.0.1', port: Number(process.env.MESH_PORT), }); } finally { await mcp.close(); } } finally { env.close(); } }); it('send_message broadcasts by default and resolves names to node numbers', async () => { const env = await connectedService(); try { env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); await waitFor(async () => (await env.svc.nodes()).length === 1); const mcp = await connectMcp(env.svc); try { const r1 = await mcp.client.callTool({ name: 'send_message', arguments: { text: 'hello' }, }); expect(r1.isError).not.toBe(true); const bcast = JSON.parse(toolText(r1)) as { id: number }; expect(bcast.id).toBeGreaterThan(0); expect(env.svc.messages()[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR, }); // name lookup via the node DB (unique substring) const r2 = await mcp.client.callTool({ name: 'send_message', arguments: { text: 'dm', to: 'kamil' }, }); expect(r2.isError).not.toBe(true); expect(env.svc.messages()[0]).toMatchObject({ dir: 'tx', to: KAMIL_NUM, }); } finally { await mcp.close(); } } finally { env.close(); } }); it('send_message surfaces resolution errors as tool errors with the REST messages', async () => { const env = await connectedService(); try { const mcp = await connectMcp(env.svc); try { const r1 = await mcp.client.callTool({ name: 'send_message', arguments: { text: 'x', to: 'zzz-nope' }, }); expect(r1.isError).toBe(true); expect(toolText(r1)).toContain("no node matching 'zzz-nope'"); const r2 = await mcp.client.callTool({ name: 'send_message', arguments: { text: 'x', to: '0' }, }); expect(r2.isError).toBe(true); expect(toolText(r2)).toContain('invalid node number'); } finally { await mcp.close(); } } finally { env.close(); } }); 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 { const mcp = await connectMcp(env.svc); try { // capture outbound bytes: the only frame ever written on connect is want_config, // so any 'packet' variant after this point would be an RF transmission const out: Uint8Array[] = []; env.sockets[0].on('data', (c) => out.push(c)); const r1 = await mcp.client.callTool({ name: 'inject_message', arguments: { text: 'trigger' }, }); expect(r1.isError).not.toBe(true); const inj = JSON.parse(toolText(r1)) as { id: number }; expect(inj.id).toBeGreaterThan(0); expect(env.svc.messages()[0]).toMatchObject({ dir: 'rx', text: 'trigger', channel: 0, }); const r2 = await mcp.client.callTool({ name: 'inject_message', arguments: { text: 'from kamil', from: KAMIL_NUM }, }); expect(r2.isError).not.toBe(true); expect(env.svc.messages()[0]).toMatchObject({ dir: 'rx', from: KAMIL_NUM, text: 'from kamil', }); const r3 = await mcp.client.callTool({ name: 'inject_message', arguments: { text: 'x', from: 0 }, }); expect(r3.isError).toBe(true); expect(toolText(r3)).toContain('invalid node number'); // nothing went over the wire — synthetic inbox injection only. // The want_config frame (written on connect) may or may not have been captured; // what matters is that no ToRadio 'packet' variant ever made it to the bridge. const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); let off = 0; for (const c of out) { merged.set(c, off); off += c.length; } for (const p of extractFrames(merged).packets) { const tr = fromBinary(P.Mesh.ToRadioSchema, p); expect(tr.payloadVariant?.case).not.toBe('packet'); } } finally { await mcp.close(); } } finally { env.close(); } }); it('get_node resolves by name and errors when missing', async () => { const env = await connectedService(); try { env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); await waitFor(async () => (await env.svc.nodes()).length === 1); const mcp = await connectMcp(env.svc); try { const r1 = await mcp.client.callTool({ name: 'get_node', arguments: { id: 'kamil' }, }); expect(r1.isError).not.toBe(true); expect(JSON.parse(toolText(r1))).toMatchObject({ num: KAMIL_NUM, longName: 'Kamil SP8MK/P', shortName: 'KK', }); const r2 = await mcp.client.callTool({ name: 'get_node', arguments: { id: 'zzz' }, }); expect(r2.isError).toBe(true); expect(toolText(r2)).toContain("no node matching 'zzz'"); } finally { await mcp.close(); } } finally { env.close(); } }); it('list_channels, list_nodes, get_messages and device_info return the service data', async () => { const env = await connectedService(); try { env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo())); await waitFor(async () => (await env.svc.nodes()).length === 1); const mcp = await connectMcp(env.svc); try { expect( JSON.parse( toolText( await mcp.client.callTool({ name: 'list_channels', arguments: {}, }), ), ), ).toEqual([]); // no channel frames fed yet // no metadata/config frames fed yet → empty sections, null node num expect( JSON.parse( toolText( await mcp.client.callTool({ name: 'device_info', arguments: {} }), ), ), ).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} }); const nodes = JSON.parse( toolText( await mcp.client.callTool({ name: 'list_nodes', arguments: {} }), ), ) as { num: number; longName?: string }[]; expect(nodes).toHaveLength(1); expect(nodes[0].num).toBe(KAMIL_NUM); expect(nodes[0].longName).toBe('Kamil SP8MK/P'); await mcp.client.callTool({ name: 'send_message', arguments: { text: 'hi' }, }); const msgs = JSON.parse( toolText( await mcp.client.callTool({ name: 'get_messages', arguments: {} }), ), ) as { dir: string; to?: number }[]; expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first env.sockets[0].write( telemetryPacketFrame(KAMIL_NUM, { case: 'deviceMetrics', value: { batteryLevel: 87 }, }), ); await waitFor(() => env.svc.telemetry().length === 1); const tel = JSON.parse( toolText( await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }), ), ) as { from: number; kind: string; data: object }[]; expect(tel).toHaveLength(1); expect(tel[0]).toMatchObject({ from: KAMIL_NUM, kind: 'deviceMetrics', }); expect(tel[0].data).toEqual({ batteryLevel: 87 }); } finally { await mcp.close(); } } finally { env.close(); } }); });