nova-control-mcp-server 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nova-control-mcp-server
2
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.
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/README.md) as MCP tools, allowing any MCP-capable AI assistant (Claude Desktop, Cursor, …) to control the robot arm directly.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -67,6 +67,49 @@ Add the same block under `mcpServers` in `~/.cursor/mcp.json`.
67
67
  |---|---|---|---|
68
68
  | `--port <path>` | `-p` | *(required)* | serial port path (e.g. `/dev/ttyACM0`, `COM3`) |
69
69
  | `--baud <rate>` | `-b` | `9600` | baud rate |
70
+ | `--transport <mode>` | `-t` | `stdio` | transport: `stdio` or `http` |
71
+ | `--listen <port>` | `-l` | `3000` | HTTP listen port (only used when `--transport http`) |
72
+
73
+ ## HTTP transport (Docker / remote)
74
+
75
+ Use `--transport http` to expose the MCP server over HTTP instead of stdio. This is the recommended mode when the server runs in a Docker container or on a remote machine.
76
+
77
+ The server listens on the given port and accepts MCP requests at `POST /mcp`.
78
+
79
+ ```bash
80
+ nova-control-mcp-server --port /dev/ttyACM0 --transport http --listen 3000
81
+ ```
82
+
83
+ ### Docker example
84
+
85
+ ```dockerfile
86
+ FROM node:22-slim
87
+ RUN npm install -g nova-control-mcp-server
88
+ EXPOSE 3000
89
+ CMD ["nova-control-mcp-server", "--port", "/dev/ttyACM0", \
90
+ "--transport", "http", "--listen", "3000"]
91
+ ```
92
+
93
+ Run the container with the serial device passed through:
94
+
95
+ ```bash
96
+ docker run --device /dev/ttyACM0 -p 3000:3000 nova-mcp
97
+ ```
98
+
99
+ ### Connecting an MCP client to the HTTP transport
100
+
101
+ Point your MCP client at `http://<host>:3000/mcp`. For Claude Desktop with a remote server, use the `url` transport type:
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "nova-control": {
107
+ "type": "http",
108
+ "url": "http://localhost:3000/mcp"
109
+ }
110
+ }
111
+ }
112
+ ```
70
113
 
71
114
  ## Tools
72
115
 
@@ -76,6 +119,7 @@ The server exposes the following tools. The serial connection is opened lazily o
76
119
  |---|---|---|
77
120
  | `home` | — | send all servos to their home positions |
78
121
  | `move` | `shift_to?`, `roll_to?`, `pitch_to?`, `rotate_to?`, `lift_to?` (all `number`, at least one required) | set one or more servo positions atomically |
122
+ | `move_to` | `within_ms: number` (required), `s1?`, `s2?`, `s3?`, `s4?`, `s5?` (all optional `number`) | move one or more servos to target positions in exactly `within_ms` milliseconds, using a trapezoidal ramp-up/ramp-down profile |
79
123
  | `shift_to` | `deg: number` | shift head forward (>90°) or back (<90°) — s1 |
80
124
  | `roll_to` | `deg: number` | roll head clockwise (>90°) or counter-clockwise (<90°) — s2 |
81
125
  | `pitch_to` | `deg: number` | pitch head up (>110°) or down (<110°) — s3 |
@@ -83,6 +127,7 @@ The server exposes the following tools. The serial connection is opened lazily o
83
127
  | `lift_to` | `deg: number` | lift head on secondary axis, range 20°–150° — s5 |
84
128
  | `wait` | `ms: number` | pause for `ms` milliseconds before the next action |
85
129
  | `get_state` | — | return current servo positions as a JSON object with keys `s1`–`s5` |
130
+ | `run_script` | `script: string` | execute a multi-line movement script (one command per line; blank lines and `#`-comments ignored; commands: `home`, `shift-to`, `roll-to`, `pitch-to`, `rotate-to`, `lift-to`, `move`, `wait`) |
86
131
 
87
132
  ### Servo mapping
88
133
 
@@ -99,14 +144,24 @@ The server exposes the following tools. The serial connection is opened lazily o
99
144
  Once the server is running inside Claude Desktop you can give natural-language instructions like:
100
145
 
101
146
  - *"Move NOVA's head to look straight up."* → `pitch_to(deg: 130)`
102
- - *"Rotate the body 45° to the left."* → `rotate_to(deg: 45)`
147
+ - *"Rotate the body 45° to the right."* → `rotate_to(deg: 45)`
148
+ - *"Move the head to 120° over 800 ms."* → `move_to(within_ms: 800, s1: 120)`
103
149
  - *"Shift to 100°, wait half a second, then return to home."* → `shift_to(100)` + `wait(500)` + `home()`
104
150
  - *"What is the current servo state?"* → `get_state()`
151
+ - *"Run the greeting sequence from this script."* → `run_script(script: "home\nwait 500\nshift-to 110\nwait 400\nhome")`
105
152
 
106
153
  ## Exit behaviour
107
154
 
108
155
  The server exits cleanly on `SIGINT` (Ctrl+C) or `SIGTERM`, closing the serial connection before quitting.
109
156
 
157
+ ## Related packages
158
+
159
+ | package | description |
160
+ |---|---|
161
+ | [`nova-control-browser`](../nova-control-browser/README.md) | browser ESM module — Web Serial API (Chrome / Edge 89+) |
162
+ | [`nova-control-node`](../nova-control-node/README.md) | Node.js ESM module — `serialport` package (used internally by this server) |
163
+ | [`nova-control-command`](../nova-control-command/README.md) | CLI — one-shot commands, interactive REPL, and script files |
164
+
110
165
  ## License
111
166
 
112
167
  MIT
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath as e } from "node:url";
3
3
  import { realpathSync as t } from "node:fs";
4
- import { parseArgs as n } from "node:util";
5
- import { Server as r } from "@modelcontextprotocol/sdk/server/index.js";
6
- import { StdioServerTransport as i } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { CallToolRequestSchema as a, ListToolsRequestSchema as o } from "@modelcontextprotocol/sdk/types.js";
8
- import { openNova as s } from "nova-control-node";
4
+ import { createServer as n } from "node:http";
5
+ import { parseArgs as r } from "node:util";
6
+ import { Server as i } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport as a } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { StreamableHTTPServerTransport as o } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
+ import { CallToolRequestSchema as s, ListToolsRequestSchema as c } from "@modelcontextprotocol/sdk/types.js";
10
+ import { openNova as l, runScript as u } from "nova-control-node";
9
11
  //#region src/nova-control-mcp-server.ts
10
- function c() {
12
+ function d() {
11
13
  try {
12
- let { values: e } = n({
14
+ let { values: e } = r({
13
15
  args: process.argv.slice(2),
14
16
  options: {
15
17
  port: {
@@ -19,33 +21,45 @@ function c() {
19
21
  baud: {
20
22
  type: "string",
21
23
  short: "b"
24
+ },
25
+ transport: {
26
+ type: "string",
27
+ short: "t"
28
+ },
29
+ listen: {
30
+ type: "string",
31
+ short: "l"
22
32
  }
23
33
  },
24
34
  strict: !0,
25
35
  allowPositionals: !1
26
36
  });
27
- return e.port ?? (process.stderr.write("nova-control-mcp: --port is required\n"), process.exit(1)), {
37
+ e.port ?? (process.stderr.write("nova-control-mcp: --port is required\n"), process.exit(1));
38
+ let t = e.transport ?? "stdio";
39
+ return t !== "stdio" && t !== "http" && (process.stderr.write(`nova-control-mcp: --transport must be 'stdio' or 'http', got '${t}'\n`), process.exit(1)), {
28
40
  Port: e.port,
29
- BaudRate: Number(e.baud ?? "9600")
41
+ BaudRate: Number(e.baud ?? "9600"),
42
+ Transport: t,
43
+ ListenPort: Number(e.listen ?? "3000")
30
44
  };
31
45
  } catch (e) {
32
46
  process.stderr.write(`nova-control-mcp: ${e.message ?? e}\n`), process.exit(1);
33
47
  }
34
48
  }
35
- var l = "", u = 9600, d;
36
- async function f() {
37
- return d ??= await s(l, u), d;
49
+ var f = "", p = 9600, m;
50
+ async function h() {
51
+ return m ??= await l(f, p), m;
38
52
  }
39
- function p() {
40
- d != null && (d.destroy(), d = void 0);
53
+ function g() {
54
+ m != null && (m.destroy(), m = void 0);
41
55
  }
42
- function m(e, t = 9600) {
43
- l = e, u = t;
56
+ function _(e, t = 9600) {
57
+ f = e, p = t;
44
58
  }
45
- function h() {
46
- p(), l = "", u = 9600;
59
+ function v() {
60
+ g(), f = "", p = 9600;
47
61
  }
48
- var g = [
62
+ var y = [
49
63
  {
50
64
  name: "home",
51
65
  description: "send all servos to their home positions",
@@ -88,11 +102,11 @@ var g = [
88
102
  description: "shift head forward (>90°) or back (<90°) — s1",
89
103
  inputSchema: {
90
104
  type: "object",
91
- properties: { deg: {
105
+ properties: { angle: {
92
106
  type: "number",
93
107
  description: "target angle in degrees"
94
108
  } },
95
- required: ["deg"]
109
+ required: ["angle"]
96
110
  }
97
111
  },
98
112
  {
@@ -100,11 +114,11 @@ var g = [
100
114
  description: "roll head clockwise (>90°) or counter-clockwise (<90°) — s2",
101
115
  inputSchema: {
102
116
  type: "object",
103
- properties: { deg: {
117
+ properties: { angle: {
104
118
  type: "number",
105
119
  description: "target angle in degrees"
106
120
  } },
107
- required: ["deg"]
121
+ required: ["angle"]
108
122
  }
109
123
  },
110
124
  {
@@ -112,11 +126,11 @@ var g = [
112
126
  description: "pitch head up (>110°) or down (<110°) — s3",
113
127
  inputSchema: {
114
128
  type: "object",
115
- properties: { deg: {
129
+ properties: { angle: {
116
130
  type: "number",
117
131
  description: "target angle in degrees"
118
132
  } },
119
- required: ["deg"]
133
+ required: ["angle"]
120
134
  }
121
135
  },
122
136
  {
@@ -124,11 +138,11 @@ var g = [
124
138
  description: "rotate body around Z-axis — s4",
125
139
  inputSchema: {
126
140
  type: "object",
127
- properties: { deg: {
141
+ properties: { angle: {
128
142
  type: "number",
129
143
  description: "target angle in degrees"
130
144
  } },
131
- required: ["deg"]
145
+ required: ["angle"]
132
146
  }
133
147
  },
134
148
  {
@@ -136,11 +150,45 @@ var g = [
136
150
  description: "lift head on secondary axis, range 20°–150° — s5",
137
151
  inputSchema: {
138
152
  type: "object",
139
- properties: { deg: {
153
+ properties: { angle: {
140
154
  type: "number",
141
155
  description: "target angle in degrees"
142
156
  } },
143
- required: ["deg"]
157
+ required: ["angle"]
158
+ }
159
+ },
160
+ {
161
+ name: "move_to",
162
+ description: "move one or more servos smoothly to their target positions, completing the movement in the specified number of milliseconds using a trapezoidal ramp-up/ramp-down profile",
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ within_ms: {
167
+ type: "number",
168
+ description: "total movement duration in milliseconds (must be > 0)"
169
+ },
170
+ s1: {
171
+ type: "number",
172
+ description: "head shift target angle (optional)"
173
+ },
174
+ s2: {
175
+ type: "number",
176
+ description: "head roll target angle (optional)"
177
+ },
178
+ s3: {
179
+ type: "number",
180
+ description: "head pitch target angle (optional)"
181
+ },
182
+ s4: {
183
+ type: "number",
184
+ description: "body rotate target angle (optional)"
185
+ },
186
+ s5: {
187
+ type: "number",
188
+ description: "head lift target angle (optional)"
189
+ }
190
+ },
191
+ required: ["within_ms"]
144
192
  }
145
193
  },
146
194
  {
@@ -162,82 +210,111 @@ var g = [
162
210
  type: "object",
163
211
  properties: {}
164
212
  }
213
+ },
214
+ {
215
+ name: "run_script",
216
+ description: "execute a multi-line movement script — one command per line; blank lines and lines starting with # are ignored; commands: home | shift-to <deg> | roll-to <deg> | pitch-to <deg> | rotate-to <deg> | lift-to <deg> | move [shift-to <deg>] [roll-to <deg>] [pitch-to <deg>] [rotate-to <deg>] [lift-to <deg>] | wait <ms>",
217
+ inputSchema: {
218
+ type: "object",
219
+ properties: { script: {
220
+ type: "string",
221
+ description: "multi-line movement script"
222
+ } },
223
+ required: ["script"]
224
+ }
165
225
  }
166
226
  ];
167
- async function _() {
168
- return await (await f()).home(), "all servos moved to home positions";
227
+ async function b() {
228
+ return await (await h()).home(), "all servos moved to home positions";
169
229
  }
170
- async function v(e) {
230
+ async function x(e) {
171
231
  let t = {};
172
232
  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");
173
- let n = await f();
233
+ let n = await h();
174
234
  return n.State = t, await n.sendServoState(), `servos updated: ${JSON.stringify(t)}`;
175
235
  }
176
- async function y(e) {
177
- let t = Number(e.deg), n = await f();
236
+ async function S(e) {
237
+ let t = Number(e.angle), n = await h();
178
238
  return n.State = { s1: t }, await n.sendServoState(), `s1 (shift) → ${t}°`;
179
239
  }
180
- async function b(e) {
181
- let t = Number(e.deg), n = await f();
240
+ async function C(e) {
241
+ let t = Number(e.angle), n = await h();
182
242
  return n.State = { s2: t }, await n.sendServoState(), `s2 (roll) → ${t}°`;
183
243
  }
184
- async function x(e) {
185
- let t = Number(e.deg), n = await f();
244
+ async function w(e) {
245
+ let t = Number(e.angle), n = await h();
186
246
  return n.State = { s3: t }, await n.sendServoState(), `s3 (pitch) → ${t}°`;
187
247
  }
188
- async function S(e) {
189
- let t = Number(e.deg), n = await f();
248
+ async function T(e) {
249
+ let t = Number(e.angle), n = await h();
190
250
  return n.State = { s4: t }, await n.sendServoState(), `s4 (rotate) → ${t}°`;
191
251
  }
192
- async function C(e) {
193
- let t = Number(e.deg), n = await f();
252
+ async function E(e) {
253
+ let t = Number(e.angle), n = await h();
194
254
  return n.State = { s5: t }, await n.sendServoState(), `s5 (lift) → ${t}°`;
195
255
  }
196
- async function w(e) {
256
+ async function D(e) {
257
+ let t = Number(e.within_ms);
258
+ if (isNaN(t) || t <= 0) throw Error("move_to: within_ms must be a positive number");
259
+ let n = {};
260
+ if (e.s1 != null && (n.s1 = Number(e.s1)), e.s2 != null && (n.s2 = Number(e.s2)), e.s3 != null && (n.s3 = Number(e.s3)), e.s4 != null && (n.s4 = Number(e.s4)), e.s5 != null && (n.s5 = Number(e.s5)), Object.keys(n).length === 0) throw Error("move_to: at least one servo target (s1–s5) must be specified");
261
+ return await (await h()).moveTo(n, t), "move completed";
262
+ }
263
+ async function O(e) {
197
264
  let t = Number(e.ms);
198
265
  if (isNaN(t) || t < 0) throw Error(`wait: invalid duration '${e.ms}' — expected a non-negative number`);
199
266
  return await new Promise((e) => setTimeout(e, t)), `waited ${t} ms`;
200
267
  }
201
- async function T() {
202
- let e = await f();
268
+ async function k() {
269
+ let e = await h();
203
270
  return JSON.stringify(e.State);
204
271
  }
205
- function E() {
206
- let e = new r({
272
+ async function A(e) {
273
+ let t = String(e.script ?? "");
274
+ return await u(await h(), t), "script executed successfully";
275
+ }
276
+ function j() {
277
+ let e = new i({
207
278
  name: "nova-control-mcp-server",
208
- version: "0.0.1"
279
+ version: "0.0.5"
209
280
  }, { capabilities: { tools: {} } });
210
- return e.setRequestHandler(o, async () => ({ tools: g })), e.setRequestHandler(a, async (e) => {
281
+ return e.setRequestHandler(c, async () => ({ tools: y })), e.setRequestHandler(s, async (e) => {
211
282
  let t = e.params.name, n = e.params.arguments ?? {};
212
283
  try {
213
284
  let e;
214
285
  switch (t) {
215
286
  case "home":
216
- e = await _();
287
+ e = await b();
217
288
  break;
218
289
  case "move":
219
- e = await v(n);
290
+ e = await x(n);
220
291
  break;
221
292
  case "shift_to":
222
- e = await y(n);
293
+ e = await S(n);
223
294
  break;
224
295
  case "roll_to":
225
- e = await b(n);
296
+ e = await C(n);
226
297
  break;
227
298
  case "pitch_to":
228
- e = await x(n);
299
+ e = await w(n);
229
300
  break;
230
301
  case "rotate_to":
231
- e = await S(n);
302
+ e = await T(n);
232
303
  break;
233
304
  case "lift_to":
234
- e = await C(n);
305
+ e = await E(n);
306
+ break;
307
+ case "move_to":
308
+ e = await D(n);
235
309
  break;
236
310
  case "wait":
237
- e = await w(n);
311
+ e = await O(n);
238
312
  break;
239
313
  case "get_state":
240
- e = await T();
314
+ e = await k();
315
+ break;
316
+ case "run_script":
317
+ e = await A(n);
241
318
  break;
242
319
  default: return {
243
320
  content: [{
@@ -262,17 +339,36 @@ function E() {
262
339
  }
263
340
  }), e;
264
341
  }
265
- async function D() {
266
- let { Port: e, BaudRate: t } = c();
267
- l = e, u = t;
268
- let n = E(), r = new i();
269
- await n.connect(r);
342
+ async function M(e) {
343
+ let t = new a();
344
+ await e.connect(t);
270
345
  for (let e of ["SIGINT", "SIGTERM"]) process.on(e, () => {
271
- p(), process.exit(0);
346
+ g(), process.exit(0);
347
+ });
348
+ }
349
+ async function N(e, t) {
350
+ let r = new o({ sessionIdGenerator: void 0 });
351
+ await e.connect(r);
352
+ let i = n(async (e, t) => {
353
+ e.url === "/mcp" ? await r.handleRequest(e, t) : (t.writeHead(404, { "Content-Type": "text/plain" }), t.end("not found"));
272
354
  });
355
+ await new Promise((e, n) => {
356
+ i.listen(t, () => {
357
+ process.stderr.write(`nova-control-mcp: HTTP transport listening on port ${t} — POST /mcp\n`), e();
358
+ }), i.once("error", n);
359
+ });
360
+ for (let e of ["SIGINT", "SIGTERM"]) process.on(e, async () => {
361
+ await r.close(), i.close(), g(), process.exit(0);
362
+ });
363
+ }
364
+ async function P() {
365
+ let { Port: e, BaudRate: t, Transport: n, ListenPort: r } = d();
366
+ f = e, p = t;
367
+ let i = j();
368
+ n === "http" ? await N(i, r) : await M(i);
273
369
  }
274
- t(process.argv[1]) === e(import.meta.url) && D().catch((e) => {
370
+ t(process.argv[1]) === e(import.meta.url) && P().catch((e) => {
275
371
  process.stderr.write(`nova-control-mcp: fatal: ${e.message ?? e}\n`), process.exit(1);
276
372
  });
277
373
  //#endregion
278
- export { h as _destroyForTests, m as _setupForTests, E as createServer };
374
+ export { v as _destroyForTests, _ as _setupForTests, j as createServer };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nova-control-mcp-server",
3
3
  "description": "MCP server for controlling a NOVA DIY Artificial Intelligence Robot by Creoqode",
4
- "version": "0.0.3",
4
+ "version": "0.0.5",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "nova",