nova-control-mcp-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +95 -0
- package/dist/nova-control-mcp-server.js +277 -0
- package/package.json +45 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Andreas Rozek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# nova-control-mcp-server
|
|
2
|
+
|
|
3
|
+
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server for controlling the [NOVA DIY Artificial Intelligence Robot](https://www.creoqode.com/nova) by Creoqode. It exposes the same servo commands as [nova-control-command](../nova-control-command) as MCP tools, allowing any MCP-capable AI assistant (Claude Desktop, Cursor, …) to control the robot arm directly.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
| requirement | details |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Node.js | v22 or later |
|
|
10
|
+
| NOVA robot | connected via USB serial port |
|
|
11
|
+
| serial permissions | on Linux: `sudo usermod -aG dialout $USER` (re-login required) |
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
npm run build
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The compiled binary lands at `dist/nova-control-mcp-server.js`.
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
### Claude Desktop
|
|
25
|
+
|
|
26
|
+
Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"nova-control": {
|
|
32
|
+
"command": "node",
|
|
33
|
+
"args": [
|
|
34
|
+
"/absolute/path/to/nova-control-mcp-server/dist/nova-control-mcp-server.js",
|
|
35
|
+
"--port", "/dev/ttyACM0",
|
|
36
|
+
"--baud", "115200"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Cursor
|
|
44
|
+
|
|
45
|
+
Add the same block under `mcpServers` in `~/.cursor/mcp.json`.
|
|
46
|
+
|
|
47
|
+
### Command-line options
|
|
48
|
+
|
|
49
|
+
| option | short | default | description |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `--port <path>` | `-p` | *(required)* | serial port path (e.g. `/dev/ttyACM0`, `COM3`) |
|
|
52
|
+
| `--baud <rate>` | `-b` | `9600` | baud rate |
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
The server exposes the following tools. The serial connection is opened lazily on the first tool call and kept alive for the lifetime of the server process.
|
|
57
|
+
|
|
58
|
+
| tool | parameters | description |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `home` | — | send all servos to their home positions |
|
|
61
|
+
| `move` | `shift_to?`, `roll_to?`, `pitch_to?`, `rotate_to?`, `lift_to?` (all `number`, at least one required) | set one or more servo positions atomically |
|
|
62
|
+
| `shift_to` | `deg: number` | shift head forward (>90°) or back (<90°) — s1 |
|
|
63
|
+
| `roll_to` | `deg: number` | roll head clockwise (>90°) or counter-clockwise (<90°) — s2 |
|
|
64
|
+
| `pitch_to` | `deg: number` | pitch head up (>110°) or down (<110°) — s3 |
|
|
65
|
+
| `rotate_to` | `deg: number` | rotate body around Z-axis — s4 |
|
|
66
|
+
| `lift_to` | `deg: number` | lift head on secondary axis, range 20°–150° — s5 |
|
|
67
|
+
| `wait` | `ms: number` | pause for `ms` milliseconds before the next action |
|
|
68
|
+
| `get_state` | — | return current servo positions as a JSON object with keys `s1`–`s5` |
|
|
69
|
+
|
|
70
|
+
### Servo mapping
|
|
71
|
+
|
|
72
|
+
| key | tool suffix | direction |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| s1 | `shift_to` | forward/back |
|
|
75
|
+
| s2 | `roll_to` | clockwise/counter-clockwise |
|
|
76
|
+
| s3 | `pitch_to` | up/down |
|
|
77
|
+
| s4 | `rotate_to` | Z-axis rotation |
|
|
78
|
+
| s5 | `lift_to` | secondary lift axis |
|
|
79
|
+
|
|
80
|
+
## Usage examples
|
|
81
|
+
|
|
82
|
+
Once the server is running inside Claude Desktop you can give natural-language instructions like:
|
|
83
|
+
|
|
84
|
+
- *"Move NOVA's head to look straight up."* → `pitch_to(deg: 130)`
|
|
85
|
+
- *"Rotate the body 45° to the left."* → `rotate_to(deg: 45)`
|
|
86
|
+
- *"Shift to 100°, wait half a second, then return to home."* → `shift_to(100)` + `wait(500)` + `home()`
|
|
87
|
+
- *"What is the current servo state?"* → `get_state()`
|
|
88
|
+
|
|
89
|
+
## Exit behaviour
|
|
90
|
+
|
|
91
|
+
The server exits cleanly on `SIGINT` (Ctrl+C) or `SIGTERM`, closing the serial connection before quitting.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath as e } from "node:url";
|
|
3
|
+
import { parseArgs as t } from "node:util";
|
|
4
|
+
import { Server as n } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport as r } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { CallToolRequestSchema as i, ListToolsRequestSchema as a } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { openNova as o } from "nova-control-node";
|
|
8
|
+
//#region src/nova-control-mcp-server.ts
|
|
9
|
+
function s() {
|
|
10
|
+
try {
|
|
11
|
+
let { values: e } = t({
|
|
12
|
+
args: process.argv.slice(2),
|
|
13
|
+
options: {
|
|
14
|
+
port: {
|
|
15
|
+
type: "string",
|
|
16
|
+
short: "p"
|
|
17
|
+
},
|
|
18
|
+
baud: {
|
|
19
|
+
type: "string",
|
|
20
|
+
short: "b"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
strict: !0,
|
|
24
|
+
allowPositionals: !1
|
|
25
|
+
});
|
|
26
|
+
return e.port ?? (process.stderr.write("nova-control-mcp: --port is required\n"), process.exit(1)), {
|
|
27
|
+
Port: e.port,
|
|
28
|
+
BaudRate: Number(e.baud ?? "9600")
|
|
29
|
+
};
|
|
30
|
+
} catch (e) {
|
|
31
|
+
process.stderr.write(`nova-control-mcp: ${e.message ?? e}\n`), process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
var c = "", l = 9600, u;
|
|
35
|
+
async function d() {
|
|
36
|
+
return u ??= await o(c, l), u;
|
|
37
|
+
}
|
|
38
|
+
function f() {
|
|
39
|
+
u != null && (u.destroy(), u = void 0);
|
|
40
|
+
}
|
|
41
|
+
function p(e, t = 9600) {
|
|
42
|
+
c = e, l = t;
|
|
43
|
+
}
|
|
44
|
+
function m() {
|
|
45
|
+
f(), c = "", l = 9600;
|
|
46
|
+
}
|
|
47
|
+
var h = [
|
|
48
|
+
{
|
|
49
|
+
name: "home",
|
|
50
|
+
description: "send all servos to their home positions",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "move",
|
|
58
|
+
description: "set one or more servo positions atomically — at least one of shift_to, roll_to, pitch_to, rotate_to, lift_to is required",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
shift_to: {
|
|
63
|
+
type: "number",
|
|
64
|
+
description: "shift head forward (>90°) or back (<90°) — s1"
|
|
65
|
+
},
|
|
66
|
+
roll_to: {
|
|
67
|
+
type: "number",
|
|
68
|
+
description: "roll head clockwise (>90°) or counter-clockwise (<90°) — s2"
|
|
69
|
+
},
|
|
70
|
+
pitch_to: {
|
|
71
|
+
type: "number",
|
|
72
|
+
description: "pitch head up (>110°) or down (<110°) — s3"
|
|
73
|
+
},
|
|
74
|
+
rotate_to: {
|
|
75
|
+
type: "number",
|
|
76
|
+
description: "rotate body around Z-axis — s4"
|
|
77
|
+
},
|
|
78
|
+
lift_to: {
|
|
79
|
+
type: "number",
|
|
80
|
+
description: "lift head on secondary axis, range 20°–150° — s5"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "shift_to",
|
|
87
|
+
description: "shift head forward (>90°) or back (<90°) — s1",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: { deg: {
|
|
91
|
+
type: "number",
|
|
92
|
+
description: "target angle in degrees"
|
|
93
|
+
} },
|
|
94
|
+
required: ["deg"]
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "roll_to",
|
|
99
|
+
description: "roll head clockwise (>90°) or counter-clockwise (<90°) — s2",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: { deg: {
|
|
103
|
+
type: "number",
|
|
104
|
+
description: "target angle in degrees"
|
|
105
|
+
} },
|
|
106
|
+
required: ["deg"]
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "pitch_to",
|
|
111
|
+
description: "pitch head up (>110°) or down (<110°) — s3",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: { deg: {
|
|
115
|
+
type: "number",
|
|
116
|
+
description: "target angle in degrees"
|
|
117
|
+
} },
|
|
118
|
+
required: ["deg"]
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "rotate_to",
|
|
123
|
+
description: "rotate body around Z-axis — s4",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: { deg: {
|
|
127
|
+
type: "number",
|
|
128
|
+
description: "target angle in degrees"
|
|
129
|
+
} },
|
|
130
|
+
required: ["deg"]
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "lift_to",
|
|
135
|
+
description: "lift head on secondary axis, range 20°–150° — s5",
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: { deg: {
|
|
139
|
+
type: "number",
|
|
140
|
+
description: "target angle in degrees"
|
|
141
|
+
} },
|
|
142
|
+
required: ["deg"]
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "wait",
|
|
147
|
+
description: "pause for a given number of milliseconds before the next action",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: { ms: {
|
|
151
|
+
type: "number",
|
|
152
|
+
description: "duration in milliseconds (non-negative)"
|
|
153
|
+
} },
|
|
154
|
+
required: ["ms"]
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "get_state",
|
|
159
|
+
description: "return the current servo positions as a JSON object with keys s1–s5",
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
];
|
|
166
|
+
async function g() {
|
|
167
|
+
return await (await d()).home(), "all servos moved to home positions";
|
|
168
|
+
}
|
|
169
|
+
async function _(e) {
|
|
170
|
+
let t = {};
|
|
171
|
+
if (e.shift_to != null && (t.s1 = Number(e.shift_to)), e.roll_to != null && (t.s2 = Number(e.roll_to)), e.pitch_to != null && (t.s3 = Number(e.pitch_to)), e.rotate_to != null && (t.s4 = Number(e.rotate_to)), e.lift_to != null && (t.s5 = Number(e.lift_to)), Object.keys(t).length === 0) throw Error("move: at least one of shift_to, roll_to, pitch_to, rotate_to, lift_to is required");
|
|
172
|
+
let n = await d();
|
|
173
|
+
return n.State = t, await n.sendServoState(), `servos updated: ${JSON.stringify(t)}`;
|
|
174
|
+
}
|
|
175
|
+
async function v(e) {
|
|
176
|
+
let t = Number(e.deg), n = await d();
|
|
177
|
+
return n.State = { s1: t }, await n.sendServoState(), `s1 (shift) → ${t}°`;
|
|
178
|
+
}
|
|
179
|
+
async function y(e) {
|
|
180
|
+
let t = Number(e.deg), n = await d();
|
|
181
|
+
return n.State = { s2: t }, await n.sendServoState(), `s2 (roll) → ${t}°`;
|
|
182
|
+
}
|
|
183
|
+
async function b(e) {
|
|
184
|
+
let t = Number(e.deg), n = await d();
|
|
185
|
+
return n.State = { s3: t }, await n.sendServoState(), `s3 (pitch) → ${t}°`;
|
|
186
|
+
}
|
|
187
|
+
async function x(e) {
|
|
188
|
+
let t = Number(e.deg), n = await d();
|
|
189
|
+
return n.State = { s4: t }, await n.sendServoState(), `s4 (rotate) → ${t}°`;
|
|
190
|
+
}
|
|
191
|
+
async function S(e) {
|
|
192
|
+
let t = Number(e.deg), n = await d();
|
|
193
|
+
return n.State = { s5: t }, await n.sendServoState(), `s5 (lift) → ${t}°`;
|
|
194
|
+
}
|
|
195
|
+
async function C(e) {
|
|
196
|
+
let t = Number(e.ms);
|
|
197
|
+
if (isNaN(t) || t < 0) throw Error(`wait: invalid duration '${e.ms}' — expected a non-negative number`);
|
|
198
|
+
return await new Promise((e) => setTimeout(e, t)), `waited ${t} ms`;
|
|
199
|
+
}
|
|
200
|
+
async function w() {
|
|
201
|
+
let e = await d();
|
|
202
|
+
return JSON.stringify(e.State);
|
|
203
|
+
}
|
|
204
|
+
function T() {
|
|
205
|
+
let e = new n({
|
|
206
|
+
name: "nova-control-mcp-server",
|
|
207
|
+
version: "0.0.1"
|
|
208
|
+
}, { capabilities: { tools: {} } });
|
|
209
|
+
return e.setRequestHandler(a, async () => ({ tools: h })), e.setRequestHandler(i, async (e) => {
|
|
210
|
+
let t = e.params.name, n = e.params.arguments ?? {};
|
|
211
|
+
try {
|
|
212
|
+
let e;
|
|
213
|
+
switch (t) {
|
|
214
|
+
case "home":
|
|
215
|
+
e = await g();
|
|
216
|
+
break;
|
|
217
|
+
case "move":
|
|
218
|
+
e = await _(n);
|
|
219
|
+
break;
|
|
220
|
+
case "shift_to":
|
|
221
|
+
e = await v(n);
|
|
222
|
+
break;
|
|
223
|
+
case "roll_to":
|
|
224
|
+
e = await y(n);
|
|
225
|
+
break;
|
|
226
|
+
case "pitch_to":
|
|
227
|
+
e = await b(n);
|
|
228
|
+
break;
|
|
229
|
+
case "rotate_to":
|
|
230
|
+
e = await x(n);
|
|
231
|
+
break;
|
|
232
|
+
case "lift_to":
|
|
233
|
+
e = await S(n);
|
|
234
|
+
break;
|
|
235
|
+
case "wait":
|
|
236
|
+
e = await C(n);
|
|
237
|
+
break;
|
|
238
|
+
case "get_state":
|
|
239
|
+
e = await w();
|
|
240
|
+
break;
|
|
241
|
+
default: return {
|
|
242
|
+
content: [{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: `unknown tool: ${t}`
|
|
245
|
+
}],
|
|
246
|
+
isError: !0
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { content: [{
|
|
250
|
+
type: "text",
|
|
251
|
+
text: e
|
|
252
|
+
}] };
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return {
|
|
255
|
+
content: [{
|
|
256
|
+
type: "text",
|
|
257
|
+
text: e instanceof Error ? e.message : String(e)
|
|
258
|
+
}],
|
|
259
|
+
isError: !0
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}), e;
|
|
263
|
+
}
|
|
264
|
+
async function E() {
|
|
265
|
+
let { Port: e, BaudRate: t } = s();
|
|
266
|
+
c = e, l = t;
|
|
267
|
+
let n = T(), i = new r();
|
|
268
|
+
await n.connect(i);
|
|
269
|
+
for (let e of ["SIGINT", "SIGTERM"]) process.on(e, () => {
|
|
270
|
+
f(), process.exit(0);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
process.argv[1] === e(import.meta.url) && E().catch((e) => {
|
|
274
|
+
process.stderr.write(`nova-control-mcp: fatal: ${e.message ?? e}\n`), process.exit(1);
|
|
275
|
+
});
|
|
276
|
+
//#endregion
|
|
277
|
+
export { m as _destroyForTests, p as _setupForTests, T as createServer };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nova-control-mcp-server",
|
|
3
|
+
"description": "MCP server for controlling a NOVA DIY Artificial Intelligence Robot by Creoqode",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"nova",
|
|
8
|
+
"creoqode",
|
|
9
|
+
"robot",
|
|
10
|
+
"mcp",
|
|
11
|
+
"mcp-server"
|
|
12
|
+
],
|
|
13
|
+
"author": "Andreas Rozek",
|
|
14
|
+
"homepage": "https://github.com/rozek/nova-control#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/rozek/nova-control.git",
|
|
18
|
+
"directory": "packages/nova-control-mcp-server"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/rozek/nova-control/issues"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bin": {
|
|
25
|
+
"nova-control-mcp": "./dist/nova-control-mcp-server.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
32
|
+
"nova-control-node": "*"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"vite": "^8.0.0",
|
|
38
|
+
"vitest": "^4.1.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "vite build",
|
|
42
|
+
"test": "vitest",
|
|
43
|
+
"test:run": "vitest run"
|
|
44
|
+
}
|
|
45
|
+
}
|