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
+8 -2
View File
@@ -115,14 +115,20 @@ export async function loadHwModelNames(): Promise<Map<number, string>> {
return hwModelNames;
}
/** Random non-zero uint32 packet id. */
export function randomPacketId(): number {
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
if (id === 0) id = 1; // non-zero uint32
return id;
}
export async function buildTextToRadio(
to: number,
text: string,
channelIndex: number,
): Promise<{ bytes: Uint8Array; id: number }> {
const P = await loadProto();
let id = Math.floor(Math.random() * 0x100000000) >>> 0;
if (id === 0) id = 1; // non-zero uint32
const id = randomPacketId();
const meshPacket = create(P.Mesh.MeshPacketSchema, {
to,
id,
+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 {
+9
View File
@@ -77,6 +77,15 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
async () => jsonResult(mesh.messages()),
);
server.registerTool(
'inject_message',
{
description: `Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh — NOT sent over RF, nothing goes to the bridge or air. Appears in get_messages like any other rx record (channel 0). from is optional (integer in 1..0xffffffff); empty text or invalid from are tool errors.${CHANNEL_NOTE}`,
inputSchema: { text: z.string(), from: z.number().int().optional() },
},
async ({ text, from }) => jsonResult(mesh.injectMessage(text, from)),
);
server.registerTool(
'get_telemetry',
{
+34
View File
@@ -16,6 +16,18 @@ import {
import type { DeviceInfo } from './mesh.service.js';
import { MeshService } from './mesh.service.js';
export class InjectMessageDto {
@ApiProperty({ description: 'Message text', example: 'external trigger' })
text!: string;
@ApiProperty({
required: false,
description:
'Sender node number (integer in 1..0xffffffff); omitted when the external source has no mesh identity.',
example: 987654321,
})
from?: number;
}
export class SendTextDto {
@ApiProperty({ description: 'Message text', example: 'hello' })
text!: string;
@@ -183,6 +195,28 @@ export class MeshController {
return this.mesh.messages();
}
@Post('messages')
@ApiOperation({
summary: 'Inject a synthetic inbound message into the history',
description:
'Synthetic inbox injection for external triggers: records an rx entry in the message history as if received on the mesh, so external systems can push messages to trigger communication later. NOT transmitted over RF — nothing is sent to the bridge or air. The record appears in GET /api/messages like any other rx message (dir "rx", channel 0). Returns { id } with a random uint32 packet id.',
})
@ApiResponse({
status: 200,
description: 'Record injected',
example: { id: 123456789 },
})
@ApiResponse({ status: 400, description: 'Invalid text or from' })
injectMessage(@Body() body: InjectMessageDto) {
try {
return this.mesh.injectMessage(body?.text, body?.from);
} catch (err) {
throw new BadRequestException(
err instanceof Error ? err.message : String(err),
);
}
}
@Get('telemetry')
@ApiOperation({
summary: 'Telemetry history (last 100)',
+88
View File
@@ -561,6 +561,94 @@ describe('MeshService message history', () => {
});
});
describe('MeshService injectMessage', () => {
it('injects rx records that appear newest-first in messages()', () => {
const svc = new MeshService();
expect(svc.messages()).toEqual([]);
const r1 = svc.injectMessage('first');
const r2 = svc.injectMessage('second', KAMIL_NUM);
expect(r1.id).toBeGreaterThan(0); // random non-zero uint32, like tx ids
expect(r2.id).toBeGreaterThan(0);
expect(svc.messages()).toEqual([
{
id: r2.id,
dir: 'rx',
from: KAMIL_NUM,
text: 'second',
channel: 0,
at: expect.any(Number),
},
{
id: r1.id,
dir: 'rx',
text: 'first',
channel: 0,
at: expect.any(Number),
},
]);
});
it('omits from when not provided and rejects empty text or invalid from', () => {
const svc = new MeshService();
const rec = svc.injectMessage('no sender');
expect(svc.messages()[0]).toEqual({
id: rec.id,
dir: 'rx',
text: 'no sender',
channel: 0,
at: expect.any(Number),
});
expect(svc.messages()[0]).not.toHaveProperty('from');
for (const bad of ['', undefined]) {
expect(() => svc.injectMessage(bad as unknown as string)).toThrow(
'text must be a non-empty string',
);
}
for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) {
expect(() => svc.injectMessage('x', bad)).toThrow(/invalid node number/);
}
expect(svc.messages()).toHaveLength(1); // rejected calls add nothing
});
it('caps history at 100 records, dropping the oldest', () => {
const svc = new MeshService();
for (let i = 0; i < 105; i++) svc.injectMessage(`m-${i}`);
expect(svc.messages()).toHaveLength(100);
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
});
it('shares the history buffer with captured packets', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
svc.injectMessage('injected');
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, 'real', 2));
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({ dir: 'rx', text: 'real' }); // newest first
expect(svc.messages()[1]).toMatchObject({
dir: 'rx',
text: 'injected',
channel: 0,
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService telemetry', () => {
it('captures device metrics and environment variants with from/kind/data', async () => {
const bridge = await startBridge();
+26
View File
@@ -23,6 +23,7 @@ import {
extractFrames,
frame,
loadHwModelNames,
randomPacketId,
} from './codec.js';
/** One entry in the message history: an inbound text (dir 'rx') or one of our sends (dir 'tx'). */
@@ -303,6 +304,31 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
return [...this.history].reverse();
}
/**
* Inject a synthetic inbound (rx) record into the message history — as if received on the mesh.
* NOT transmitted over RF: nothing is written to the bridge or air; the record exists so external
* systems can push messages into the bot's inbox to trigger communication later. `from` is optional
* (integer in 1..0xffffffff when provided) and omitted from the record when absent. The record rides
* channel 0 like a primary-channel capture and appears in messages() newest first, subject to the
* same HISTORY_LIMIT cap as real captures. Returns { id } with a random uint32 packet id (same
* generation as tx sends). Throws on empty text or an invalid `from`.
*/
injectMessage(text: string, from?: number): { id: number } {
if (typeof text !== 'string' || text.length === 0)
throw new Error('text must be a non-empty string');
if (from !== undefined) this.assertNodeNum(from);
const rec: MessageRecord = {
id: randomPacketId(),
dir: 'rx',
...(from !== undefined ? { from } : {}),
text,
channel: 0,
at: Date.now(),
};
this.pushHistory(rec);
return { id: rec.id };
}
/**
* Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics
* (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them.