161 lines
7.3 KiB
TypeScript
161 lines
7.3 KiB
TypeScript
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. replyId (optional, integer in 1..0xffffffff): the packet id of an earlier message from GET /api/messages or get_messages — when set, the recipient app renders this message as a threaded reply/quote of that packet.${CHANNEL_NOTE}`,
|
|
inputSchema: {
|
|
text: z.string(),
|
|
to: z.string().optional(),
|
|
replyId: z.number().int().min(1).max(0xffffffff).optional(),
|
|
},
|
|
},
|
|
async ({ text, to, replyId }) =>
|
|
jsonResult({
|
|
id: await mesh.sendText(await mesh.resolveDest(to), text, replyId),
|
|
}),
|
|
);
|
|
|
|
server.registerTool(
|
|
'respond',
|
|
{
|
|
description: `Reply to an earlier message by its packet id (from get_messages); returns { id } — the Meshtastic packet id of the reply. Routing is automatic: rx records are answered as a DM back to the original sender, tx records go to their destination; broadcast originals and injected messages without a sender get a broadcast. The reply always rides the record's channel and carries replyId = messageId (threaded quote in the recipient app). Invalid messageId/content or an unknown id are tool errors.`,
|
|
inputSchema: {
|
|
messageId: z.number().int().min(1).max(0xffffffff),
|
|
content: z.string(),
|
|
},
|
|
},
|
|
async ({ messageId, content }) =>
|
|
jsonResult(await mesh.respond(messageId, content)),
|
|
);
|
|
|
|
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<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,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|