Skip to content

Commit 8b13239

Browse files
authored
feat(site): add Hermes Agent support to setup wizard (#2208)
* feat(site): add Hermes Agent support to setup wizard Hermes reads its MCP servers from ~/.hermes/config.yaml under an `mcp_servers` mapping keyed by server name. That is incompatible with the `mcpServers:` list Continue expects, and Continue was the only other client with `configFormat: "yaml"`, so the wizard's YAML arm had no id guard at all — a metadata-only entry would have handed Hermes users a well-formed config their client ignores. Adds the client entry, a Hermes branch covering stdio (uvx and Docker) and Streamable HTTP with an authenticated-headers variant, and the brand icon for the picker tile. The new test pins each YAML client to its own root key: the existing per-client test only asserts that a branch emitted something, so it stays green when a client falls through to the other schema. * docs(internal): refresh the setup-wizard data map in site/AGENTS.md The array list still described the pre-rework model: it named `connectionsData` and `deploymentData`, neither of which exists in the frontmatter anymore, and omitted `scopeData` plus the two client-capability lists that replaced them. The client count was already one behind before this branch added Hermes. Also records that a `configFormat` shared by clients with incompatible schemas needs its own id guard, which is the step this file's checklist did not cover. * fix(site): stop offering SSE in the Hermes instructions Hermes can switch a server to SSE, but the ha-mcp endpoint cannot receive it: the HTTP run path pins `mcp.run(transport="http")`, so the MCP path is POST-only Streamable HTTP and answers the GET an SSE-style pre-flight sends with 405 (documented on `ProbeAccessLogFilter` in `src/ha_mcp/__main__.py`). The instructions now say to leave the transport alone and name that limit. The claim about the client was verified, the one about our own server was not — so the guard added here asserts the rendered Hermes instructions never offer `transport: sse`, with a plausibility floor so an empty capture cannot satisfy the absence check.
1 parent 03bdbad commit 8b13239

4 files changed

Lines changed: 213 additions & 11 deletions

File tree

site/AGENTS.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,26 @@ npm run preview # Preview production build locally
1313

1414
## Setup Wizard (`site/src/pages/setup.astro`)
1515

16-
Single-file Astro page that drives the on-site setup flow. Both the metadata (which clients/platforms/connections/deployments exist) and the per-client instruction prose live in this one file.
16+
Single-file Astro page that drives the on-site setup flow. Both the metadata (which clients/platforms/scopes exist) and the per-client instruction prose live in this one file.
1717

18-
**Data**four pre-sorted JS arrays at the top of the component frontmatter:
18+
**Data**three pre-sorted JS arrays at the top of the component frontmatter, plus two client-capability lists:
1919

2020
```ts
21-
const clientsData = [...] // 19 supported AI clients
22-
const platformsData = [...] // macOS / Linux / Windows / Docker
23-
const connectionsData = [...]// local / network / remote
24-
const deploymentData = [...] // uvx / docker / ha-addon / cloudflared / webhook-proxy
21+
const clientsData = [...] // 21 supported AI clients
22+
const platformsData = [...] // macOS / Linux / Windows / Docker
23+
const scopeData = [...] // local / remote
24+
const stdioOnlyClients = [..] // need the fastmcp-remote bridge for HTTP
25+
const remoteOnlyClients = [..]// cannot use a local endpoint, need public HTTPS
2526
```
2627

27-
These feed the picker tiles in the markup section AND the wizard `<script>` block (`state.client`, `state.connection`, etc.).
28+
These feed the picker tiles in the markup section AND the wizard `<script>` block (`state.client`, `state.scope`, etc.).
2829

29-
**Instruction templates** are JS template literals inside the `<script>` block, keyed off `state.client.id` / `platformId` / `state.connection.id` / `state.proxy`. Cross-cutting troubleshooting and restart-related help lives in `site/src/pages/faq.astro`; OS-specific install walkthroughs live in `guide-macos.astro` / `guide-windows.astro`.
30+
**Instruction templates** are JS template literals inside the `<script>` block, keyed off `state.client.id` / `state.client.configFormat` / `platformId` / `state.method` / `state.remotePath`. Cross-cutting troubleshooting and restart-related help lives in `site/src/pages/faq.astro`; OS-specific install walkthroughs live in `guide-macos.astro` / `guide-windows.astro`.
3031

31-
**Adding a new client / platform / connection / deployment:**
32+
**Adding a new client / platform / scope / server method / remote path:**
3233

3334
1. Add an entry to the appropriate inline array (insert at the right `order` position). Keep each array ordered by the `order` field — the wizard renders entries in array order without re-sorting.
34-
2. Add a wizard branch in the `<script>` block keyed off the new entry's `id`. Match neighboring patterns: JSON clients add an `else if` in the JSON config builder; CLI clients add a CLI command emit; UI clients add an `instruction-block` div with click steps. See `cursor` / `chatgpt` / `claude-code` / `cloudflared` for examples.
35+
2. Add a wizard branch in the `<script>` block keyed off the new entry's `id`. Match neighboring patterns: JSON clients add an `else if` in the JSON config builder; CLI clients add a CLI command emit; UI clients add an `instruction-block` div with click steps. A `configFormat` shared by clients with incompatible schemas needs an `id` guard of its own — see the `hermes` branch, which would otherwise inherit Continue's YAML shape. See `cursor` / `chatgpt` / `claude-code` / `cloudflared` for examples.
3536
3. If the addition has cross-cutting troubleshooting content (PATH issues, restart requirements, version requirements), add it to `faq.astro`.
3637

3738
## Accessibility & checks

site/public/logos/hermes.png

6.38 KB
Loading

site/src/pages/setup.astro

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,18 @@ const clientsData = [
243243
"httpNote": "Requires HTTPS - Remote deployment required",
244244
"clientNote": "Custom connected apps are OAuth-only. Only the HA-MCP Server component's legacy OAuth mode currently works with Spark — see the steps below.",
245245
},
246+
{
247+
"id": "hermes",
248+
"name": "Hermes Agent",
249+
"company": "Nous Research",
250+
"logo": "/logos/hermes.png",
251+
"transports": ["stdio", "sse", "streamable-http"],
252+
"configFormat": "yaml",
253+
"configLocation": "~/.hermes/config.yaml",
254+
"accuracy": 4,
255+
"order": 21,
256+
"clientNote": "Run /reload-mcp in an active session after editing the config — Hermes picks up MCP changes without a restart.",
257+
},
246258
].map(c => ({ ...c, logo: withBase(c.logo) }));
247259
248260
const platformsData: { id: string; name: string; icon: IconName; order: number }[] = [
@@ -2260,6 +2272,64 @@ ingress:
22602272
</div>`);
22612273
}
22622274

2275+
} else if (state.client.configFormat === 'yaml' && state.client.id === 'hermes') {
2276+
// Hermes keeps its servers in ~/.hermes/config.yaml under `mcp_servers`,
2277+
// a mapping keyed by server name — not Continue's list of
2278+
// {name, type, url} entries, so it needs its own YAML shape here.
2279+
if (isStdio) {
2280+
if (isDocker) {
2281+
config = `mcp_servers:
2282+
home-assistant:
2283+
command: "docker"
2284+
args:
2285+
- "run"
2286+
- "--rm"
2287+
- "-i"
2288+
- "-v"
2289+
- "ha-mcp-data:/home/mcpuser/.ha-mcp"
2290+
- "-e"
2291+
- "HOMEASSISTANT_URL={{HOMEASSISTANT_URL}}"
2292+
- "-e"
2293+
- "HOMEASSISTANT_TOKEN={{HOMEASSISTANT_TOKEN}}"
2294+
- "ghcr.io/homeassistant-ai/ha-mcp:latest"`;
2295+
} else {
2296+
config = `mcp_servers:
2297+
home-assistant:
2298+
command: "uvx"
2299+
args:
2300+
- "ha-mcp@latest"
2301+
env:
2302+
HOMEASSISTANT_URL: "{{HOMEASSISTANT_URL}}"
2303+
HOMEASSISTANT_TOKEN: "{{HOMEASSISTANT_TOKEN}}"`;
2304+
}
2305+
} else {
2306+
config = `mcp_servers:
2307+
home-assistant:
2308+
url: "${mcpUrl}"`;
2309+
}
2310+
if (!isStdio) {
2311+
instructions.push(`<div class="instruction-block">
2312+
<h3 class="instruction-title">Alternative: With Authentication Headers</h3>
2313+
<div class="text-sm text-slate-300 space-y-2">
2314+
<p class="text-slate-400">If your endpoint is behind a Bearer token, add a <code class="bg-slate-800 px-1 rounded">headers</code> block. Replace the config above with:</p>
2315+
<pre class="bg-slate-800 rounded p-2 text-xs overflow-x-auto">mcp_servers:
2316+
home-assistant:
2317+
url: "${mcpUrl}"
2318+
headers:
2319+
Authorization: "Bearer \${env:HA_TOKEN}"</pre>
2320+
<p class="text-slate-400">Hermes resolves <code class="bg-slate-800 px-1 rounded">\${env:VAR}</code> (and <code class="bg-slate-800 px-1 rounded">\${VAR}</code>) from <code class="bg-slate-800 px-1 rounded">~/.hermes/.env</code>, so the token stays out of the config file. For an endpoint that speaks OAuth instead, drop the headers and set <code class="bg-slate-800 px-1 rounded">auth: oauth</code> on the entry.</p>
2321+
<p class="text-slate-400">Leave the transport at its default. Hermes can switch a server to SSE, but the ha-mcp endpoint is Streamable HTTP only — it is POST-only and answers an SSE-style pre-flight with <code class="bg-slate-800 px-1 rounded">405</code>.</p>
2322+
</div>
2323+
</div>`);
2324+
}
2325+
instructions.push(`<div class="instruction-block">
2326+
<h3 class="instruction-title">Hermes Notes</h3>
2327+
<div class="text-sm text-slate-400 space-y-2">
2328+
<p><strong class="text-slate-300">Tool names:</strong> ha-mcp tools show up as <code class="bg-slate-800 px-1 rounded">mcp__home_assistant__&lt;tool&gt;</code> (Hermes replaces the hyphen in the server name with an underscore).</p>
2329+
<p><strong class="text-slate-300">Trimming the catalog:</strong> <code class="bg-slate-800 px-1 rounded">tools.include</code> / <code class="bg-slate-800 px-1 rounded">tools.exclude</code> on the server entry take exact tool names or globs. Use the <strong>original</strong> ha-mcp names (e.g. <code class="bg-slate-800 px-1 rounded">ha_get_state</code>), not the prefixed ones.</p>
2330+
<p><a href="https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Hermes MCP docs →</a></p>
2331+
</div>
2332+
</div>`);
22632333
} else if (state.client.configFormat === 'yaml') {
22642334
// Continue uses YAML format
22652335
if (isStdio) {

tests/src/unit/test_astro_setup_js_behavior.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Behavioural tests for ``site/src/pages/setup.astro``'s wizard script.
22
33
The setup wizard's correctness is a multidimensional grid (5 server
4-
methods x 19 clients x 2 scopes x 4 platforms x 4 remote paths). The
4+
methods x 21 clients x 2 scopes x 4 platforms x 4 remote paths). The
55
script is one giant state machine driving per-client instruction
66
templates; a typo or condition inversion silently breaks setup for one
77
or more branches and the only signal today is a user complaint.
@@ -19,6 +19,9 @@
1919
``config-output`` AND that the emitted content is non-empty (so a typo
2020
that drops the JSON / CLI / instruction block is caught — not just "no
2121
JS error", which fires the moment any badge renders).
22+
* Pin the schema each YAML client gets, which the loop above cannot see:
23+
both Continue and Hermes emit valid YAML, so a client falling through to
24+
the other one's branch still counts as "emitted something".
2225
2326
The harness rebuilds Astro's ``<script define:vars={...}>`` injection
2427
by reading the real arrays out of the page frontmatter, so changes to
@@ -588,6 +591,134 @@ def test_generate_config_emits_for_client(
588591
)
589592

590593

594+
# ---------------------------------------------------------------------------
595+
# YAML dialects
596+
# ---------------------------------------------------------------------------
597+
598+
599+
# client id -> (marker its own config must carry, marker that belongs to the
600+
# other YAML client and must therefore not appear).
601+
YAML_DIALECTS = {
602+
"continue": ("mcpServers:", "mcp_servers:"),
603+
"hermes": ("mcp_servers:", "mcpServers:"),
604+
}
605+
606+
607+
class TestYamlDialects:
608+
"""``configFormat: "yaml"`` covers two incompatible schemas.
609+
610+
Continue takes a ``mcpServers:`` list of ``{name, type, url}`` entries;
611+
Hermes takes a ``mcp_servers:`` mapping keyed by server name in
612+
``~/.hermes/config.yaml``. Both are well-formed YAML, so a client that
613+
falls through to the other one's branch still emits *a* config and
614+
``TestPerClientInstructionTemplate`` — which only asserts that the branch
615+
emitted something — stays green while the user copies a config their
616+
client ignores. These assertions pin the dialect itself.
617+
"""
618+
619+
@pytest.mark.parametrize("stdio", [True, False], ids=["stdio", "http"])
620+
@pytest.mark.parametrize(
621+
"client_id", sorted(YAML_DIALECTS), ids=sorted(YAML_DIALECTS)
622+
)
623+
def test_yaml_client_gets_its_own_schema(
624+
self,
625+
client_id: str,
626+
stdio: bool,
627+
setup_script: str,
628+
prelude: str,
629+
wizard_vars: dict[str, Any],
630+
) -> None:
631+
clients = {c["id"]: c for c in wizard_vars["clientsData"]}
632+
assert clients[client_id]["configFormat"] == "yaml", (
633+
f"test premise: {client_id!r} must be a YAML-config client"
634+
)
635+
own_marker, foreign_marker = YAML_DIALECTS[client_id]
636+
637+
# stdio-local always asks for the platform; an HTTP method serving a
638+
# client with native HTTP support reaches config after scope alone.
639+
flow = (
640+
_click("server-method", "stdio-local")
641+
+ _click("client", client_id)
642+
+ _click("platform", "macos")
643+
if stdio
644+
else _click("server-method", "ha-addon")
645+
+ _click("client", client_id)
646+
+ _click("scope", "local")
647+
)
648+
result = run_script(
649+
setup_script,
650+
prelude=prelude,
651+
initial_html=_build_wizard_dom(wizard_vars),
652+
invoke=(
653+
flow + "document.body.dataset.configCode = "
654+
"document.querySelector('#config-output code').textContent || '';\n"
655+
),
656+
)
657+
_assert_clean_init(result)
658+
match = re.search(r'data-config-code="([^"]*)"', result.dom)
659+
assert match is not None, "config-output was not captured"
660+
config = match.group(1)
661+
662+
assert own_marker in config, (
663+
f"{client_id} ({'stdio' if stdio else 'http'}): emitted config is "
664+
f"missing its own root key {own_marker!r}; config={config!r}"
665+
)
666+
assert foreign_marker not in config, (
667+
f"{client_id} ({'stdio' if stdio else 'http'}): emitted config "
668+
f"carries {foreign_marker!r}, which belongs to the other YAML "
669+
f"client — the branch fell through to the wrong schema; "
670+
f"config={config!r}"
671+
)
672+
673+
def test_hermes_http_instructions_do_not_offer_sse(
674+
self,
675+
setup_script: str,
676+
prelude: str,
677+
wizard_vars: dict[str, Any],
678+
) -> None:
679+
"""Hermes can speak SSE; the ha-mcp endpoint cannot.
680+
681+
``mcp.run(transport="http")`` serves Streamable HTTP only, and a GET
682+
against the MCP path — which is what an SSE-style pre-flight sends —
683+
is answered with 405 (see ``ProbeAccessLogFilter`` in
684+
``src/ha_mcp/__main__.py``). Telling a Hermes user they may add
685+
``transport: sse`` therefore points them at a transport this server
686+
does not serve, so the instructions must not offer it.
687+
"""
688+
result = run_script(
689+
setup_script,
690+
prelude=prelude,
691+
initial_html=_build_wizard_dom(wizard_vars),
692+
invoke=(
693+
_click("server-method", "ha-addon")
694+
+ _click("client", "hermes")
695+
+ _click("scope", "local")
696+
+ """
697+
const instructionsEl = document.getElementById('setup-instructions');
698+
document.body.dataset.instructionsHtml = String(
699+
(instructionsEl && instructionsEl.innerHTML) || ''
700+
);
701+
"""
702+
),
703+
)
704+
_assert_clean_init(result)
705+
match = re.search(r'data-instructions-html="([^"]*)"', result.dom)
706+
assert match is not None, "setup-instructions was not captured"
707+
html = match.group(1)
708+
709+
# Plausibility floor: an empty capture must not pass the absence
710+
# assertion below by saying nothing at all.
711+
assert "Streamable HTTP" in html, (
712+
"hermes: HTTP instructions never rendered — the absence check "
713+
f"below would pass vacuously; html={html[:300]!r}"
714+
)
715+
assert "transport: sse" not in html, (
716+
"hermes: the instructions offer `transport: sse`, but the ha-mcp "
717+
"endpoint is Streamable HTTP only and answers an SSE-style "
718+
"pre-flight with 405"
719+
)
720+
721+
591722
class TestStdioBridgeChoice:
592723
"""The generated stdio-bridge config must use a bridge with a bounded SDK.
593724

0 commit comments

Comments
 (0)