Skip to content

Groups over MCP - #45

Merged
flic merged 7 commits into
mainfrom
mcp-groups
Aug 1, 2026
Merged

Groups over MCP#45
flic merged 7 commits into
mainfrom
mcp-groups

Conversation

@flic

@flic flic commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Groups gained a value of their own in 2.7.0, but only inside Node-RED. Over MCP an assistant still
had to enumerate a dozen devices and do the arithmetic itself, and to switch a room it sent a dozen
commands.

Two tools, not two parameters

get_groups and control_group are their own tools rather than group_id parameters on the
device ones. The tool boundary is the explanation: a model reaches for control_group when the
user says "all the lights" and set_light when they mean one lamp, without having to read a
paragraph to work out which. It also keeps the schemas honest — a group has no items, so it has no
business in one built around item_id.

get_all_states gains an additive groups key. Without it a model could only discover groups by
reading a description closely enough to call a second tool. devices is untouched.

What the descriptions spend their words on

The asymmetry, because it is the part that is genuinely surprising and no amount of schema conveys
it:

  • Reading a group uses its state-capable members; commanding it uses its command-capable
    ones. Either set can be empty — a sensor group reads and cannot be commanded, a scene group is
    the reverse — so readable and controllable are separate flags rather than one.
  • Commanding never writes the value. The value is derived; it follows from what the members
    report back afterwards.
  • A group with nothing reporting has no value key at all, not a null one. A null reads as
    "the value is nothing", which is a different claim from "nobody is answering".
  • Offline members are left out of the value, so a group's value can change with nothing having been
    switched.

Groups gain tags

Things and Items have always had tags and notes, and the catalog leans on them heavily. A group
needs them more, not less: the name is usually all an assistant has to go on, and "Alla lampor"
does not say whether the outdoor lights are in it. Both now reach MCP, and get_groups takes a
tag filter so the assistant can ask for exactly the set it means.

The tag-parsing one-liner already existed twice, so it moved to resources/hal.js rather than
becoming a third copy that can drift.

Review round

Claude.ai used both tools unprompted on first contact and reviewed them. Four gaps closed:

  • control_group never documented its return. It does now — but not the per-member success
    list that was asked for, because hal2 cannot know it: commands go fire-and-forget onto the event
    bus and are paced by the rate limit, so most members have not been sent when the call returns. An
    empty failures array would look like a promise. It says so plainly and says to re-read the
    group instead. The count also became true rather than estimated: the bus listener and the tool
    now share one send path, so what comes back is the queue that was actually built.
  • get_groups gained group_id, which control_group had and it did not.
  • ha_type now expands the same category aliases as get_all_states, so ha_type="light"
    reaches a dimmer group here exactly as it reaches a dimmer device there. Documenting the
    difference was the worse of the two fixes: one parameter name meaning two things is a trap
    however well described.
  • range and percent true are spelled out in the function list.

Declined: typing value as a string|number|boolean union. A colour group takes a structured
value, so the union would reject a legitimate call — and since nothing validates against it, the
only effect would be to mislead.

Testing

276 passing. lib/group-tools.js holds the part with judgement in it — entry shaping, filtering,
name resolution — so it is testable without a running Node-RED. The cases worth naming: a silent
group carries no value key, readable and controllable vary independently in all four
combinations, an ambiguous name is refused rather than guessed at, and a report-only group refuses
a command while listing the ones that would take it.

Verified live throughout: the groups key appears in get_all_states without a connector
reconnect, Temperatur inne reads by average over 9 live members with the note that had never
left the editor, and ha_type=light returns the dimmer groups alongside the light ones.

Follow-up: the function is not fixed

The configured Value was the only question a group could answer. But the same members answer
different ones depending on the function: any true says whether a lamp is on, all true says
whether the command to turn them all on worked, count true says how many are. One group, three
questions, and only one of them reachable.

get_groups takes a function argument that computes another one from the same members for that
call. It changes nothing — the configured Value is still what the flow nodes read and what
get_all_states reports — and the reply names configured_function whenever the two differ, so an
ad-hoc reading cannot be mistaken for the setting.

The larger consequence: readable stops meaning "someone picked a function" and starts meaning
what it says — the group has members that carry a state. Every one of this location's seven command
groups turns out to have them, because switchable devices report back. They were all being reported
as unreadable because nobody had chosen a default, which is a poor reason to hide 39 members that
were answering all along.

flic and others added 7 commits August 1, 2026 16:12
A group has been usable only from inside Node-RED. Over MCP an assistant had to
enumerate a dozen devices and do the arithmetic itself, and to switch a room it
sent a dozen commands.

get_groups and control_group are their own tools rather than group_id parameters
on the device ones. The tool boundary is the explanation: a model reaches for
control_group when the user says "all the lights" and set_light when they mean
one lamp, without having to read a paragraph to work out which. It also keeps
the schemas honest — a group has no items, so it has no business in one built
around item_id.

