MCP server at POST /mcp (official SDK, stateless Streamable HTTP) (#11)
CI / build + unit (push) Successful in 1m6s

This commit was merged in pull request #11.
This commit is contained in:
2026-09-24 20:39:45 +02:00
parent 108aa400d3
commit b31fb35464
11 changed files with 719 additions and 75 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();
app.setGlobalPrefix('api');
// 'mcp' is excluded so the MCP endpoint lives at /mcp, not /api/mcp (like Scalar at /)
app.setGlobalPrefix('api', { exclude: ['mcp'] });
// Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix.
const docConfig = new DocumentBuilder()
+80
View File
@@ -0,0 +1,80 @@
import { create, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { frame } from './codec.js';
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
export function startBridge(
onSocket?: (s: net.Socket) => void,
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
export async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
/** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */
export interface NodeInfoFields {
user?: {
id?: string;
longName?: string;
shortName?: string;
hwModel?: number;
};
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
lastHeard?: number;
deviceMetrics?: { batteryLevel?: number };
}
export function nodeInfoFrame(
num: number,
fields?: NodeInfoFields,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
+263
View File
@@ -0,0 +1,263 @@
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 {
KAMIL_NUM,
kamilNodeInfo,
nodeInfoFrame,
startBridge,
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 six 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([
'get_messages',
'get_node',
'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',
]);
} 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('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 and get_messages 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
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
} finally {
await mcp.close();
}
} finally {
env.close();
}
});
});
+116
View File
@@ -0,0 +1,116 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import type { Request } from 'express';
import type { ServerResponse } from 'node:http';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { z } from 'zod';
// The SDK's root entry is broken in all published 1.x tarballs (upstream #2273: the exports map points to a dist index that was never shipped) — use the documented subpath imports instead.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import * as pkg from '../../package.json';
import { MeshService } from './mesh.service.js';
const CHANNEL_NOTE =
' All sends ride the pinned "test" channel (index 3) — never the primary channel.';
function jsonResult(value: unknown): {
content: { type: 'text'; text: string }[];
} {
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
}
/** Build an MCP server exposing the MeshService as tools (1:1 with the REST endpoints). */
export function createMeshMcpServer(mesh: MeshService): McpServer {
const server = new McpServer({ name: 'mesh-bot', version: pkg.version });
server.registerTool(
'mesh_status',
{
description: `Bridge connection state: whether the bot is connected to the meshtastic bridge, this device's node number, and the bridge host/port.${CHANNEL_NOTE}`,
},
async () => jsonResult(mesh.status()),
);
server.registerTool(
'send_message',
{
description: `Send a text message over the mesh; returns { id } — the Meshtastic packet id. to: omitted/"" or "!b"/"broadcast" (case-insensitive) → broadcast; number or numeric string → that node number (integer in 1..0xffffffff); anything else is looked up by name (exact long/short name, then unique substring). Unknown or ambiguous targets are an error.${CHANNEL_NOTE}`,
inputSchema: { text: z.string(), to: z.string().optional() },
},
async ({ text, to }) =>
jsonResult({ id: await mesh.sendText(await mesh.resolveDest(to), text) }),
);
server.registerTool(
'list_channels',
{
description: `The device's Meshtastic channels as { index, name, uplinkEnabled, downlinkEnabled }[] sorted by index (psk/keys redacted); empty until the initial sync completes.${CHANNEL_NOTE}`,
},
async () => jsonResult(mesh.channels()),
);
server.registerTool(
'list_nodes',
{
description: `Known mesh nodes as lean summaries sorted by node number; names/hwModel/batteryLevel appear only when the device reported them, lastHeard is epoch seconds (null = never heard), position only when both coordinates are known.${CHANNEL_NOTE}`,
},
async () => jsonResult(await mesh.nodes()),
);
server.registerTool(
'get_node',
{
description: `Look up one node by exact node number (number or numeric string), then case-insensitive exact long/short name, else the unique case-insensitive substring match. Errors when nothing or more than one node matches.${CHANNEL_NOTE}`,
inputSchema: { id: z.string() },
},
async ({ id }) => {
const n = await mesh.node(id);
if (!n) throw new Error(`no node matching '${id}'`);
return jsonResult(n);
},
);
server.registerTool(
'get_messages',
{
description: `Message history, newest first (last 100): inbound text (dir "rx", with from) and our sends (dir "tx", with to + status pending→delivered/failed; broadcasts stay pending because firmware strips want_ack). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
},
async () => jsonResult(mesh.messages()),
);
return server;
}
@Controller()
export class McpController {
constructor(private readonly mesh: MeshService) {}
// MCP Streamable HTTP endpoint (stateless mode): a fresh server + transport per request, outside the /api prefix.
@Post('mcp')
@ApiExcludeEndpoint()
async mcp(@Req() req: Request, @Res() res: ServerResponse): Promise<void> {
const server = createMeshMcpServer(this.mesh);
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
void transport.close();
void server.close();
});
} catch (error) {
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32603, message: String(error) },
id: null,
}),
);
}
}
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { McpController } from './mcp.controller.js';
import { MeshController } from './mesh.controller.js';
import { MeshService } from './mesh.service.js';
@Module({
controllers: [MeshController],
controllers: [MeshController, McpController],
providers: [MeshService],
})
export class MeshModule {}
+8 -64
View File
@@ -1,62 +1,20 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import * as net from 'node:net';
import type { AddressInfo } from 'node:net';
import { describe, expect, it } from 'vitest';
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
import {
KAMIL_NUM,
kamilNodeInfo,
nodeInfoFrame,
startBridge,
waitFor,
} from './fake-bridge.js';
import { MeshService } from './mesh.service.js';
function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
port: number;
sockets: net.Socket[];
close: () => void;
}> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
port: (server.address() as AddressInfo).port,
sockets,
close: () => server.close(),
});
});
});
}
async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now();
while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10));
}
}
function nodeInfoFrame(
num: number,
fields?: Partial<P.Mesh.NodeInfo>,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
// live position update: POSITION_APP packet with a serialized Position in Data.payload
function positionPacketFrame(
from: number,
pos: Partial<P.Mesh.Position>,
pos: { latitudeI?: number; longitudeI?: number },
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.POSITION_APP,
@@ -80,7 +38,6 @@ function positionPacketFrame(
);
}
const KAMIL_NUM = 123456789;
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
@@ -138,19 +95,6 @@ function routingFrame(
),
);
}
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
function channelFrame(
index: number,