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.
- mpremote_mcp-0.1.0/.github/workflows/ci.yml +18 -0
- mpremote_mcp-0.1.0/.github/workflows/release.yml +25 -0
- mpremote_mcp-0.1.0/.gitignore +6 -0
- mpremote_mcp-0.1.0/LICENSE +21 -0
- mpremote_mcp-0.1.0/PKG-INFO +73 -0
- mpremote_mcp-0.1.0/README.md +61 -0
- mpremote_mcp-0.1.0/docs/plans/2026-02-25-mpremote-mcp-design.md +78 -0
- mpremote_mcp-0.1.0/docs/plans/2026-02-25-mpremote-mcp-plan.md +713 -0
- mpremote_mcp-0.1.0/mpremote_mcp.py +405 -0
- mpremote_mcp-0.1.0/pyproject.toml +19 -0
|
@@ -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,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"
|