MCP Server
1. Overview
The MCP server exposes a RainMaker Neo deployment to AI assistants over the Model Context Protocol. An assistant that speaks MCP — Claude, or any other client — connects to one endpoint, authenticates the end user through an OAuth flow, and can then find that user’s devices, read their state, change it, and manage their schedules, entirely within the permissions that user already has.
Two Lambdas sit behind a single HTTP API:
mcp-serveranswers JSON-RPC 2.0 onPOST /v1/mcp. It implementsinitialize,notifications/initialized,tools/listandtools/call, and nothing else.mcp-oauth-proxyserves the discovery and OAuth endpoints (/.well-known/*,/oauth2/authorize,/oauth2/callback,/oauth2/token), brokering end-user authentication to the ESP User OIDC issuer as themcp-oauth-clientregistry client.
Both are deployed by the reusable McpOAuthConstruct
(src/mcp/handlers/core.py), wired up in
rmng_core_stack.py.
The server calls rmng’s own Go packages in-process — the same group, node and service
code the REST API uses. It is not a client of the REST API, and it has no data of its own.
2. Why these tools
An MCP tool surface is a prompt, not just an API. The model chooses which tool to call from the descriptions alone, so the surface is designed around the requests a user actually makes rather than around the resources the backend happens to have.
In rmng every node belongs to exactly one group (the user’s home) and optionally to subgroups within it (rooms). Nothing exists outside a group. So a real request nearly always starts from a human name — “the bedroom light” — and never from an id. Two consequences shape the whole surface:
Resolution must cost exactly one call. Node-scoped tools need group_id as well as
node_id. A discovery tool that returned only node_id would force a second lookup for the
group, and a model that has learned to chain lookups will chain them everywhere. So
list_devices returns group_id on every row, and every description says so.
Placement and state belong in the same answer. “Which devices are in the kitchen” and “is
the kitchen light on” are one question asked twice. list_devices answers both, because a
node’s group membership and its reported shadow are both reachable from its id.
Each description therefore names the sibling tool to use for every adjacent intent, and says plainly when not to call the tool it describes. Those clauses are load-bearing: without them models insert redundant lookup calls before every action.
Four further clauses were added after observed failures, and are covered by tests because they are behaviour, not prose:
set_paramsbounds what a parameter is. Given a request the device cannot serve, a model would otherwise invent a plausible key —{"OTA": {"Trigger": true}}for “run a firmware update” — so the description states that every device and parameter name must be one the device declared. Description alone could not carry this, though: see Validating a write.set_paramshands timed requests toset_schedule. “Every weekday at 7am” names a device and a state, which is enough to look like an immediate write. The description routes anything carrying a time, a delay or a repetition toset_schedule.list_devicesaccepts a node id inname. See the section below — an argument a model cannot fill correctly is a defect in the argument, not in the model.list_devicesandlist_groupssay what the server cannot do. With no tool describing the boundary, “create a room” or “show me last month’s energy” draws a speculative tool call instead of a straight answer. Both discovery tools now state that this server does not create, rename or move devices, homes or rooms, and does not read history.
A tool surface has no way to express “we do not do that”, so the refusal has to live in the description of the tool a model would otherwise reach for.
Validating a write
set_params is a generic write — {"<device>": {"<param>": value}} — not a typed set_hsv(), so
nothing about its shape constrains what a model may put in it. For a while the only guard was the
description quoted above, and that was not enough for three reasons that no wording fixes:
The description tells the model to skip discovery when it already holds the ids, which is the right instruction — so it can reach
set_paramshaving never seen the device’s parameter list. “Use only whatlist_devicesreported” is then a precondition the model cannot check.list_devices’paramsis the reported shadow: current values, not a specification. It carries no types, no ranges and no indication of what is writable.Device parameters are named by firmware, so the obvious name is often wrong. A colour light in the wild calls its hue, saturation and brightness
H,SandV; asked to make the light red, a model sendsHue— semantically correct and unusable.
The consequence was the worst available failure. There is no acknowledgement from a device, so an
undeclared parameter was accepted, published, ignored by firmware, and reported as
succeeded: 1 — the user was told a change happened that never did.
Writes are therefore validated against the node’s own config before publishing, in
NodeCfg.ValidateParams, driven by service/params. Two things make it safe to enforce:
Only a positive contradiction rejects. Config is firmware-reported and its ingest is never schema-checked, so sparse and malformed configs are normal. A missing
data_type, an absentpropertieslist, degenerate bounds, an undecodable config, a Matter node — each means the config cannot judge the write, and the write proceeds. Refusing on absent metadata would make working devices uncontrollable, which is a worse failure than the one being fixed.A rejection is per node and total. Nothing is published for a node whose write was refused, so a device is never left half-set, and the message names the parameters it does have. That turns a silent no-op into something a model corrects in one turn — the same reasoning as §4 Errors.
list_devices gained spec for the same reason: parameter id to type, range and semantic type
({"Colour Light": {"H": "int 0-360, hue"}}), built from the same NodeCfg the validator reads.
That last point is a constraint, not an implementation detail — a model told about a parameter in
spec and then refused for using it would be worse off than with no spec at all. The semantic
type is what makes a generic write usable: an id is whatever firmware chose, but esp.param.hue
is stable across every device, which is the mapping a typed API would have supplied for free.
Schedules are validated identically. A schedule’s action is a params payload, so leaving it
unchecked would simply teach a refused model to route the same invented parameter through
set_schedule and surface the failure at 7am.
Rejection alone turned out to be the wrong answer for one class of mistake. A model that sends {"Power": "true"} for a boolean has the intent exactly right and the JSON type wrong, and the smaller models do not read the type out of the error and correct themselves — they retry the identical payload until they run out of turns, which left device control unusable on models whose reads and OAuth worked fine, at a tool invocation per attempt. Relaxing the check was not an option either: firmware fixes a parameter’s type at creation and refuses an update of any other type (ESP_RMAKER_INVALID_ARG), so accepting the string would publish a message the device drops — the silent no-op again.
So the repair lives with the write. service/params is the seam every checked params write goes through: it reads the node’s config, repairs the payload, has NodeCfg.ValidateParams judge the repaired one, and publishes that — the publish of the repaired payload, not the check, is the load-bearing part. set_params calls ParamsService.Publish, set_schedule calls ParamsService.Check because a schedule’s action is stored rather than published, and the MCP tools are left with what is theirs: authorization, and how a refusal is reported per node. It is narrower than the validator: params.Repair turns a quoted boolean ("true", "on", "1") or a quoted number ("80") into one, against a data_type the config actually declares, and nothing else moves. An undeclared type, an unknown parameter, "red" for a boolean, 2 for a boolean, "50.5" for an int — all travel through untouched and are answered by the validator as before. The repair never makes a value acceptable: "150" on a 0-100 parameter becomes 150 and is then refused by the bounds check.
This covers the MCP caller only. Apps publish params straight to MQTT with scoped STS credentials
and no Lambda in the path, and the voice integrations publish through
Node.PublishToDeviceDesired unchecked, so neither is affected.
3. Tools
Tool |
Scope |
Purpose |
|---|---|---|
|
user |
Find devices by name, type, home or room; return placement and live state |
|
user |
Homes and rooms — structure and device counts only |
|
node |
The schedules stored on one device |
|
node(s) |
Change device state now, one or many devices at a time |
|
node |
Add, edit, remove, enable or disable a schedule |
list_devices
Reads the user’s groups once — which also grants node permissions on the request context —
then fans out per device with parallel.ProcessParallel:
one node_details read for the config and one shadow read for params and connectivity.
Filters that need no per-device I/O (node_id, group_id, subgroup_id) are applied first.
A device’s user-visible name is not in its config: rmng’s NodeCfgDevice carries an id
and a type, and the name lives in the Name parameter. The name filter therefore matches
against params as well as config.info.name, which is why shadows are read before filtering
rather than after.
name also matches the node id. That is not a semantic slip, it is the only thing that
works: a rmng node id is an arbitrary string with no distinguishing shape, so a model handed
node_switch cannot tell an id from a name, and the tool text — “call this first whenever the
user names a device” — points it at name. Documenting that name excludes ids does not teach
the model a distinction it has no way to draw; it only turns a resolvable request into an empty
result, which the model then reports to the user as “that device does not exist”. Matching ids
costs one substring compare and removes the failure, so both argument descriptions say plainly
that an unclassifiable identifier belongs in name.
A device whose config or shadow cannot be read is still returned, with an error field. One
unreachable device must not blind the assistant to the rest of the home.
fields projects the response, accepting both top-level keys and dot paths
(params.Light.Power), and returns each value under the path it was asked for. error survives
the projection whether or not it was asked for: without it a device whose read failed is
indistinguishable from one that is genuinely offline, and the assistant reports the wrong thing.
list_groups
Structure only — ids, names, device counts, and node ids when include_devices is set. It
deliberately returns no parameters, connectivity or config, so there is exactly one tool that
answers questions about devices.
subgroups is always serialised, empty array included. An omitted key reads to an agent as
“rooms unknown” and earns a second call; [] says plainly that the home has no rooms. This is
why subgroups carries no omitempty while node_ids — which is genuinely opt-in behind
include_devices — does.
set_params
Authorizes and publishes per node, and reports each node separately: a caller cannot smuggle a foreign node through alongside their own, and one unreachable device does not hide the writes that did land. When no node could be written the call is reported as a tool failure rather than a partial success.
The write goes to the desired shadow, so success means published, not applied.
list_schedules / set_schedule
rmng holds the authoritative schedule set in the cloud (node_details, service schedule),
unlike classic RainMaker where the firmware merges operations it is sent. set_schedule is
therefore a read-modify-write: it reads the current array, applies the operation in Go,
and writes the whole array back through ScheduleService.Put, which bumps the version and
pushes the full set to the device. enable/disable set the stored enabled flag; there is
no separate firmware operation. Concurrent edits to one node’s schedules will clobber each
other, and the tool description says so.
Triggers are written in human terms and converted to the device’s wire form: time in
HH:MM becomes m, minutes past midnight; days becomes d, a weekday bitmask with Monday
in the lowest bit (daily 127, weekdays 31, weekends 96). A trigger already in device
form, or a relative rsec trigger, passes through untouched so a listed schedule can be
echoed straight back.
4. Errors
The server distinguishes two kinds of failure, and clients must too.
Protocol failures — unparseable body, wrong JSON-RPC version, unknown method, unknown tool, missing or wrong-audience token — are JSON-RPC errors (
-32700,-32600,-32601,-32602,-32001). No argument change fixes these.Tool failures — a device the caller cannot reach, a missing argument, an unparseable trigger, a parameter the device never declared — are successful JSON-RPC responses whose result carries
isError: trueand a message written for the model to act on (“call list_schedules to find it”).
That split is what lets an assistant recover in one turn instead of reporting a dead end. Internal error detail never reaches the client; it is logged instead. The one exception is an error about the caller’s own arguments — a JSON decode failure names the field and the shape it expected, which is the thing that lets the model fix its next call, and reveals nothing about how the service is built.
A partly-failed set_params is the one case that is neither: some devices were written and some
refused the parameters, which is an ordinary success response. It carries a summary naming what
did not land, because a model reading succeeded alone would otherwise report the whole request
done — tolerable when a node was merely unreachable, not when it rejected the write.
5. Authentication
End-user auth is OIDC only. Every request must carry Authorization: Bearer <token>, even
initialize, so a client discovers the OAuth flow on its very first call — an unauthenticated
request returns 401 with a WWW-Authenticate: Bearer resource_metadata="..." header
(RFC 9728). initialize and notifications/initialized do not validate the token;
tools/list and tools/call do.
Validation requires aud == mcp-oauth-client: a first-party app token must not authorize MCP
calls (RFC 9700 audience restriction). ID tokens are rejected; only access tokens are
accepted. The JWKS is read from SSM and cached in-process.
The authenticated subject becomes an ordinary rmngctx.RmngContext, so every RBAC check in
the DB layer applies exactly as it does for the REST API. Authorization is not re-implemented
here: authorizeNodeForUser is the single chokepoint that checks both group access and node
membership, and every node-scoped tool goes through it.
6. Tool catalogue
docs/mcp/rainmaker-mcp.json is a machine-readable snapshot of the live tool registry: names, descriptions and input schemas. Offline consumers — the eval framework, docs generators — read it as the answer to “what does this server expose”, so it must never drift from the code.
It is also the source for the human-readable tool reference published at
https://api.docs.neo.rainmaker.espressif.com/mcp/, with the raw catalogue served beside it at
/mcp/rainmaker-mcp.json — the same page-plus-raw-spec convention the MQTT and event
references follow. scripts/generate_mcp_reference.py renders it and the sync_mcp CI job
publishes it, so docs/mcp/rainmaker-mcp.json stays the only copy in the tree.
TestToolCatalogMatchesSnapshot regenerates the catalog from createServer() and
byte-compares it, failing the build on any divergence. Regenerate with:
make update-mcp-schema
The resulting diff is the review artifact for a tool change. Because the descriptions decide how models behave, rewording one is a behaviour change and should be evaluated as such.
The HTTP surface in front of these tools — the /v1/mcp endpoint and the OAuth 2.0 proxy that
authorizes it — is specified separately as OpenAPI in
docs/api/MCP_Api_Swagger.yaml, rendered as the MCP API
tab of the API reference site.