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:
@@ -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).
|
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"?: <node number | node name | "!b" | "broadcast"> }` — 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`).
|
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }` — 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
|
## Other
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -53,6 +53,33 @@ pending because firmware strips want_ack before TX.
|
|||||||
All sends always target channel index 3 (`test`) — never the primary
|
All sends always target channel index 3 (`test`) — never the primary
|
||||||
channel. Override with `MESH_SEND_CHANNEL`.
|
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
|
## Env vars
|
||||||
|
|
||||||
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
- `MESH_HOST` (default `192.168.1.4`) — bridge host
|
||||||
|
|||||||
Generated
+211
-8
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.0.1",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.0.1",
|
"version": "0.5.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.15.0",
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
"@meshtastic/protobufs": "^2.8.0",
|
"@meshtastic/protobufs": "^2.8.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.0.1",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
@@ -221,6 +222,18 @@
|
|||||||
"node": ">=0.1.90"
|
"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": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "2.0.8",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz",
|
||||||
@@ -617,6 +630,46 @@
|
|||||||
"integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==",
|
"integrity": "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "12.0.5",
|
"version": "12.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.5.tgz",
|
||||||
@@ -2179,7 +2232,6 @@
|
|||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -2196,7 +2248,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.0.0"
|
"ajv": "^8.0.0"
|
||||||
@@ -2764,6 +2815,20 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2991,6 +3056,27 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
@@ -3044,6 +3130,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/external-editor": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
||||||
@@ -3083,7 +3188,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-safe-stringify": {
|
"node_modules/fast-safe-stringify": {
|
||||||
@@ -3113,7 +3217,6 @@
|
|||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3428,6 +3531,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -3618,6 +3730,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -3669,6 +3790,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
@@ -3717,6 +3844,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
@@ -3750,9 +3886,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -4658,6 +4799,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"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"
|
"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": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@@ -4850,7 +5009,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"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==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5037,6 +5195,27 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
@@ -5866,6 +6045,21 @@
|
|||||||
"defaults": "^1.0.3"
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -5925,6 +6119,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.15.0",
|
"@bufbuild/protobuf": "^2.15.0",
|
||||||
"@meshtastic/protobufs": "^2.8.0",
|
"@meshtastic/protobufs": "^2.8.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.0.1",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.0.1",
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@ import { AppModule } from './app.module.js';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.enableShutdownHooks();
|
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.
|
// Scalar API docs at / (OpenAPI JSON at /docs-json), outside the api prefix.
|
||||||
const docConfig = new DocumentBuilder()
|
const docConfig = new DocumentBuilder()
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { McpController } from './mcp.controller.js';
|
||||||
import { MeshController } from './mesh.controller.js';
|
import { MeshController } from './mesh.controller.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [MeshController],
|
controllers: [MeshController, McpController],
|
||||||
providers: [MeshService],
|
providers: [MeshService],
|
||||||
})
|
})
|
||||||
export class MeshModule {}
|
export class MeshModule {}
|
||||||
|
|||||||
@@ -1,62 +1,20 @@
|
|||||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||||
import * as P from '@meshtastic/protobufs';
|
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 { describe, expect, it } from 'vitest';
|
||||||
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
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';
|
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
|
// live position update: POSITION_APP packet with a serialized Position in Data.payload
|
||||||
function positionPacketFrame(
|
function positionPacketFrame(
|
||||||
from: number,
|
from: number,
|
||||||
pos: Partial<P.Mesh.Position>,
|
pos: { latitudeI?: number; longitudeI?: number },
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const data = create(P.Mesh.DataSchema, {
|
const data = create(P.Mesh.DataSchema, {
|
||||||
portnum: P.Portnums.PortNum.POSITION_APP,
|
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
|
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
|
||||||
function textPacketFrame(
|
function textPacketFrame(
|
||||||
from: number,
|
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(
|
function channelFrame(
|
||||||
index: number,
|
index: number,
|
||||||
|
|||||||
Reference in New Issue
Block a user