What the descriptions spend their words on is the asymmetry, because it is the
part that is genuinely surprising: reading a group uses its state-capable
members, commanding it uses its command-capable ones, and either set can be
empty. Commanding never writes the value; the value follows from what the
members report back. A group with nothing reporting has no value key at all
rather than a null one, since a null reads as "the value is nothing" — a
different claim from "nobody is answering".

get_all_states gains an additive groups key. Without it a model could only
discover groups by reading a description closely enough to call a second tool.

Groups gain Tags to go with the Notes they have always stored and never shown.
The name is usually all an assistant has, and "Alla lampor" does not say whether
the outdoor lights are in it; get_groups takes a tag filter so it can ask for
the set it means. The tag-parsing one-liner existed twice already, so it moves
to hal.js rather than becoming three copies that can disagree.

Verified live through the existing connector, which sees the new key without a
reconnect: Temperatur inne reads 26.18 by average over 9 live members, correctly
readable and not controllable, carrying the note that had never left the editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four gaps worth closing, and one suggestion declined.

control_group never said what it returns, so a caller had to guess between
trusting a silent ack and re-reading "just in case". It now documents the shape
in the description — but not the per-member success list the review asked for,
because hal2 cannot know it: commands go fire-and-forget onto the event bus and
are paced by the rate limit, so most members have not been sent when the call
returns. An empty failures array would look like a promise. It says so instead,
and says to read the group again to confirm.

While there, the count became true rather than estimated. It reported the
member list's command-capable count; the bus listener and the tool now share one
send path, so what comes back is the queue that was actually built — a member
whose thing has gone missing shows up in skipped rather than being counted as
commanded.

get_groups gains group_id, which control_group had and it did not — an exact id
in hand had no direct way to re-read one group.

ha_type on get_groups now expands the same category aliases as get_all_states,
so ha_type="light" reaches a dimmer group here exactly as it reaches a dimmer
device there. Documenting a difference would have been the worse of the two
fixes: one parameter name meaning two things is a trap however well described.

range is spelled out as highest minus lowest; percent true says 0-100.

Declined: typing `value` as a string|number|boolean union. A colour group takes
a structured value, so the union would reject a legitimate call — and since
nothing validates against it, the only effect would be to mislead. The prose
says what each ha_type expects and that the parameter is untyped on purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The configured Value was the only question a group could answer. But the same
members answer different ones depending on the function: "any true" says whether
a lamp is on, "all true" says whether the command to turn them all on worked,
"count true" says how many are. One group, three questions, and only one of them
reachable.

get_groups takes a function argument that computes another one from the same
members for that call. It changes nothing: the configured Value is still what
the flow nodes read and what get_all_states reports, and the reply names
configured_function whenever the two differ so an ad-hoc reading cannot be
mistaken for the setting.

The larger consequence is that `readable` stops meaning "someone picked a
function" and starts meaning what it says — the group has members that carry a
state. Every one of this location's seven command groups turns out to have them:
switchable devices report back. They were all being reported as unreadable
because nobody had chosen a default for them, which is a poor reason to hide 39
members that were answering all along.

An unknown function name is refused with the list rather than quietly returning
nothing, and the schema's enum is checked against the module's own function
table by test, so the two cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The on-demand function is part of the same feature, not a patch on top of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Asking a temperature group whether all its members are true came back as false.
That is a claim about the group when it should have been a statement about the
question: no member holds a boolean, so allTrue has nothing to be true of. It
now comes back as an error naming what the members do hold and listing the
functions that fit.

Partial coverage is the opposite case and is answered rather than refused. On a
mixed group — "Alla lampor" here is 29 switches and 10 dimmers — average uses
the dimmers and ignores the rest, returning 25.4. That is a real answer to a
real question, but presented as the group's value it reads as the brightness of
the house. The reply now carries used beside live, so a mean over 10 of 39
members can be recognised as one.

The split is what makes both honest: zero usable members is a refusal, some is
a disclosure. usableCount and suitableFunctions live in the aggregation module,
so what the tool claims about a function's applicability comes from the same
code that would compute it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The README asked for it, and the sweep found the same thing in the MCP tool
descriptions — where it does more harm. Those examples exist to teach a model a
pattern, and half a pattern in a language it cannot assume it will see again
teaches nothing: get_history explained that things and items are separate
namespaces using a device called "Sjövatten Sensor" holding an item called
"Temperatur", right next to the English word it is a translation of.

Changed in text that ships: the get_history namespace example, two tag="ute"
examples, the item_name and thing_name examples, and the group examples in
get_groups and the README. docs/API.md regenerated from the catalog. Comments
carrying the same names went with them, so the codebase reads one language.

Left alone: scripts/bsky-announce.js, whose Swedish is a prompt asking a model
for an English release note. It is the author talking, not the package, and it
is excluded from the published tarball.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flic
flic merged commit fb64266 into main Aug 1, 2026
3 checks passed
@flic
flic deleted the mcp-groups branch August 1, 2026 15:13
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