Skip to content

feat(mcp): Add <idf.py monitor> support for MCP#18385

Open
jinw06k wants to merge 1 commit into
espressif:masterfrom
jinw06k:mcp-monitor
Open

feat(mcp): Add <idf.py monitor> support for MCP#18385
jinw06k wants to merge 1 commit into
espressif:masterfrom
jinw06k:mcp-monitor

Conversation

@jinw06k
Copy link
Copy Markdown

@jinw06k jinw06k commented Mar 24, 2026

Description

Add monitor support to the ESP-IDF MCP server for interactive serial workflows.

This PR introduces PTY-backed monitor tools that allow MCP clients to start an idf.py monitor session, send input to the device, read buffered serial output, stop the session, and capture boot logs in a one-shot flow.

Changes included

  • add monitor_start
  • add monitor_send
  • add monitor_read
  • add monitor_stop
  • add device_reset_and_capture
  • add PTY/session management for idf.py monitor
  • guard monitor-only functionality on platforms without PTY support
  • factor repeated idf.py command construction into _idf_cmd()

Motivation

idf.py monitor requires a TTY-like environment, so it cannot be integrated the same way as the existing build/flash/clean tools. This change adds a PTY-backed monitor session model so MCP clients can interact with device serial output in a more complete way while keeping the existing non-monitor tools unchanged.

Related

N/A

Testing

Tested manually on a local MacOS ESP-IDF setup.

Tested flows

  • monitor_start resets the board and returns initial boot output inline
  • monitor_read polls buffered output with remaining-line indicator
  • monitor_send writes commands to the device; response retrieved via monitor_read
  • monitor_stop kills the full process tree and drains remaining output
  • monitor_boot captures boot log in a one-shot flow with clean cleanup
  • Repeated start/stop cycles work back-to-back — no zombie processes or port locks
  • Wrong port: reports "Monitor failed to start" with available ports; all other tools return clear "no session" messages
  • Unexpected process exit: monitor_read returns buffered data with exit note; monitor_stop detects and cleans up the dead session

Platform notes

  • monitor tools require PTY support and are guarded on platforms where PTY is unavailable
  • existing build/flash/clean/set-target behavior remains available even when PTY support is unavailable

Checklist

Before submitting a Pull Request, please ensure the following:

  • 🚨 This PR does not introduce breaking changes.
  • All CI checks (GH Actions) pass.
  • Documentation is updated as needed.
  • Tests are updated or added as necessary.
  • Code is well-commented, especially in complex areas.
  • Git history is clean — commits are squashed to the minimum necessary.

Note

Medium Risk
Introduces long-lived subprocess/PTY session management and process-group termination logic, which can affect serial port usage and cleanup behavior across platforms. Non-monitor tools are mostly unchanged but rely on new _idf_cmd() IDF_PATH validation.

Overview
Adds interactive serial monitoring to the MCP server by introducing PTY-backed tools: monitor_start, monitor_send, monitor_read, monitor_stop, plus a one-shot monitor_boot that resets and captures boot logs for a bounded duration.

Refactors idf.py invocation into _idf_cmd() (with explicit IDF_PATH validation) and adds a MonitorSession with background output buffering, ANSI stripping, and robust process-group cleanup; monitor features are guarded on platforms without PTY support and sessions are cleaned up on server exit.

Written by Cursor Bugbot for commit 78b3581. This will update automatically on new commits. Configure here.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 24, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 24, 2026

Messages
📖 🎉 Good Job! All checks are passing!

👋 Hello jinw06k, we appreciate your contribution to this project!


📘 Please review the project's Contributions Guide for key guidelines on code, documentation, testing, and more.

🖊️ Please also make sure you have read and signed the Contributor License Agreement for this project.

Click to see more instructions ...


This automated output is generated by the PR linter DangerJS, which checks if your Pull Request meets the project's requirements and helps you fix potential issues.

DangerJS is triggered with each push event to a Pull Request and modify the contents of this comment.

Please consider the following:
- Danger mainly focuses on the PR structure and formatting and can't understand the meaning behind your code or changes.
- Danger is not a substitute for human code reviews; it's still important to request a code review from your colleagues.
- To manually retry these Danger checks, please navigate to the Actions tab and re-run last Danger workflow.

Review and merge process you can expect ...


We do welcome contributions in the form of bug reports, feature requests and pull requests via this public GitHub repository.

This GitHub project is public mirror of our internal git repository

1. An internal issue has been created for the PR, we assign it to the relevant engineer.
2. They review the PR and either approve it or ask you for changes or clarifications.
3. Once the GitHub PR is approved, we synchronize it into our internal git repository.
4. In the internal git repository we do the final review, collect approvals from core owners and make sure all the automated tests are passing.
- At this point we may do some adjustments to the proposed change, or extend it by adding tests or documentation.
5. If the change is approved and passes the tests it is merged into the default branch.
5. On next sync from the internal git repository merged change will appear in this public GitHub repository.

