From b31fb354646ca4e00bf0e442ccf2f6d5cc5014bf Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 24 Sep 2026 20:39:45 +0200 Subject: [PATCH] MCP server at POST /mcp (official SDK, stateless Streamable HTTP) (#11) --- AGENTS.md | 1 + CHANGELOG.md | 7 + README.md | 27 ++++ package-lock.json | 219 +++++++++++++++++++++++++- package.json | 3 +- src/main.ts | 3 +- src/mesh/fake-bridge.ts | 80 ++++++++++ src/mesh/mcp.controller.spec.ts | 263 ++++++++++++++++++++++++++++++++ src/mesh/mcp.controller.ts | 116 ++++++++++++++ src/mesh/mesh.module.ts | 3 +- src/mesh/mesh.service.spec.ts | 72 +-------- 11 files changed, 719 insertions(+), 75 deletions(-) create mode 100644 src/mesh/fake-bridge.ts create mode 100644 src/mesh/mcp.controller.spec.ts create mode 100644 src/mesh/mcp.controller.ts diff --git a/AGENTS.md b/AGENTS.md index 808e4a6..d19d8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). 2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`). +3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`. Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.). ## Other diff --git a/CHANGELOG.md b/CHANGELOG.md index 26da020..e3b7d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.6.0 — 2026-09-24 18:28 + +- New MCP (Model Context Protocol) server endpoint at `POST /mcp` (outside the `/api` prefix, like Scalar at `/`), served in-process via the official `@modelcontextprotocol/sdk` Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`; a fresh `McpServer` + transport per request). Six tools map 1:1 to existing `MeshService` methods with no new logic: `mesh_status`, `send_message(text, to?)` (same target resolution as `POST /api/send` — omitted/""/"!b"/"broadcast" → broadcast, numbers pass through, names via the node DB; resolution errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), and `get_messages`. Tool results are JSON in a text content block. +- New dependency: `@modelcontextprotocol/sdk` (^1.30.1; zod comes with it). Note: the SDK's root entry is broken in all published 1.x tarballs (upstream issue #2273 — the exports map points to a dist index that was never shipped), so imports use the documented subpaths (`@modelcontextprotocol/sdk/server/mcp.js`, `.../server/streamableHttp.js`, `.../inMemory.js`, `.../client/index.js`). +- Test helpers shared by both specs extracted from `mesh.service.spec.ts` into `src/mesh/fake-bridge.ts` (fake bridge, waitFor, nodeInfoFrame, KAMIL fixtures); new `mcp.controller.spec.ts` drives the MCP server over an in-memory transport pair — all six tools listed with descriptions/schemas, and mesh_status / send_message (broadcast + name resolution + error messages) / get_node / list_channels / list_nodes / get_messages return correct shapes. +- README gains an MCP section; AGENTS.md endpoint list updated for `/mcp`. + ## v0.5.0 — 2026-09-24 17:37 - Publishing channel moved from `testowy` (index 2) to the new `test` channel (index 3); `MESH_SEND_CHANNEL` default is now `3`. All docs/comments referencing the old pinning updated accordingly. diff --git a/README.md b/README.md index 4da8da3..29d6784 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,33 @@ pending because firmware strips want_ack before TX. All sends always target channel index 3 (`test`) — never the primary channel. Override with `MESH_SEND_CHANNEL`. +## MCP server + +The bot also exposes an MCP (Model Context Protocol) endpoint at +`POST /mcp` (outside the `/api` prefix), served in-process over the SDK's +Streamable HTTP transport in stateless mode. It maps 1:1 to the REST +endpoints — no new logic, same channel pinning: + +- `mesh_status` — bridge connection state (`{ connected, myNodeNum, host, port }`) +- `send_message(text, to?)` — send a text message; `to` resolves like `POST /api/send` (omitted/""/"!b"/"broadcast" → broadcast, number or numeric string → node number, else name lookup); returns `{ id }`; unknown/ambiguous targets are tool errors +- `list_channels` — device channels (keys redacted) +- `list_nodes` — known nodes as lean summaries +- `get_node(id)` — one node by number or name; tool error when missing/ambiguous +- `get_messages` — message history, newest first (last 100, tx records carry delivery status) + +Point any MCP client at it (Streamable HTTP URL): + +```json +{ "mcpServers": { "mesh-bot": { "url": "http://bot.dev.kkhost.pl/mcp" } } } +``` + +```sh +curl -X POST http://localhost:3000/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + ## Env vars - `MESH_HOST` (default `192.168.1.4`) — bridge host diff --git a/package-lock.json b/package-lock.json index 6e48030..6c23afa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "mesh-bot", - "version": "0.0.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mesh-bot", - "version": "0.0.1", + "version": "0.5.0", "license": "UNLICENSED", "dependencies": { "@bufbuild/protobuf": "^2.15.0", "@meshtastic/protobufs": "^2.8.0", + "@modelcontextprotocol/sdk": "^1.30.1", "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-express": "^12.0.1", @@ -221,6 +222,18 @@ "node": ">=0.1.90" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", @@ -617,6 +630,46 @@ "integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@nestjs/cli": { "version": "12.0.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz", @@ -2179,7 +2232,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2196,7 +2248,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -2764,6 +2815,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2991,6 +3056,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3044,6 +3130,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -3083,7 +3188,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-safe-stringify": { @@ -3113,7 +3217,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, "funding": [ { "type": "github", @@ -3428,6 +3531,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3618,6 +3730,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3669,6 +3790,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3717,6 +3844,15 @@ "node": ">=6" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -3750,9 +3886,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4658,6 +4799,15 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -4695,6 +4845,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -4850,7 +5009,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5037,6 +5195,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -5866,6 +6045,21 @@ "defaults": "^1.0.3" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5925,6 +6119,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index d5bf830..bb36def 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.5.0", + "version": "0.6.0", "description": "", "author": "", "private": true, @@ -23,6 +23,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.15.0", "@meshtastic/protobufs": "^2.8.0", + "@modelcontextprotocol/sdk": "^1.30.1", "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-express": "^12.0.1", diff --git a/src/main.ts b/src/main.ts index efe0f44..0195857 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,8 @@ import { AppModule } from './app.module.js'; async function bootstrap() { const app = await NestFactory.create(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() diff --git a/src/mesh/fake-bridge.ts b/src/mesh/fake-bridge.ts new file mode 100644 index 0000000..7de22b4 --- /dev/null +++ b/src/mesh/fake-bridge.ts @@ -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, + timeoutMs = 5000, +): Promise { + 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 }, + }; +} diff --git a/src/mesh/mcp.controller.spec.ts b/src/mesh/mcp.controller.spec.ts new file mode 100644 index 0000000..714cf11 --- /dev/null +++ b/src/mesh/mcp.controller.spec.ts @@ -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>): 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; +}> { + 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(); + } + }); +}); diff --git a/src/mesh/mcp.controller.ts b/src/mesh/mcp.controller.ts new file mode 100644 index 0000000..4c79684 --- /dev/null +++ b/src/mesh/mcp.controller.ts @@ -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 { + 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, + }), + ); + } + } + } +} diff --git a/src/mesh/mesh.module.ts b/src/mesh/mesh.module.ts index 5902fd3..85999d2 100644 --- a/src/mesh/mesh.module.ts +++ b/src/mesh/mesh.module.ts @@ -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 {} diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index be99a8c..4e5051a 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -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, - timeoutMs = 5000, -): Promise { - 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, -): 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, + 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 { - 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,