MCP server at POST /mcp (official SDK, stateless Streamable HTTP) (#11)
CI / build + unit (push) Successful in 1m6s
CI / build + unit (push) Successful in 1m6s
This commit was merged in pull request #11.
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user