Generated by 🚫 dangerJS against 56ebd26

@espressif-bot espressif-bot added the Status: Opened Issue is new label Mar 24, 2026
@jinw06k jinw06k changed the title tools: add MCP <idf.py monitor> support feat(mcp): Add <idf.py monitor> support for MCP Mar 24, 2026
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
cwd=cwd, start_new_session=True,
)
os.close(slave_fd)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PTY file descriptors leak if Popen fails

Medium Severity

If subprocess.Popen raises an exception (e.g., OSError from an invalid cwd, permissions, etc.), both master_fd and slave_fd from _pty_open_noecho() are leaked. The os.close(slave_fd) call and self._master_fd = master_fd assignment sit after the Popen call with no try/finally guard, so neither fd is ever closed on failure. The same pattern appears in monitor_boot. Repeated failures would accumulate leaked file descriptors.

Additional Locations (1)
Fix in Cursor Fix in Web

self._master_fd = None
if self._reader_thread is not None:
self._reader_thread.join(timeout=3)
self._reader_thread = None
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reader thread crashes on TypeError, loses buffered data

Low Severity

In stop(), the reader thread is joined after self._master_fd is set to None (lines 236–237 or 252–257). A race exists where the reader thread passes the while not self._stop_event.is_set() check, then stop() closes the fd and sets self._master_fd = None, then the reader calls select.select([self._master_fd], ...) with None. This raises TypeError, which is not caught by except OSError on line 163. The uncaught exception skips the partial-buffer flush at lines 166–168, silently losing any incomplete line data in buf.

Additional Locations (1)
Fix in Cursor Fix in Web

@cursor
Copy link
Copy Markdown

cursor Bot commented Mar 24, 2026

You have used all of your free Bugbot PR reviews.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

@cursor
Copy link
Copy Markdown

cursor Bot commented Mar 24, 2026

You have used all of your free Bugbot PR reviews.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

deadline = time.monotonic() + timeout if timeout > 0 else 0

while True:
lines, remaining = monitor_session.read(max_lines=max_lines)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

monitor_read can return more than max_lines lines

The polling loop passes the original max_lines to every read() call without decrementing it by the lines already collected. When timeout > 0, each iteration can fetch up to max_lines additional lines, so the total may significantly exceed the caller's requested limit.

Suggested fix:

budget = max_lines
while True:
    lines, remaining = monitor_session.read(max_lines=budget)
    if lines:
        collected.extend(lines)
        budget -= len(lines)
        if wait_for and wait_for in ''.join(collected):
            break
        if budget <= 0:
            break
    if deadline == 0:
        break
    if time.monotonic() >= deadline:
        break
    if not monitor_session.is_running:
        break
    time.sleep(0.3)

if not chunk:
break
parts.append(chunk.decode('utf-8', errors='replace'))
if wait_for and wait_for in ''.join(parts):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O(n²) join for wait_for check in _pty_read_for

On each received chunk, ''.join(parts) re-joins the entire accumulated output to scan for wait_for. For long captures this is quadratic in total output size.

A simple fix is to check only the tail of the buffer — the match can only span the boundary between the previous accumulated text and the new chunk:

parts.append(chunk.decode('utf-8', errors='replace'))
if wait_for:
    # Only the last (len(wait_for)-1 + new chunk) bytes can contain a new match
    tail = ''.join(parts)[-(len(wait_for) + len(parts[-1])):]  
    if wait_for in tail:
        break

Or maintain a running concatenated string instead of a list of parts.

except ImportError:
_PTY_SUPPORTED = False

_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete ANSI escape sequence stripping

