368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
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,
|
|
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<ReturnType<Client['callTool']>>): 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<void>;
|
|
}> {
|
|
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 nine 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',
|
|
'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',
|
|
]);
|
|
} 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('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();
|
|
}
|
|
});
|
|
});
|