Skip to content

Dynamic Tool Discovery

Traditional tool integrations require the client to know ahead of time what tools exist — you hardcode gpio_write, adc_read, etc. in your client. If you add a new tool to your firmware, you must update the client code.

MCP/U inverts this model. The firmware declares its capabilities at runtime. The client asks “what can you do?” and the firmware responds with a complete list: tool names, descriptions, parameter schemas, and pin registry. The client never hardcodes tool names.

This means:

  • Adding a new sensor → firmware registers a tool → client discovers it automatically
  • Swapping firmware versions → tools change → client adapts without code changes
  • Multi-device with different capabilities → each device advertises its own tools

sequenceDiagram
participant Client
participant MCU
Client->>MCU: connect() (Serial/TCP)
Client->>MCU: get_info()
MCU-->>Client: {device, version, platform, pin_count}
Client->>MCU: list_tools()
MCU-->>Client: {tools[], pins[]}
Note over Client: For each tool:<br/>convert schema, register handler

The client opens a Serial or TCP connection to the MCU. For ESP32 over USB, a 600ms delay allows the DTR signal to trigger a board reset and boot into the application.

The client calls the get_info method:

{"jsonrpc": "2.0", "id": 1, "method": "get_info"}

The firmware responds:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"device": "esp32-demo",
"version": "1.0.0",
"platform": "arduino",
"pin_count": 3
}
}

This gives the client basic device identification for logging and multi-device differentiation.

The client calls list_tools:

{"jsonrpc": "2.0", "id": 2, "method": "list_tools"}

The firmware responds with a complete capability manifest:

{
"jsonrpc": "2.0",
"id": 2,
"result": {
"device": "esp32-demo",
"version": "1.0.0",
"tools": [
{
"name": "gpio_write",
"description": "Write HIGH or LOW to a digital output pin",
"inputSchema": {
"type": "object",
"properties": {
"pin": { "type": "integer", "description": "GPIO pin number" },
"value": { "type": "boolean", "description": "true = HIGH, false = LOW" }
},
"required": ["pin", "value"]
}
},
{
"name": "adc_read",
"description": "Read analog voltage from ADC pin",
"inputSchema": {
"type": "object",
"properties": {
"pin": { "type": "integer", "description": "ADC pin number" }
},
"required": ["pin"]
}
}
],
"pins": [
{ "pin": 2, "name": "led", "type": "digital_output", "description": "Onboard LED" },
{ "pin": 34, "name": "sensor", "type": "adc_input", "description": "Light sensor" }
]
}
}

The client iterates over each tool in the response:

  • Converts the JSON Schema to a Zod schema (see below)
  • Registers it as an MCP tool with the AI agent
  • Binds the handler to forward calls to the correct device

For multi-device setups, tool names are prefixed: robot__gpio_write, sensor__adc_read.


The list_tools response contains:

FieldDescription
tools[]Array of tool definitions, each with name, description, and inputSchema
pins[]Pin registry mapping physical pins to logical names and types
deviceDevice identifier (from get_info)
versionFirmware version (from get_info)

Each tool includes:

  • name — unique identifier, used in JSON-RPC calls
  • description — human-readable explanation for AI agents
  • inputSchema — JSON Schema describing parameters

Each pin includes:

  • pin — physical pin number
  • name — friendly identifier (e.g., “led”, “buzzer”)
  • typedigital_output, digital_input, pwm_output, adc_input
  • description — what the pin is used for

The MCP SDK requires Zod schemas for tool validation. The client includes schema_builder.ts which converts firmware JSON Schema to Zod at runtime.

JSON Schema TypeZod Type
integerz.number().int()
numberz.number()
booleanz.boolean()
stringz.string()
  • required array → fields are required in Zod
  • Optional fields → omitted from required become optional in Zod
  • description → preserved as Zod schema metadata

Firmware JSON Schema:

{
"type": "object",
"properties": {
"pin": { "type": "integer", "description": "GPIO pin number" },
"value": { "type": "boolean", "description": "true = HIGH, false = LOW" }
},
"required": ["pin", "value"]
}

Becomes Zod:

z.object({
pin: z.number().int().describe("GPIO pin number"),
value: z.boolean().describe("true = HIGH, false = LOW")
})

AspectHardcoded ToolsMCP/U Dynamic Discovery
Adding a toolClient code change requiredAutomatic on client restart
Firmware updatesMay break clientClient adapts
Multi-deviceComplex conditional logicEach device advertises its own tools
DocumentationManualGenerated from firmware
Tool discoveryNoneComplete at startup
Custom toolsMust be added to clientJust register in firmware

The key insight: the firmware knows what it can do better than the client ever could. By pushing capability discovery to the device, we eliminate the need for manual client updates every time hardware changes.


On AVR-based boards (Uno, Mega, Nano), RAM is extremely constrained (~2KB). Including full JSON Schema for every tool can exhaust memory.

To handle this, MCP/U on AVR:

  • Omits inputSchema from list_tools response by default
  • The client receives tool names and descriptions but no parameter schemas

The client provides fallback schemas for built-in tools (gpio_write, gpio_read, pwm_write, adc_read). Custom tools on AVR may not have full schema information.