The current regex \x1b\[[0-9;]*[A-Za-z] only handles CSI sequences. Several common sequences emitted by idf_monitor.py will leak through:

  • CSI sequences with ? modifier, e.g. \x1b[?25h (cursor show/hide)
  • OSC sequences: \x1b]0;title\x07 (terminal title)
  • Single-char escapes: \x1b(B (character set)

A more comprehensive pattern:

_ANSI_RE = re.compile(
    r'\x1b(?:'
    r'\[[0-9;?]*[A-Za-z]'    # CSI sequences (including ?)
    r'|\][^\x07]*(?:\x07|\x1b\\)'  # OSC sequences
    r'|[()][A-Z]'              # Character set designations
    r'|[A-Z]'                  # Single-char Fe escapes
    r')'
)


# === Monitor Tools ===

def _require_pty(tool_name: str) -> str | None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_require_pty captures nothing from the enclosing scope — move to module level

This helper is defined inside start_mcp_server but doesn't reference any local variables from that scope. Placing it at module level alongside the other _pty_* helper functions would make the organisation cleaner and easier to test in isolation.

@dobairoland
Copy link
Copy Markdown
Collaborator

Thank you for the contribution. The main issue I see that this is not compatible with Windows which is a must for all of our tools. Also the documentation section about MCP support needs to be updated.

@dobairoland
Copy link
Copy Markdown
Collaborator

Also please note that the contribution guide states that pre-commit hooks must pass for contributions. See the failed check for more information.

@jinw06k
Copy link
Copy Markdown
Author

jinw06k commented Mar 30, 2026

@dobairoland Thanks for the feedback. The current implementation wraps idf.py monitor in a PTY to bypass the isatty() check. However, Python pty is Unix-only, so it doesn't work on Windows.

I've been exploring two approaches to fix this and wanted to get your input on which direction to take:

Option A: pyserial + address decoding

  • Replace the monitor path with pyserial, which removes the PTY dependency and keeps the implementation cross-platform with no additional dependency. However, this would no longer run idf.py monitor directly, so it wouldn't be a 100% IDF Monitor equivalent. For the MCP use cases, panic/backtrace decoding could be reproduced by invoking the toolchain’s addr2line against the project ELF when a Backtrace: pattern is detected.

Option B: PTY on Unix + PyWinpty/ConPTY on Windows

  • Keep the current idf.py monitor design on Unix, and use a Windows pseudoterminal backend such as PyWinpty/ConPTY on Windows. That would preserve all of the original idf.py monitor’s behavior, but it would add an external dependency.

My preference is Option A unless preserving full idf.py monitor parity is a requirement.

@dobairoland
Copy link
Copy Markdown
Collaborator

@jinw06k I think option B is the best and then we will have to test this if we haven't missed anything Window-related.

The issue with option A is that we would start to develop a new monitor implementation which would have to be maintained in addition to esp-idf-monitor. Here, we could prepare a pyserial object and pass it to esp-idf-monitor's internals but the port, baud and similar would also have to be re-implemented.

We could also remove the writing capability (and hence the necessity of PTY) but then interactive applications (eg. https://github.com/espressif/esp-idf/tree/44c77cbf46844cd056c923277ece745173cb270d/examples/system/console/advanced ) could not be used. And I think it would be useful if the MCP server would able to control the ESP application that way.

So all in all, we need to evaluate option B on Windows with a console-based interactive application.

@jinw06k
Copy link
Copy Markdown
Author

jinw06k commented Mar 30, 2026

@dobairoland Thanks for the feedback on Option B. Before I go down the PyWinpty path, I wanted to share a third option (prototyped and tested).

Option C: Add --non-interactive mode to esp-idf-monitor
I traced the two things that block idf.py monitor from running in a subprocess pipe:

  1. The isatty() check in main() — rejects non-TTY stdin
  2. miniterm.Console() in Monitor.init() — calls termios.tcgetattr() which fails on a pipe

Just around 15 lines of code changes to esp-idf-monitor (gated on a new ESP_IDF_MONITOR_NON_INTERACTIVE env var) fixes both, in the same pattern as ESP_IDF_MONITOR_TEST which already bypasses isatty() and sets socket_test_mode to skip console input:

  1. Skip the isatty() check (same as ESP_IDF_MONITOR_TEST)
  2. Set socket_test_mode=True so ConsoleReader doesn't read interactive input (same as ESP_IDF_MONITOR_TEST)
  3. Replace miniterm.Console() with a dummy that writes to stdout
  4. In console_reader.py, catch the termios.error and keep the thread alive without reading

I tested this locally, and all IDF monitor features work normally. The MCP server can just set the env var and read stdout. Option C does not require PTY, platform-specific code, or new dependencies. It does, however, require another PR to the esp-idf-monitor repo.

Happy to go with Option B if you prefer keeping changes to one repo, but Option C would also benefit in the long run.

@dobairoland
Copy link
Copy Markdown
Collaborator

Yes, I didn't explain it in too much details but essentially this was option C:

We could also remove the writing capability (and hence the necessity of PTY) but then interactive applications (eg. https://github.com/espressif/esp-idf/tree/44c77cbf46844cd056c923277ece745173cb270d/examples/system/console/advanced ) could not be used. And I think it would be useful if the MCP server would able to control the ESP application that way.

The issue with this that we'd do a lot of hacking and then comes someone who really needs this to be interactive and then we will have to implement option B.

@jinw06k
Copy link
Copy Markdown
Author

jinw06k commented Mar 30, 2026

@dobairoland Interactive writing works in my Option C prototype.

The ConsoleReader patch reads from sys.stdin.buffer when no TTY is present and forwards to the device via TAG_KEY events. I tested monitor_send() and was able to get responses back.

My "non-interactive mode" wording may have been misleading — it's more of a headless mode. Full read and write work, just without the miniterm.Console TTY setup (which required PTY).

@dobairoland
Copy link
Copy Markdown
Collaborator

Thank you for the explanation. Option B still sounds the best to me.

@jinw06k
Copy link
Copy Markdown
Author

jinw06k commented Mar 31, 2026

Understood. I will update with option B!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Opened Issue is new

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants