Skip to content

MCP access gates: claim values, read/write split, ported hardening - #42

Merged
flic merged 6 commits into
mainfrom
gates
Jul 31, 2026
Merged

MCP access gates: claim values, read/write split, ported hardening#42
flic merged 6 commits into
mainfrom
gates

Conversation

@flic

@flic flic commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Ports the access-gate work that node-red-contrib-mcp-server gained after it was extracted from
hal2, and adds a read/write split for hal2's own built-in tool catalog. Until now a token that
could read device state could also switch every light in the house — the only per-tool distinction
that existed was MCP_ADMIN_TOOL_NAMES.

Access model

One claim name on the Event handler carries three comma-separated any-of value lists,
combined with AND:

Gate Covers Default
Read tools get_all_states, get_state, get_history, get_scenes, get_presence, get_alerts, analyze_patterns empty — any authenticated caller
Write tools set_light, control_device, control_fan, control_cover, control_spa, control_climate, activate_scene empty — same as read
Admin tools get_flow, deploy_flow admin

The read list is the floor; writes are checked on top of it, so read = family / write = ops
lets the household see everything while only ops can change anything. All new fields default to
empty
, which is no constraint, so an existing install behaves exactly as before.

Tools a caller cannot use are absent from tools/list and from the initialize blurb rather than
advertised and then refused. A direct call to a hidden tool returns an MCP tool result with
isError: true and a readable reason, so the model is told why instead of getting a generic
"tool execution failed".

Three findings that shaped it

  • control_light is an undocumented alias of set_light that the dispatcher accepts but the
    catalog does not list. A write gate keyed on a name set has to include it or it is a clean
    bypass. Pinned by its own test.
  • Read/write cannot be derived from dispatcher membershipget_scenes and get_alerts live
    in dispatchControlTools but only observe. The classification is an explicit set.
  • A tool in neither set is treated as a write, so a future addition that nobody classifies
    fails closed instead of slipping through ungated.

Scope decision: MCP only

Gates are enforced on the MCP surface, where claims are cryptographically verified. hal2Api
reads msg.claims straight off the message, which any flow can set, so gating it would be
decoration rather than a boundary; it keeps its own local Allow admin tools checkbox. The gate
therefore travels in opts through node.callTool — the single choke point both surfaces reach —
rather than being derived from the claims.

Ported verbatim

lib/claim-gate.js and lib/mcp-rpc.js are byte-identical to their upstream copies, which is
what keeps future fixes a cp away in either direction. The standalone hal2MCPServer route is
rewired onto handleRpc, replacing ~90 lines of inline dispatch. The embedded /mcp route keeps
its own dispatch deliberately: its static, hardware-filtered catalog does not fit handleRpc's
registry shape, and generalizing it would end the byte-identical portability.

claimSatisfied is deleted now that both callers use createToolGate.

Nested claim paths: a dotted claim name such as realm_access.roles is walked as a path when
no literal key of that name exists, so Keycloak-style nested roles work. A literal key always
wins, so PocketID's flat groups is untouched.

Hardening

  • Object.create(null) for the tool registry and pending-call map. Caller-supplied names like
    __proto__ previously resolved through the prototype chain, past the "Unknown tool" check, into
    a 30-second hang.
  • Duplicate tool-name warning at registration; ownership-checked unregisterMCPTool.
  • Owner-tagged route removal, so partial-deploying one of two servers sharing a path no longer
    strips its sibling's route.
  • Configurable localDebugGroupsmcp-auth already accepted it but nothing supplied it, so the
    debug token was permanently admin and the gates could not be exercised locally.

Editor and docs

The claim field moved out of the admin section, which is hidden whenever admin tools are off — the
read/write gates need the same claim. hal2MCPIn gained a per-tool list with a live tip that reads
the selected server's actual claim name. docs/API.md now tags every tool read/write/admin.

Testing

183 passing, up from 175 — including 19 borrowed claim-gate tests, 21 borrowed RPC-dispatcher
tests with the prototype-chain regression, and 10 new gate-policy tests covering the
control_light alias, unknown-tool-is-write, and empty defaults reaching everything.

Verified live against the running instance: routes register cleanly, the embedded endpoint and the
standalone server both answer real OAuth-authenticated calls after the rewire, and a read-only
token is correctly refused write tools while keeping get_scenes/get_alerts.

flic and others added 6 commits July 31, 2026 15:23
That package was extracted from hal2 and has since gained work hal2
never got back. These two modules are pure and dependency-free, so
they come across unchanged along with their 40 tests.

claim-gate.js generalises authorization from a single claim value to
comma-separated any-of lists composed with AND. mcp-rpc.js is the
JSON-RPC dispatcher, separated from Express and Node-RED so the whole
decision surface is testable; only hal2's standalone server will use
it, since the embedded route's built-in catalog does not fit its
registry shape.

Two adaptations. core/mcp-auth.js takes only the functional delta —
configurable localDebugGroups instead of a hardcoded ['admin'], so
gates with other values can be exercised locally — keeping hal2's own
header, which accurately describes it as extracted from
eventhandler.js. And mcp-rpc.test.js points at hal2's admin catalog in
core/mcp-tools.js rather than importing a lib/admin-tools module hal2
does not have, so the dispatcher is tested against the real
get_flow/deploy_flow set instead of a copied duplicate.

Nothing is wired up yet; 166 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The only per-tool distinction hal2 had was admin, so a token that
could read state could also switch every light. This adds the sets a
read/write gate can be keyed on.

Two things make the classification worth stating explicitly rather
than deriving. get_scenes and get_alerts are read-only but are handled
inside dispatchControlTools, so dispatcher membership is not the
answer. And control_light is an undocumented alias of set_light that
the dispatcher accepts but MCP_TOOLS does not list — omitting it from
the write set would be a clean bypass, so a test pins it.

toolClass() fails closed: anything unclassified counts as a write, and
a partition test turns a future unclassified catalog entry into a
build failure rather than a silent hole.

No gate reads these yet; 170 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MCP surface had one per-tool distinction — admin — so any token
that could read state could also switch every light. It now has three
classes behind comma-separated any-of value lists on a single claim,
composed with AND: read, write, and the existing admin.

Where the check sits matters. node.callTool is the choke point for
both the /mcp route and hal2Api, but only the former has claims this
package verified: hal2Api takes msg.claims straight off the message,
so a flow could self-grant. The gate is therefore built per request
from verified claims and passed in opts, never derived from `claims`
inside callTool. No gate in opts means no read/write gating, which is
exactly the local-flow case. The admin check is untouched and stays in
dispatchAdminTools, where it also guards hal2Api by requiring claims
to be present at all — only its matcher gains list support.

tools/list and the initialize blurb filter by the same gate, so a
barred tool is never advertised and then refused; denials remain
isError results carrying a reason, since MCP clients collapse a
JSON-RPC error into "tool execution failed".

The standalone server drops ~90 lines of inline dispatch for
lib/mcp-rpc.js, which brings per-tool gates on hal2MCPIn with it.
lib/common.js loses claimSatisfied — the last caller is gone and
claim-gate covers its semantics, including the single-value form, so
existing configs match identically.

Hardening ported alongside: null-prototype tool registries, so a
caller asking for "__proto__" gets an unknown-tool error rather than a
30 s hang; duplicate tool-name warnings and ownership-checked
unregistration; and owner-tagged route removal so partial-deploying
one of two servers sharing a path cannot strip its sibling.

All new gate fields default to empty, which is no constraint — an
existing install behaves exactly as before, pinned by a test that
walks the whole catalog. 175 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The claim name moves out of the admin section: it is now shared by all three
gates and had been hidden whenever admin tools were off. Read and write value
lists sit beside it, hal2MCPIn gains its per-tool list with the live claim-name
tip, and the standalone server's field is reworded for lists.

Also wires localDebugGroups through the Event handler, which mcp-auth already
accepted but nothing supplied — without it the gates cannot be exercised
locally against anything but 'admin'.

The generated API reference now tags each tool read/write/admin, so the classes
the gates act on are visible where the tools are documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lib/claim-gate.js and its tests stay byte-identical to the upstream copy, which
is what keeps porting between the two repos risk-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flic
flic merged commit 357e580 into main Jul 31, 2026
3 checks passed
@flic
flic deleted the gates branch July 31, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant