mpremote-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ workflow_call:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install ruff
17
+ - run: ruff check .
18
+ - run: ruff format --check .
@@ -0,0 +1,25 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ ci:
10
+ uses: ./.github/workflows/ci.yml
11
+
12
+ publish:
13
+ needs: ci
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ permissions:
17
+ id-token: write
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+ - run: pip install build
24
+ - run: python -m build
25
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .venv/
6
+ *.pyc
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: mpremote-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for MicroPython boards via mpremote
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: fastmcp
9
+ Requires-Dist: mpremote
10
+ Requires-Dist: pyserial
11
+ Description-Content-Type: text/markdown
12
+
13
+ # mpremote-mcp
14
+
15
+ MCP server for MicroPython boards via [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ uvx mpremote-mcp
21
+ ```
22
+
23
+ Or:
24
+
25
+ ```bash
26
+ pip install mpremote-mcp
27
+ mpremote-mcp
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ | Variable | Default | Description |
33
+ |----------|---------|-------------|
34
+ | `MPY_PORT` | auto-detect | Serial port override (e.g. `COM3`, `/dev/ttyACM0`) |
35
+ | `MPY_BAUD` | `115200` | Baud rate |
36
+
37
+ Auto-detection connects to the first USB serial device found.
38
+
39
+ ### MCP client config example
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "micropython": {
45
+ "command": "uvx",
46
+ "args": ["mpremote-mcp"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Tools
53
+
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `exec` | Execute MicroPython code via raw REPL |
57
+ | `eval` | Evaluate an expression and return result |
58
+ | `run` | Run a local .py file on device from RAM |
59
+ | `device_info` | Board name, MicroPython version, memory info |
60
+ | `list_files` | List files/directories on device |
61
+ | `read_file` | Read file from device |
62
+ | `write_file` | Write file to device |
63
+ | `mkdir` | Create directory |
64
+ | `rmdir` | Remove directory |
65
+ | `rm` | Remove file |
66
+ | `touch` | Create empty file |
67
+ | `df` | Filesystem storage stats |
68
+ | `mip_install` | Install MicroPython package |
69
+ | `rtc_get` | Read device real-time clock |
70
+ | `rtc_set` | Sync device RTC to host time |
71
+ | `soft_reset` | Soft-reset (Ctrl-D equivalent) |
72
+ | `hard_reset` | Hard reset (machine.reset()) |
73
+ | `enter_bootloader` | Enter USB bootloader for flashing |
@@ -0,0 +1,61 @@
1
+ # mpremote-mcp
2
+
3
+ MCP server for MicroPython boards via [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uvx mpremote-mcp
9
+ ```
10
+
11
+ Or:
12
+
13
+ ```bash
14
+ pip install mpremote-mcp
15
+ mpremote-mcp
16
+ ```
17
+
18
+ ## Configuration
19
+
20
+ | Variable | Default | Description |
21
+ |----------|---------|-------------|
22
+ | `MPY_PORT` | auto-detect | Serial port override (e.g. `COM3`, `/dev/ttyACM0`) |
23
+ | `MPY_BAUD` | `115200` | Baud rate |
24
+
25
+ Auto-detection connects to the first USB serial device found.
26
+
27
+ ### MCP client config example
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "micropython": {
33
+ "command": "uvx",
34
+ "args": ["mpremote-mcp"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## Tools
41
+
42
+ | Tool | Description |
43
+ |------|-------------|
44
+ | `exec` | Execute MicroPython code via raw REPL |
45
+ | `eval` | Evaluate an expression and return result |
46
+ | `run` | Run a local .py file on device from RAM |
47
+ | `device_info` | Board name, MicroPython version, memory info |
48
+ | `list_files` | List files/directories on device |
49
+ | `read_file` | Read file from device |
50
+ | `write_file` | Write file to device |
51
+ | `mkdir` | Create directory |
52
+ | `rmdir` | Remove directory |
53
+ | `rm` | Remove file |
54
+ | `touch` | Create empty file |
55
+ | `df` | Filesystem storage stats |
56
+ | `mip_install` | Install MicroPython package |
57
+ | `rtc_get` | Read device real-time clock |
58
+ | `rtc_set` | Sync device RTC to host time |
59
+ | `soft_reset` | Soft-reset (Ctrl-D equivalent) |
60
+ | `hard_reset` | Hard reset (machine.reset()) |
61
+ | `enter_bootloader` | Enter USB bootloader for flashing |
@@ -0,0 +1,78 @@
1
+ # mpremote-mcp Design
2
+
3
+ ## Overview
4
+
5
+ MCP server exposing MicroPython board interaction via mpremote's SerialTransport.
6
+ Published to PyPI as `mpremote-mcp`, runnable via `uvx mpremote-mcp` or `pip install mpremote-mcp`.
7
+
8
+ ## Project Structure
9
+
10
+ ```
11
+ mpremote-mcp/
12
+ ├── mpremote_mcp.py
13
+ ├── pyproject.toml
14
+ ├── LICENSE (MIT)
15
+ ├── .gitignore
16
+ └── .github/workflows/
17
+ ├── ci.yml
18
+ └── release.yml
19
+ ```
20
+
21
+ Single-module package. Entry point: `mpremote-mcp = "mpremote_mcp:main"`.
22
+
23
+ ## Configuration
24
+
25
+ | Variable | Default | Description |
26
+ |----------|---------|-------------|
27
+ | `MPY_PORT` | auto-detect | Serial port override |
28
+ | `MPY_BAUD` | `115200` | Baud rate |
29
+
30
+ Auto-detection uses mpremote's built-in logic (first USB serial device).
31
+
32
+ ## Tools
33
+
34
+ ### Existing (from mcp_micropython.py)
35
+
36
+ - **exec** — Execute MicroPython code via raw REPL. `timeout: int = 30`.
37
+ - **device_info** — Board name, MicroPython version, memory info.
38
+ - **soft_reset** — Soft-reset (Ctrl-D equivalent).
39
+ - **enter_bootloader** — Enter USB bootloader mode for flashing.
40
+ - **list_files** — List files/dirs on device filesystem.
41
+ - **read_file** — Read file from device.
42
+ - **write_file** — Write file to device.
43
+
44
+ ### New
45
+
46
+ - **eval** — Evaluate expression, return result. `timeout: int = 30`.
47
+ - **run** — Run local .py file on device from RAM. `timeout: int = 30`.
48
+ - **mkdir** — Create directory on device.
49
+ - **rmdir** — Remove directory on device.
50
+ - **rm** — Remove file on device.
51
+ - **touch** — Create empty file on device.
52
+ - **df** — Storage stats via `os.statvfs('/')`.
53
+ - **mip_install** — Install package via `mpremote.mip`.
54
+ - **rtc_get** — Read device real-time clock.
55
+ - **rtc_set** — Sync device RTC to host time.
56
+ - **hard_reset** — Hard reset via `machine.reset()`.
57
+
58
+ ### Connection Pattern
59
+
60
+ Every tool opens a fresh SerialTransport, enters raw REPL, executes, closes.
61
+ No persistent connections.
62
+
63
+ ## GitHub Actions
64
+
65
+ ### ci.yml
66
+ - Triggers: push, pull_request, workflow_call
67
+ - Runs: ruff check, ruff format --check
68
+
69
+ ### release.yml
70
+ - Triggers: push tags `v*`
71
+ - Job 1: calls ci.yml
72
+ - Job 2 (depends on job 1): build + publish to PyPI via Trusted Publishers (OIDC, no tokens)
73
+
74
+ ## Dependencies
75
+
76
+ - fastmcp
77
+ - mpremote
78
+ - pyserial
@@ -0,0 +1,713 @@
1
+ # mpremote-mcp Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Rename, restructure, and extend the MicroPython MCP server into a publishable PyPI package with full mpremote tool coverage.
6
+
7
+ **Architecture:** Single-module FastMCP server (`mpremote_mcp.py`) using mpremote's `SerialTransport` as a library. Each tool opens a fresh serial connection, enters raw REPL, executes, and closes. Auto-detection mirrors mpremote's own logic (first USB device with VID+PID).
8
+
9
+ **Tech Stack:** Python 3.10+, FastMCP, mpremote, pyserial, ruff (linting), hatchling (build), GitHub Actions + Trusted Publishers (release).
10
+
11
+ ---
12
+
13
+ ### Task 1: Project scaffolding — pyproject.toml, .gitignore, LICENSE
14
+
15
+ **Files:**
16
+ - Create: `pyproject.toml`
17
+ - Create: `.gitignore`
18
+ - Create: `LICENSE`
19
+
20
+ **Step 1: Create pyproject.toml**
21
+
22
+ ```toml
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [project]
28
+ name = "mpremote-mcp"
29
+ version = "0.1.0"
30
+ description = "MCP server for MicroPython boards via mpremote"
31
+ readme = "README.md"
32
+ license = "MIT"
33
+ requires-python = ">=3.10"
34
+ dependencies = [
35
+ "fastmcp",
36
+ "mpremote",
37
+ "pyserial",
38
+ ]
39
+
40
+ [project.scripts]
41
+ mpremote-mcp = "mpremote_mcp:main"
42
+ ```
43
+
44
+ **Step 2: Create .gitignore**
45
+
46
+ Standard Python .gitignore: `__pycache__/`, `*.egg-info/`, `dist/`, `build/`, `.venv/`, `*.pyc`.
47
+
48
+ **Step 3: Create LICENSE**
49
+
50
+ MIT license, copyright 2026 Wannes.
51
+
52
+ **Step 4: Commit**
53
+
54
+ ```bash
55
+ git add pyproject.toml .gitignore LICENSE
56
+ git commit -m "feat: add project scaffolding"
57
+ ```
58
+
59
+ ---
60
+
61
+ ### Task 2: Rename and refactor core module
62
+
63
+ **Files:**
64
+ - Delete: `mcp_micropython.py`
65
+ - Create: `mpremote_mcp.py`
66
+
67
+ **Step 1: Create mpremote_mcp.py with updated core**
68
+
69
+ Rename `mcp_micropython.py` → `mpremote_mcp.py`. Refactor:
70
+
71
+ - Replace `find_device()` with mpremote-style auto-detection (iterate `serial.tools.list_ports.comports()`, filter by `vid is not None and pid is not None`)
72
+ - Drop `MPY_VID`, `MPY_SERIAL_TIMEOUT` env vars
73
+ - Drop `timeout` parameter from `SerialTransport` constructor call
74
+ - Change `MPY_EXEC_TIMEOUT` from env var to tool parameter `timeout: int = 30`
75
+ - Add `main()` function that calls `mcp.run(transport="stdio")`
76
+ - Keep `_open()` / `_close()` pattern
77
+
78
+ Core connection code:
79
+
80
+ ```python
81
+ import logging
82
+ import os
83
+ import stat
84
+ import sys
85
+ import time
86
+
87
+ import serial.tools.list_ports
88
+ from fastmcp import FastMCP
89
+ from mpremote.transport_serial import SerialTransport
90
+ from mpremote.transport import TransportError
91
+
92
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO)
93
+ log = logging.getLogger("mpremote-mcp")
94
+
95
+ mcp = FastMCP(
96
+ "micropython",
97
+ instructions="Interact with a MicroPython board over serial. "
98
+ "Use exec to run code, list_files/read_file/write_file for filesystem ops, "
99
+ "device_info for board details, soft_reset to reset the board.",
100
+ )
101
+
102
+ MPY_PORT = os.environ.get("MPY_PORT")
103
+ MPY_BAUD = int(os.environ.get("MPY_BAUD", "115200"))
104
+
105
+
106
+ def _find_device() -> str:
107
+ """Find MicroPython device: explicit port > auto-detect first USB serial."""
108
+ if MPY_PORT:
109
+ return MPY_PORT
110
+ for p in sorted(serial.tools.list_ports.comports()):
111
+ if p.vid is not None and p.pid is not None:
112
+ log.info("Auto-detected device on %s (VID=0x%04X)", p.device, p.vid)
113
+ return p.device
114
+ raise RuntimeError("No MicroPython device found. Set MPY_PORT or connect a device.")
115
+
116
+
117
+ def _open(soft_reset=False) -> SerialTransport:
118
+ """Open serial connection and enter raw REPL."""
119
+ port = _find_device()
120
+ t = SerialTransport(port, baudrate=MPY_BAUD)
121
+ t.enter_raw_repl(soft_reset=soft_reset)
122
+ return t
123
+
124
+
125
+ def _close(t: SerialTransport):
126
+ """Exit raw REPL and close serial connection."""
127
+ try:
128
+ t.exit_raw_repl()
129
+ except Exception:
130
+ pass
131
+ t.close()
132
+
133
+
134
+ def main():
135
+ mcp.run(transport="stdio")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
140
+ ```
141
+
142
+ **Step 2: Delete old file**
143
+
144
+ ```bash
145
+ git rm mcp_micropython.py
146
+ ```
147
+
148
+ **Step 3: Commit**
149
+
150
+ ```bash
151
+ git add mpremote_mcp.py
152
+ git commit -m "refactor: rename to mpremote_mcp, use mpremote auto-detection"
153
+ ```
154
+
155
+ ---
156
+
157
+ ### Task 3: Migrate existing tools
158
+
159
+ **Files:**
160
+ - Modify: `mpremote_mcp.py`
161
+
162
+ **Step 1: Add existing tools with updated signatures**
163
+
164
+ Port all 7 existing tools from `mcp_micropython.py` into `mpremote_mcp.py`, updating:
165
+ - `exec`: change default timeout to 30, remove env var reference
166
+ - `device_info`: keep as-is
167
+ - `soft_reset`: keep as-is
168
+ - `enter_bootloader`: use `_find_device()` instead of `find_device()`
169
+ - `list_files`: keep as-is
170
+ - `read_file`: keep as-is
171
+ - `write_file`: keep as-is
172
+
173
+ ```python
174
+ @mcp.tool()
175
+ def exec(code: str, timeout: int = 30) -> str:
176
+ """Execute MicroPython code on the board and return stdout.
177
+
178
+ Args:
179
+ code: Python code to execute on the device.
180
+ timeout: Timeout in seconds waiting for output (default 30).
181
+ """
182
+ t = _open()
183
+ try:
184
+ t.exec_raw_no_follow(code)
185
+ ret, ret_err = t.follow(timeout=timeout)
186
+ if ret_err:
187
+ from mpremote.transport import TransportExecError
188
+ raise TransportExecError(ret, ret_err.decode())
189
+ return ret.decode(errors="replace")
190
+ finally:
191
+ _close(t)
192
+
193
+
194
+ @mcp.tool()
195
+ def enter_bootloader() -> str:
196
+ """Reset the board into USB bootloader mode for flashing."""
197
+ port = _find_device()
198
+ try:
199
+ t = SerialTransport(port, baudrate=MPY_BAUD)
200
+ t.enter_raw_repl(soft_reset=False)
201
+ try:
202
+ t.exec_raw_no_follow("import machine; machine.bootloader()")
203
+ time.sleep(0.5)
204
+ except Exception:
205
+ pass
206
+ try:
207
+ t.close()
208
+ except Exception:
209
+ pass
210
+ except Exception as e:
211
+ log.info("enter_bootloader serial exception (expected): %s", e)
212
+ return "Board entering bootloader mode. USB disconnected. Ready for flashing."
213
+
214
+
215
+ @mcp.tool()
216
+ def soft_reset() -> str:
217
+ """Soft-reset the MicroPython board (equivalent to Ctrl-D)."""
218
+ t = _open(soft_reset=True)
219
+ try:
220
+ result = t.exec("print('reset ok')")
221
+ return result.decode(errors="replace")
222
+ finally:
223
+ _close(t)
224
+
225
+
226
+ @mcp.tool()
227
+ def list_files(path: str = "/") -> str:
228
+ """List files and directories on the device filesystem.
229
+
230
+ Args:
231
+ path: Directory path to list (default: root "/").
232
+ """
233
+ t = _open()
234
+ try:
235
+ entries = t.fs_listdir(path)
236
+ lines = []
237
+ for entry in entries:
238
+ name = entry[0]
239
+ mode = entry[1]
240
+ size = entry[3]
241
+ kind = "dir" if stat.S_ISDIR(mode) else "file"
242
+ lines.append(f"{kind:4s} {size:>8d} {name}")
243
+ return "\n".join(lines) if lines else "(empty)"
244
+ finally:
245
+ _close(t)
246
+
247
+
248
+ @mcp.tool()
249
+ def read_file(path: str) -> bytes:
250
+ """Read a file from the device filesystem.
251
+
252
+ Args:
253
+ path: File path on the device (e.g. "/main.py").
254
+ """
255
+ t = _open()
256
+ try:
257
+ return t.fs_readfile(path)
258
+ finally:
259
+ _close(t)
260
+
261
+
262
+ @mcp.tool()
263
+ def write_file(path: str, content: bytes) -> str:
264
+ """Write content to a file on the device filesystem.
265
+
266
+ Args:
267
+ path: Destination file path on the device (e.g. "/main.py").
268
+ content: File content to write.
269
+ """
270
+ t = _open()
271
+ try:
272
+ t.fs_writefile(path, content)
273
+ return f"Wrote {len(content)} bytes to {path}"
274
+ finally:
275
+ _close(t)
276
+
277
+
278
+ @mcp.tool()
279
+ def device_info() -> str:
280
+ """Get board name, MicroPython version, and memory info."""
281
+ code = """\
282
+ import sys, os, gc
283
+ gc.collect()
284
+ print("platform:", sys.platform)
285
+ print("version:", sys.version)
286
+ print("implementation:", sys.implementation)
287
+ uname = os.uname()
288
+ print("machine:", uname.machine)
289
+ print("sysname:", uname.sysname)
290
+ print("release:", uname.release)
291
+ gc.collect()
292
+ print("mem_free:", gc.mem_free())
293
+ print("mem_alloc:", gc.mem_alloc())
294
+ """
295
+ t = _open()
296
+ try:
297
+ result = t.exec(code)
298
+ return result.decode(errors="replace")
299
+ finally:
300
+ _close(t)
301
+ ```
302
+
303
+ **Step 2: Commit**
304
+
305
+ ```bash
306
+ git add mpremote_mcp.py
307
+ git commit -m "feat: migrate existing tools to mpremote_mcp"
308
+ ```
309
+
310
+ ---
311
+
312
+ ### Task 4: Add new filesystem tools — mkdir, rmdir, rm, touch, df
313
+
314
+ **Files:**
315
+ - Modify: `mpremote_mcp.py`
316
+
317
+ **Step 1: Add filesystem tools**
318
+
319
+ ```python
320
+ @mcp.tool()
321
+ def mkdir(path: str) -> str:
322
+ """Create a directory on the device filesystem.
323
+
324
+ Args:
325
+ path: Directory path to create (e.g. "/lib").
326
+ """
327
+ t = _open()
328
+ try:
329
+ t.fs_mkdir(path)
330
+ return f"Created directory {path}"
331
+ finally:
332
+ _close(t)
333
+
334
+
335
+ @mcp.tool()
336
+ def rmdir(path: str) -> str:
337
+ """Remove a directory on the device filesystem.
338
+
339
+ Args:
340
+ path: Directory path to remove (must be empty).
341
+ """
342
+ t = _open()
343
+ try:
344
+ t.fs_rmdir(path)
345
+ return f"Removed directory {path}"
346
+ finally:
347
+ _close(t)
348
+
349
+
350
+ @mcp.tool()
351
+ def rm(path: str) -> str:
352
+ """Remove a file on the device filesystem.
353
+
354
+ Args:
355
+ path: File path to remove (e.g. "/main.py").
356
+ """
357
+ t = _open()
358
+ try:
359
+ t.fs_rmfile(path)
360
+ return f"Removed {path}"
361
+ finally:
362
+ _close(t)
363
+
364
+
365
+ @mcp.tool()
366
+ def touch(path: str) -> str:
367
+ """Create an empty file (or update access time) on the device.
368
+
369
+ Args:
370
+ path: File path to touch (e.g. "/data.txt").
371
+ """
372
+ t = _open()
373
+ try:
374
+ t.fs_touchfile(path)
375
+ return f"Touched {path}"
376
+ finally:
377
+ _close(t)
378
+
379
+
380
+ @mcp.tool()
381
+ def df(path: str = "/") -> str:
382
+ """Get filesystem storage statistics (free/used space).
383
+
384
+ Args:
385
+ path: Filesystem mount point (default: "/").
386
+ """
387
+ code = f"""\
388
+ import os
389
+ s = os.statvfs('{path}')
390
+ block_size = s[0]
391
+ total_blocks = s[2]
392
+ free_blocks = s[3]
393
+ total = block_size * total_blocks
394
+ free = block_size * free_blocks
395
+ used = total - free
396
+ print(f"Total: {{total}} bytes ({{total // 1024}} KB)")
397
+ print(f"Used: {{used}} bytes ({{used // 1024}} KB)")
398
+ print(f"Free: {{free}} bytes ({{free // 1024}} KB)")
399
+ print(f"Usage: {{used * 100 // total}}%")
400
+ """
401
+ t = _open()
402
+ try:
403
+ result = t.exec(code)
404
+ return result.decode(errors="replace")
405
+ finally:
406
+ _close(t)
407
+ ```
408
+
409
+ **Step 2: Commit**
410
+
411
+ ```bash
412
+ git add mpremote_mcp.py
413
+ git commit -m "feat: add mkdir, rmdir, rm, touch, df tools"
414
+ ```
415
+
416
+ ---
417
+
418
+ ### Task 5: Add eval and run tools
419
+
420
+ **Files:**
421
+ - Modify: `mpremote_mcp.py`
422
+
423
+ **Step 1: Add eval and run**
424
+
425
+ ```python
426
+ @mcp.tool()
427
+ def eval(expression: str, timeout: int = 30) -> str:
428
+ """Evaluate a MicroPython expression and return its result.
429
+
430
+ Args:
431
+ expression: Python expression to evaluate (e.g. "2 + 2").
432
+ timeout: Timeout in seconds (default 30).
433
+ """
434
+ t = _open()
435
+ try:
436
+ t.exec_raw_no_follow(f"print(repr({expression}))")
437
+ ret, ret_err = t.follow(timeout=timeout)
438
+ if ret_err:
439
+ from mpremote.transport import TransportExecError
440
+ raise TransportExecError(ret, ret_err.decode())
441
+ return ret.decode(errors="replace")
442
+ finally:
443
+ _close(t)
444
+
445
+
446
+ @mcp.tool()
447
+ def run(file_path: str, timeout: int = 30) -> str:
448
+ """Run a local Python file on the device from RAM (not copied to filesystem).
449
+
450
+ Args:
451
+ file_path: Path to a .py file on the host machine.
452
+ timeout: Timeout in seconds (default 30).
453
+ """
454
+ with open(file_path, "r") as f:
455
+ code = f.read()
456
+ t = _open()
457
+ try:
458
+ t.exec_raw_no_follow(code)
459
+ ret, ret_err = t.follow(timeout=timeout)
460
+ if ret_err:
461
+ from mpremote.transport import TransportExecError
462
+ raise TransportExecError(ret, ret_err.decode())
463
+ return ret.decode(errors="replace")
464
+ finally:
465
+ _close(t)
466
+ ```
467
+
468
+ **Step 2: Commit**
469
+
470
+ ```bash
471
+ git add mpremote_mcp.py
472
+ git commit -m "feat: add eval and run tools"
473
+ ```
474
+
475
+ ---
476
+
477
+ ### Task 6: Add rtc_get, rtc_set, hard_reset tools
478
+
479
+ **Files:**
480
+ - Modify: `mpremote_mcp.py`
481
+
482
+ **Step 1: Add device management tools**
483
+
484
+ ```python
485
+ @mcp.tool()
486
+ def rtc_get() -> str:
487
+ """Read the device's real-time clock."""
488
+ code = """\
489
+ try:
490
+ from machine import RTC
491
+ dt = RTC().datetime()
492
+ print(f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}")
493
+ except Exception as e:
494
+ print(f"RTC not available: {e}")
495
+ """
496
+ t = _open()
497
+ try:
498
+ result = t.exec(code)
499
+ return result.decode(errors="replace")
500
+ finally:
501
+ _close(t)
502
+
503
+
504
+ @mcp.tool()
505
+ def rtc_set() -> str:
506
+ """Sync the device's real-time clock to the host's current time."""
507
+ import datetime
508
+ now = datetime.datetime.now()
509
+ code = f"""\
510
+ from machine import RTC
511
+ RTC().datetime(({now.year}, {now.month}, {now.day}, {now.weekday()}, {now.hour}, {now.minute}, {now.second}, 0))
512
+ dt = RTC().datetime()
513
+ print(f"RTC set to: {{dt[0]:04d}}-{{dt[1]:02d}}-{{dt[2]:02d}} {{dt[4]:02d}}:{{dt[5]:02d}}:{{dt[6]:02d}}")
514
+ """
515
+ t = _open()
516
+ try:
517
+ result = t.exec(code)
518
+ return result.decode(errors="replace")
519
+ finally:
520
+ _close(t)
521
+
522
+
523
+ @mcp.tool()
524
+ def hard_reset() -> str:
525
+ """Hard reset the device (equivalent to machine.reset())."""
526
+ port = _find_device()
527
+ try:
528
+ t = SerialTransport(port, baudrate=MPY_BAUD)
529
+ t.enter_raw_repl(soft_reset=False)
530
+ try:
531
+ t.exec_raw_no_follow("import machine; machine.reset()")
532
+ time.sleep(0.5)
533
+ except Exception:
534
+ pass
535
+ try:
536
+ t.close()
537
+ except Exception:
538
+ pass
539
+ except Exception as e:
540
+ log.info("hard_reset serial exception (expected): %s", e)
541
+ return "Device hard reset initiated."
542
+ ```
543
+
544
+ **Step 2: Commit**
545
+
546
+ ```bash
547
+ git add mpremote_mcp.py
548
+ git commit -m "feat: add rtc_get, rtc_set, hard_reset tools"
549
+ ```
550
+
551
+ ---
552
+
553
+ ### Task 7: Add mip_install tool
554
+
555
+ **Files:**
556
+ - Modify: `mpremote_mcp.py`
557
+
558
+ **Step 1: Add mip_install**
559
+
560
+ Uses mpremote's internal `_install_package` function:
561
+
562
+ ```python
563
+ @mcp.tool()
564
+ def mip_install(package: str, target: str = "/lib", version: str = "", mpy: bool = True) -> str:
565
+ """Install a MicroPython package from micropython-lib or URL.
566
+
567
+ Args:
568
+ package: Package name, or github:org/repo, or URL to package.json.
569
+ target: Target directory on device (default "/lib").
570
+ version: Package version (empty string for latest).
571
+ mpy: Download pre-compiled .mpy files (default True).
572
+ """
573
+ from mpremote.mip import _install_package, _PACKAGE_INDEX
574
+ t = _open(soft_reset=True)
575
+ try:
576
+ _install_package(t, package, _PACKAGE_INDEX, target, version or None, mpy)
577
+ return f"Installed {package} to {target}"
578
+ finally:
579
+ _close(t)
580
+ ```
581
+
582
+ **Step 2: Commit**
583
+
584
+ ```bash
585
+ git add mpremote_mcp.py
586
+ git commit -m "feat: add mip_install tool"
587
+ ```
588
+
589
+ ---
590
+
591
+ ### Task 8: CI workflow — ci.yml
592
+
593
+ **Files:**
594
+ - Create: `.github/workflows/ci.yml`
595
+
596
+ **Step 1: Create ci.yml**
597
+
598
+ ```yaml
599
+ name: CI
600
+
601
+ on:
602
+ push:
603
+ pull_request:
604
+ workflow_call:
605
+
606
+ jobs:
607
+ lint:
608
+ runs-on: ubuntu-latest
609
+ steps:
610
+ - uses: actions/checkout@v4
611
+ - uses: actions/setup-python@v5
612
+ with:
613
+ python-version: "3.12"
614
+ - run: pip install ruff
615
+ - run: ruff check .
616
+ - run: ruff format --check .
617
+ ```
618
+
619
+ **Step 2: Commit**
620
+
621
+ ```bash
622
+ git add .github/workflows/ci.yml
623
+ git commit -m "ci: add lint workflow"
624
+ ```
625
+
626
+ ---
627
+
628
+ ### Task 9: Release workflow — release.yml
629
+
630
+ **Files:**
631
+ - Create: `.github/workflows/release.yml`
632
+
633
+ **Step 1: Create release.yml**
634
+
635
+ ```yaml
636
+ name: Release
637
+
638
+ on:
639
+ push:
640
+ tags:
641
+ - "v*"
642
+
643
+ jobs:
644
+ ci:
645
+ uses: ./.github/workflows/ci.yml
646
+
647
+ publish:
648
+ needs: ci
649
+ runs-on: ubuntu-latest
650
+ environment: pypi
651
+ permissions:
652
+ id-token: write
653
+ steps:
654
+ - uses: actions/checkout@v4
655
+ - uses: actions/setup-python@v5
656
+ with:
657
+ python-version: "3.12"
658
+ - run: pip install build
659
+ - run: python -m build
660
+ - uses: pypa/gh-action-pypi-publish@release/v1
661
+ ```
662
+
663
+ **Step 2: Commit**
664
+
665
+ ```bash
666
+ git add .github/workflows/release.yml
667
+ git commit -m "ci: add tag-triggered PyPI release workflow"
668
+ ```
669
+
670
+ ---
671
+
672
+ ### Task 10: GitHub repo setup
673
+
674
+ **Step 1: Create GitHub repo**
675
+
676
+ ```bash
677
+ gh repo create mpremote-mcp --public --source=. --push
678
+ ```
679
+
680
+ **Step 2: Configure Trusted Publisher on PyPI**
681
+
682
+ Manual step — go to pypi.org, create project `mpremote-mcp`, add Trusted Publisher:
683
+ - Owner: `<github-username>`
684
+ - Repository: `mpremote-mcp`
685
+ - Workflow: `release.yml`
686
+ - Environment: `pypi`
687
+
688
+ **Step 3: Tag and release**
689
+
690
+ ```bash
691
+ git tag v0.1.0
692
+ git push --tags
693
+ ```
694
+
695
+ ---
696
+
697
+ ### Task 11: Verify lint passes locally
698
+
699
+ **Step 1: Run ruff**
700
+
701
+ ```bash
702
+ pip install ruff
703
+ ruff check mpremote_mcp.py
704
+ ruff format --check mpremote_mcp.py
705
+ ```
706
+
707
+ **Step 2: Fix any issues, commit**
708
+
709
+ ```bash
710
+ ruff format mpremote_mcp.py
711
+ git add mpremote_mcp.py
712
+ git commit -m "style: format with ruff"
713
+ ```
@@ -0,0 +1,405 @@
1
+ """MCP server for MicroPython boards via mpremote serial transport."""
2
+
3
+ import logging
4
+ import os
5
+ import stat
6
+ import sys
7
+ import time
8
+
9
+ import serial.tools.list_ports
10
+ from fastmcp import FastMCP
11
+ from mpremote.transport_serial import SerialTransport
12
+
13
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO)
14
+ log = logging.getLogger("mpremote-mcp")
15
+
16
+ mcp = FastMCP(
17
+ "micropython",
18
+ instructions="Interact with a MicroPython board over serial. "
19
+ "Use exec to run code, list_files/read_file/write_file for filesystem ops, "
20
+ "device_info for board details, soft_reset to reset the board.",
21
+ )
22
+
23
+ MPY_PORT = os.environ.get("MPY_PORT")
24
+ MPY_BAUD = int(os.environ.get("MPY_BAUD", "115200"))
25
+
26
+
27
+ def _find_device() -> str:
28
+ """Find MicroPython device: explicit port > auto-detect first USB serial."""
29
+ if MPY_PORT:
30
+ return MPY_PORT
31
+ for p in sorted(serial.tools.list_ports.comports()):
32
+ if p.vid is not None and p.pid is not None:
33
+ log.info("Auto-detected device on %s (VID=0x%04X)", p.device, p.vid)
34
+ return p.device
35
+ raise RuntimeError("No MicroPython device found. Set MPY_PORT or connect a device.")
36
+
37
+
38
+ def _open(soft_reset=False) -> SerialTransport:
39
+ """Open serial connection and enter raw REPL."""
40
+ port = _find_device()
41
+ t = SerialTransport(port, baudrate=MPY_BAUD)
42
+ t.enter_raw_repl(soft_reset=soft_reset)
43
+ return t
44
+
45
+
46
+ def _close(t: SerialTransport):
47
+ """Exit raw REPL and close serial connection."""
48
+ try:
49
+ t.exit_raw_repl()
50
+ except Exception:
51
+ pass
52
+ t.close()
53
+
54
+
55
+ @mcp.tool()
56
+ def exec(code: str, timeout: int = 30) -> str:
57
+ """Execute MicroPython code on the board and return stdout.
58
+
59
+ Args:
60
+ code: Python code to execute on the device.
61
+ timeout: Timeout in seconds waiting for output (default 30).
62
+ """
63
+ t = _open()
64
+ try:
65
+ t.exec_raw_no_follow(code)
66
+ ret, ret_err = t.follow(timeout=timeout)
67
+ if ret_err:
68
+ from mpremote.transport import TransportExecError
69
+
70
+ raise TransportExecError(ret, ret_err.decode())
71
+ return ret.decode(errors="replace")
72
+ finally:
73
+ _close(t)
74
+
75
+
76
+ @mcp.tool()
77
+ def enter_bootloader() -> str:
78
+ """Reset the board into USB bootloader mode for flashing."""
79
+ port = _find_device()
80
+ try:
81
+ t = SerialTransport(port, baudrate=MPY_BAUD)
82
+ t.enter_raw_repl(soft_reset=False)
83
+ try:
84
+ t.exec_raw_no_follow("import machine; machine.bootloader()")
85
+ time.sleep(0.5)
86
+ except Exception:
87
+ pass
88
+ try:
89
+ t.close()
90
+ except Exception:
91
+ pass
92
+ except Exception as e:
93
+ log.info("enter_bootloader serial exception (expected): %s", e)
94
+ return "Board entering bootloader mode. USB disconnected. Ready for flashing."
95
+
96
+
97
+ @mcp.tool()
98
+ def soft_reset() -> str:
99
+ """Soft-reset the MicroPython board (equivalent to Ctrl-D)."""
100
+ t = _open(soft_reset=True)
101
+ try:
102
+ result = t.exec("print('reset ok')")
103
+ return result.decode(errors="replace")
104
+ finally:
105
+ _close(t)
106
+
107
+
108
+ @mcp.tool()
109
+ def list_files(path: str = "/") -> str:
110
+ """List files and directories on the device filesystem.
111
+
112
+ Args:
113
+ path: Directory path to list (default: root "/").
114
+ """
115
+ t = _open()
116
+ try:
117
+ entries = t.fs_listdir(path)
118
+ lines = []
119
+ for entry in entries:
120
+ name = entry[0]
121
+ mode = entry[1]
122
+ size = entry[3]
123
+ kind = "dir" if stat.S_ISDIR(mode) else "file"
124
+ lines.append(f"{kind:4s} {size:>8d} {name}")
125
+ return "\n".join(lines) if lines else "(empty)"
126
+ finally:
127
+ _close(t)
128
+
129
+
130
+ @mcp.tool()
131
+ def read_file(path: str) -> bytes:
132
+ """Read a file from the device filesystem.
133
+
134
+ Args:
135
+ path: File path on the device (e.g. "/main.py").
136
+ """
137
+ t = _open()
138
+ try:
139
+ return t.fs_readfile(path)
140
+ finally:
141
+ _close(t)
142
+
143
+
144
+ @mcp.tool()
145
+ def write_file(path: str, content: bytes) -> str:
146
+ """Write content to a file on the device filesystem.
147
+
148
+ Args:
149
+ path: Destination file path on the device (e.g. "/main.py").
150
+ content: File content to write.
151
+ """
152
+ t = _open()
153
+ try:
154
+ t.fs_writefile(path, content)
155
+ return f"Wrote {len(content)} bytes to {path}"
156
+ finally:
157
+ _close(t)
158
+
159
+
160
+ @mcp.tool()
161
+ def device_info() -> str:
162
+ """Get board name, MicroPython version, and memory info."""
163
+ code = """\
164
+ import sys, os, gc
165
+ gc.collect()
166
+ print("platform:", sys.platform)
167
+ print("version:", sys.version)
168
+ print("implementation:", sys.implementation)
169
+ uname = os.uname()
170
+ print("machine:", uname.machine)
171
+ print("sysname:", uname.sysname)
172
+ print("release:", uname.release)
173
+ gc.collect()
174
+ print("mem_free:", gc.mem_free())
175
+ print("mem_alloc:", gc.mem_alloc())
176
+ """
177
+ t = _open()
178
+ try:
179
+ result = t.exec(code)
180
+ return result.decode(errors="replace")
181
+ finally:
182
+ _close(t)
183
+
184
+
185
+ @mcp.tool()
186
+ def eval(expression: str, timeout: int = 30) -> str:
187
+ """Evaluate a MicroPython expression and return its result.
188
+
189
+ Args:
190
+ expression: Python expression to evaluate (e.g. "2 + 2").
191
+ timeout: Timeout in seconds (default 30).
192
+ """
193
+ t = _open()
194
+ try:
195
+ t.exec_raw_no_follow(f"print(repr({expression}))")
196
+ ret, ret_err = t.follow(timeout=timeout)
197
+ if ret_err:
198
+ from mpremote.transport import TransportExecError
199
+
200
+ raise TransportExecError(ret, ret_err.decode())
201
+ return ret.decode(errors="replace")
202
+ finally:
203
+ _close(t)
204
+
205
+
206
+ @mcp.tool()
207
+ def run(file_path: str, timeout: int = 30) -> str:
208
+ """Run a local Python file on the device from RAM (not copied to filesystem).
209
+
210
+ Args:
211
+ file_path: Path to a .py file on the host machine.
212
+ timeout: Timeout in seconds (default 30).
213
+ """
214
+ with open(file_path) as f:
215
+ code = f.read()
216
+ t = _open()
217
+ try:
218
+ t.exec_raw_no_follow(code)
219
+ ret, ret_err = t.follow(timeout=timeout)
220
+ if ret_err:
221
+ from mpremote.transport import TransportExecError
222
+
223
+ raise TransportExecError(ret, ret_err.decode())
224
+ return ret.decode(errors="replace")
225
+ finally:
226
+ _close(t)
227
+
228
+
229
+ @mcp.tool()
230
+ def mkdir(path: str) -> str:
231
+ """Create a directory on the device filesystem.
232
+
233
+ Args:
234
+ path: Directory path to create (e.g. "/lib").
235
+ """
236
+ t = _open()
237
+ try:
238
+ t.fs_mkdir(path)
239
+ return f"Created directory {path}"
240
+ finally:
241
+ _close(t)
242
+
243
+
244
+ @mcp.tool()
245
+ def rmdir(path: str) -> str:
246
+ """Remove a directory on the device filesystem.
247
+
248
+ Args:
249
+ path: Directory path to remove (must be empty).
250
+ """
251
+ t = _open()
252
+ try:
253
+ t.fs_rmdir(path)
254
+ return f"Removed directory {path}"
255
+ finally:
256
+ _close(t)
257
+
258
+
259
+ @mcp.tool()
260
+ def rm(path: str) -> str:
261
+ """Remove a file on the device filesystem.
262
+
263
+ Args:
264
+ path: File path to remove (e.g. "/main.py").
265
+ """
266
+ t = _open()
267
+ try:
268
+ t.fs_rmfile(path)
269
+ return f"Removed {path}"
270
+ finally:
271
+ _close(t)
272
+
273
+
274
+ @mcp.tool()
275
+ def touch(path: str) -> str:
276
+ """Create an empty file (or update access time) on the device.
277
+
278
+ Args:
279
+ path: File path to touch (e.g. "/data.txt").
280
+ """
281
+ t = _open()
282
+ try:
283
+ t.fs_touchfile(path)
284
+ return f"Touched {path}"
285
+ finally:
286
+ _close(t)
287
+
288
+
289
+ @mcp.tool()
290
+ def df(path: str = "/") -> str:
291
+ """Get filesystem storage statistics (free/used space).
292
+
293
+ Args:
294
+ path: Filesystem mount point (default: "/").
295
+ """
296
+ code = f"""\
297
+ import os
298
+ s = os.statvfs('{path}')
299
+ block_size = s[0]
300
+ total_blocks = s[2]
301
+ free_blocks = s[3]
302
+ total = block_size * total_blocks
303
+ free = block_size * free_blocks
304
+ used = total - free
305
+ print(f"Total: {{total}} bytes ({{total // 1024}} KB)")
306
+ print(f"Used: {{used}} bytes ({{used // 1024}} KB)")
307
+ print(f"Free: {{free}} bytes ({{free // 1024}} KB)")
308
+ print(f"Usage: {{used * 100 // total}}%")
309
+ """
310
+ t = _open()
311
+ try:
312
+ result = t.exec(code)
313
+ return result.decode(errors="replace")
314
+ finally:
315
+ _close(t)
316
+
317
+
318
+ @mcp.tool()
319
+ def mip_install(
320
+ package: str, target: str = "/lib", version: str = "", mpy: bool = True
321
+ ) -> str:
322
+ """Install a MicroPython package from micropython-lib or URL.
323
+
324
+ Args:
325
+ package: Package name, or github:org/repo, or URL to package.json.
326
+ target: Target directory on device (default "/lib").
327
+ version: Package version (empty string for latest).
328
+ mpy: Download pre-compiled .mpy files (default True).
329
+ """
330
+ from mpremote.mip import _install_package, _PACKAGE_INDEX
331
+
332
+ t = _open(soft_reset=True)
333
+ try:
334
+ _install_package(t, package, _PACKAGE_INDEX, target, version or None, mpy)
335
+ return f"Installed {package} to {target}"
336
+ finally:
337
+ _close(t)
338
+
339
+
340
+ @mcp.tool()
341
+ def rtc_get() -> str:
342
+ """Read the device's real-time clock."""
343
+ code = """\
344
+ try:
345
+ from machine import RTC
346
+ dt = RTC().datetime()
347
+ print(f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}")
348
+ except Exception as e:
349
+ print(f"RTC not available: {e}")
350
+ """
351
+ t = _open()
352
+ try:
353
+ result = t.exec(code)
354
+ return result.decode(errors="replace")
355
+ finally:
356
+ _close(t)
357
+
358
+
359
+ @mcp.tool()
360
+ def rtc_set() -> str:
361
+ """Sync the device's real-time clock to the host's current time."""
362
+ import datetime
363
+
364
+ now = datetime.datetime.now()
365
+ code = f"""\
366
+ from machine import RTC
367
+ RTC().datetime(({now.year}, {now.month}, {now.day}, {now.weekday()}, {now.hour}, {now.minute}, {now.second}, 0))
368
+ dt = RTC().datetime()
369
+ print(f"RTC set to: {{dt[0]:04d}}-{{dt[1]:02d}}-{{dt[2]:02d}} {{dt[4]:02d}}:{{dt[5]:02d}}:{{dt[6]:02d}}")
370
+ """
371
+ t = _open()
372
+ try:
373
+ result = t.exec(code)
374
+ return result.decode(errors="replace")
375
+ finally:
376
+ _close(t)
377
+
378
+
379
+ @mcp.tool()
380
+ def hard_reset() -> str:
381
+ """Hard reset the device (equivalent to machine.reset())."""
382
+ port = _find_device()
383
+ try:
384
+ t = SerialTransport(port, baudrate=MPY_BAUD)
385
+ t.enter_raw_repl(soft_reset=False)
386
+ try:
387
+ t.exec_raw_no_follow("import machine; machine.reset()")
388
+ time.sleep(0.5)
389
+ except Exception:
390
+ pass
391
+ try:
392
+ t.close()
393
+ except Exception:
394
+ pass
395
+ except Exception as e:
396
+ log.info("hard_reset serial exception (expected): %s", e)
397
+ return "Device hard reset initiated."
398
+
399
+
400
+ def main():
401
+ mcp.run(transport="stdio")
402
+
403
+
404
+ if __name__ == "__main__":
405
+ main()
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mpremote-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for MicroPython boards via mpremote"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "fastmcp",
14
+ "mpremote",
15
+ "pyserial",
16
+ ]
17
+
18
+ [project.scripts]
19
+ mpremote-mcp = "mpremote_mcp:main"