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). Backed by SQLite — seeded on startup and re-upserted every second, so it survives restarts and bridge reconnects (the database keeps the newest 1000 rows).${CHANNEL_NOTE}`, }, 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', { description: `Telemetry history, newest first (bounded recent window, last 100): TELEMETRY_APP packets from any node — device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them. kind is the meshtastic.Telemetry oneof case name; data carries that variant's payload (proto uint64 fields as decimal strings). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`, }, async () => jsonResult(mesh.telemetry()), ); server.registerTool( 'device_info', { description: `Device info captured during the initial sync: this device's node number, firmware version and metadata, plus per-section config/module-config (security publicKey/privateKey/adminKey and network wifiPsk redacted as "REDACTED"). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`, }, async () => jsonResult(mesh.deviceInfo()), ); 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 { 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, }), ); } } } }