Inbox injection endpoint: POST /api/messages + MCP inject_message (#12) (#18)
CI / build + unit (push) Successful in 1m14s

This commit was merged in pull request #18.
This commit is contained in:
2026-09-25 01:51:50 +02:00
parent 0571c7095e
commit 756086d107
10 changed files with 253 additions and 7 deletions
+74 -2
View File
@@ -1,8 +1,10 @@
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 } from './codec.js';
import { BROADCAST_ADDR, extractFrames } from './codec.js';
import {
KAMIL_NUM,
kamilNodeInfo,
@@ -65,7 +67,7 @@ async function connectMcp(svc: MeshService): Promise<{
}
describe('MCP server tools', () => {
it('lists all eight tools with descriptions and input schemas', async () => {
it('lists all nine tools with descriptions and input schemas', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
@@ -76,6 +78,7 @@ describe('MCP server tools', () => {
'get_messages',
'get_node',
'get_telemetry',
'inject_message',
'list_channels',
'list_nodes',
'mesh_status',
@@ -89,6 +92,13 @@ describe('MCP server tools', () => {
'text',
'to',
]);
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();
}
@@ -185,6 +195,68 @@ describe('MCP server tools', () => {
}
});
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 {