Skip to content

Component Design

mcesptool organizes its 40+ tools into nine component classes. This page explains why the component boundary exists, how tool registration works internally, and the patterns that keep each component manageable.

A single flat file with 40 tool functions would work. FastMCP does not require any particular organization — you can register tools however you like. The component structure exists for human reasons, not framework reasons.

Each component groups tools that share a conceptual domain and tend to share helper code. Flash operations need output parsing for bytes-written counts. Security operations need to invoke espefuse in addition to esptool. QEMU management needs to track running processes. Grouping these concerns into classes creates a natural home for shared state and utility methods.

The alternative — a utils module with shared helpers and a flat list of tool functions — would produce the same runtime behavior. But the component classes provide a clear answer to “where does new code go?” When you need to add a tool for reading eFuse summary data, the answer is SecurityManager. When you need to add flash compression settings, the answer is FlashManager. This kind of obvious placement reduces coordination overhead.

Tool registration uses FastMCP’s @app.tool() decorator on inner functions defined inside each component’s _register_tools() method:

class FlashManager:
def __init__(self, app: FastMCP, config: ESPToolServerConfig) -> None:
self.app = app
self.config = config
self._register_tools()
def _register_tools(self) -> None:
@self.app.tool("esp_flash_firmware")
async def flash_firmware(
context: Context,
firmware_path: str,
port: str | None = None,
address: str = "0x0",
verify: bool = True,
) -> dict[str, Any]:
"""Flash firmware to ESP device."""
return await self._flash_firmware_impl(
context, firmware_path, port, address, verify
)

Three things happen during __init__:

  1. The component stores references to the FastMCP app and the server config.
  2. _register_tools() is called, which defines and decorates inner functions.
  3. FastMCP records each decorated function as a tool, including its name, docstring, and parameter schema.

After this, the component’s job is done in terms of registration. FastMCP owns the routing — when a tool call arrives over JSON-RPC, FastMCP dispatches it to the registered function based on the tool name.

You might wonder why the tools are defined as inner functions inside _register_tools() rather than as regular methods on the class. The reason is FastMCP’s introspection.

FastMCP inspects the decorated function’s signature to build the tool’s parameter schema. It looks at the function’s type annotations, default values, and docstring to generate the JSON Schema that gets advertised to MCP clients. For this to work correctly, the function needs to have exactly the right signature — the parameters that the LLM will provide, plus a Context parameter.

If the tool were a regular class method, its first parameter would be self, which FastMCP would try to include in the schema. The inner function pattern avoids this by defining a standalone function that closes over self:

# This is what the LLM sees in the tool schema:
# name: "esp_flash_firmware"
# parameters: firmware_path (str), port (str|null), address (str), verify (bool)
#
# The inner function's context parameter is handled specially by FastMCP.
# self is captured via closure, invisible to the schema.

Every inner function immediately delegates to a _*_impl() method:

@self.app.tool("esp_flash_firmware")
async def flash_firmware(context, firmware_path, port, address, verify):
return await self._flash_firmware_impl(context, firmware_path, port, address, verify)
async def _flash_firmware_impl(self, context, firmware_path, port, address, verify):
# 50+ lines of actual logic

This looks like unnecessary indirection, and in a narrow sense it is. But it serves a few purposes:

Testability. The impl methods are regular instance methods that can be called directly in tests without going through FastMCP’s dispatch layer. You can construct a FlashManager with a mock config, call _flash_firmware_impl() directly, and inspect the result.

Readability. The _register_tools() method reads as a clean list of tool definitions — name, parameters, docstring. The implementation details live elsewhere. When you open a component file, you can quickly scan the tool list without getting lost in implementation code.

Flexibility. Impl methods can call each other. For example, FlashManager._flash_backup_impl() delegates to _flash_read_impl() to avoid duplicating the flash-read logic. This kind of internal reuse would be awkward if the tools were only accessible through their FastMCP-decorated entry points.

Every component that interacts with hardware has its own _run_esptool (or _run_cmd) async method. These helpers build the esptool command, run it as a subprocess, handle timeouts, and return a standardized result dict.

The helpers are not shared across components. Each one has a slightly different signature tuned to its component’s needs:

  • ChipControl’s version accepts a command string and connect_attempts parameter, because chip detection operations benefit from limited retries and quick timeouts.
  • FlashManager’s version takes a flat args list and defaults to a 120-second timeout, because flash operations are slow.
  • SecurityManager’s version is called _run_cmd and accepts an arbitrary command list, because it invokes both esptool and espefuse.

Each component implements an async def health_check() method that returns a status dictionary:

async def health_check(self) -> dict[str, Any]:
return {
"status": "healthy",
"qemu_xtensa_available": bool(
self.config.qemu_xtensa_path
and Path(self.config.qemu_xtensa_path).exists()
),
"running_instances": sum(
1 for i in self.instances.values() if i.is_running
),
}

Health checks are called by the server’s esp_health_check tool when the detailed flag is set. They report on component-specific concerns: whether binary dependencies exist, how many QEMU instances are running, whether serial ports are accessible.

The health check interface is informal — there is no base class enforcing it. The server checks hasattr(component, "health_check") before calling it. This keeps the component contract light. Components that have nothing interesting to report beyond “I exist” return a simple {"status": "healthy"} dict.

All tool names use the esp_ prefix:

  • esp_detect_chip
  • esp_flash_firmware
  • esp_qemu_start
  • esp_security_audit

This namespacing prevents collisions when the MCP client has multiple servers connected. An LLM working with both mcesptool and a database MCP server will see esp_flash_firmware and db_query as clearly belonging to different domains.

The prefix also groups all mcesptool tools together when they are listed alphabetically, which is how most MCP clients present their tool catalogs.

The components/__init__.py module exports a COMPONENT_REGISTRY dictionary that maps names to classes:

COMPONENT_REGISTRY = {
"chip_control": ChipControl,
"flash_manager": FlashManager,
"partition_manager": PartitionManager,
"security_manager": SecurityManager,
"firmware_builder": FirmwareBuilder,
"ota_manager": OTAManager,
"production_tools": ProductionTools,
"diagnostics": Diagnostics,
"qemu_manager": QemuManager,
}

Today, the server does not use this registry for instantiation — it creates components by name in _initialize_components(). The registry is forward-looking infrastructure. It enables patterns like configuration-driven component selection (“only load flash and diagnostics components”) or plugin-style extension (“register a custom component from an external package”).

The system has exactly one cross-component reference: ChipControl holds an optional reference to QemuManager.

# Set by server after QemuManager initialization
self.qemu_manager = None

This reference exists so that esp_scan_ports can include running QEMU virtual devices in its results alongside physical serial ports. Without it, a port scan would only find hardware devices, and the LLM would need to call esp_qemu_list separately to discover virtual ones.

The cross-wire is set by the server after all components are initialized:

if "qemu_manager" in self.components:
self.components["chip_control"].qemu_manager = self.components["qemu_manager"]

This post-init wiring avoids both circular constructor dependencies and import-time coupling. ChipControl can be instantiated and tested without QemuManager — the attribute simply remains None, and the scan results exclude QEMU devices.

The pattern for adding a new component is straightforward:

  1. Create a new file in components/ with a class that accepts app: FastMCP and config: ESPToolServerConfig.
  2. Implement _register_tools() with your tool definitions.
  3. Implement health_check() if your component has meaningful status to report.
  4. Add the class to COMPONENT_REGISTRY in components/__init__.py.
  5. Instantiate it in ESPToolServer._initialize_components().

The bar for adding a new component (vs. adding tools to an existing one) should be: does this group of tools share a distinct conceptual domain and unique helper code? If the answer is yes, a new component keeps things organized. If you are adding one or two tools that fit naturally alongside existing ones, extending the existing component is the simpler choice.