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.
Why components at all
Section titled “Why components at all”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.
The registration mechanism
Section titled “The registration mechanism”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__:
- The component stores references to the FastMCP app and the server config.
_register_tools()is called, which defines and decorates inner functions.- 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.
Why inner functions
Section titled “Why inner functions”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.The impl delegation pattern
Section titled “The impl delegation pattern”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 logicThis 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.
The _run_esptool helper
Section titled “The _run_esptool helper”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
commandstring andconnect_attemptsparameter, because chip detection operations benefit from limited retries and quick timeouts. - FlashManager’s version takes a flat
argslist and defaults to a 120-second timeout, because flash operations are slow. - SecurityManager’s version is called
_run_cmdand accepts an arbitrary command list, because it invokes bothesptoolandespefuse.
Health checks
Section titled “Health checks”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.
Naming convention
Section titled “Naming convention”All tool names use the esp_ prefix:
esp_detect_chipesp_flash_firmwareesp_qemu_startesp_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 component registry
Section titled “The component registry”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”).
Cross-component references
Section titled “Cross-component references”The system has exactly one cross-component reference: ChipControl holds an optional reference to QemuManager.
# Set by server after QemuManager initializationself.qemu_manager = NoneThis 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.
Adding a new component
Section titled “Adding a new component”The pattern for adding a new component is straightforward:
- Create a new file in
components/with a class that acceptsapp: FastMCPandconfig: ESPToolServerConfig. - Implement
_register_tools()with your tool definitions. - Implement
health_check()if your component has meaningful status to report. - Add the class to
COMPONENT_REGISTRYincomponents/__init__.py. - 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.