feat: add Hermes as a supported MCP client (#1403)

* feat: add Hermes as a supported MCP client

Hermes Agent reads stdio MCP servers from `mcp_servers.<name>` in
`config.yaml`. REA had no Hermes client, so setup could not register it and
Hermes users had to wire the server up by hand.

- SupportedClients: add the `hermes` format and client definition, resolving
  the config path from HERMES_HOME, else the platform default that Hermes's own
  resolver uses (LOCALAPPDATA/hermes on Windows, ~/.hermes elsewhere), including
  HERMES_DATA_DIR_SUFFIX.
- ClientConfigurationDocument: parse and serialize Hermes YAML, upserting the
  rea entry into the parsed document so user comments and unrelated settings are
  preserved; emit `enabled: true` since Hermes connects entries unless disabled.
- ClientRegistrationStatus: forward the Hermes location variables so status
  checks resolve the same path as setup.
- Tests: cover path resolution on both platforms and the register/read/uninstall
  round trip, and clear the Hermes location variables between tests.

- delilah 🌸

* docs: document the Hermes client and its config path

- delilah 🌸

* refactor(setup): resolve skill destinations per client

* fix(setup): install Hermes skills in the active profile

* fix(setup): identify each client skill action uniquely

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
zayd / coldandmorluto authored and GitHub committed 2026-10-10 16:03:25 +08:00
1 parent a0e5e9db2b
commit f970b886aa
19 files changed
+512 -141

No files matched your search

@@ -1,4 +1,9 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
parse as parseYaml,
parseDocument as parseYamlDocument,
stringify as stringifyYaml,
} from "yaml";
import { isDeepStrictEqual } from "node:util";
import {
applyEdits,
@@ -70,6 +75,7 @@ export const clientConfigurationServersKey = (
switch (format) {
case "toml":
case "grok":
case "hermes":
return "mcp_servers";
case "opencode":
return "mcp";
@@ -93,6 +99,8 @@ const parseDocument = (
): Record<string, unknown> => {
if (format === "toml" || format === "grok")
return objectSchema.parse(parseToml(text));
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
if (format === "hermes") return objectSchema.parse(parseYaml(text));
// An empty file, which some clients create before any server is added,
// holds no settings to preserve.
if (isEmptyClientConfigurationText(text)) return {};
@@ -1265,6 +1273,8 @@ export const serializeClientConfiguration = (
return serializeCodexConfiguration(document, originalText);
if (format === "grok")
return serializeGrokConfiguration(document, originalText);
if (format === "hermes")
return serializeHermesConfiguration(document, originalText);
// An empty original holds no comments or settings to preserve. Writing a
// fresh document also keeps a lone BOM from shifting past the closing brace.
if (
@@ -1303,6 +1313,34 @@ export const legacyClientServerPath = (
? [parsed.serversPath[0] ?? "mcp", name]
: undefined;
/**
* Keep an existing Hermes `config.yaml` intact aside from the REA server entry.
* Hermes config files carry user comments and unrelated settings, so the rea
* entry is upserted into the parsed YAML document (which retains formatting and
* comments) rather than re-serializing the whole object.
*/
const serializeHermesConfiguration = (
document: Record<string, unknown>,
originalText: string | undefined,
): string => {
if (
originalText === undefined ||
isEmptyClientConfigurationText(originalText)
)
return stringifyYaml(document);
const serversKey = clientConfigurationServersKey("hermes");
const servers = document[serversKey];
const entry =
typeof servers === "object" && servers !== null
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
: undefined;
const yamlDocument = parseYamlDocument(originalText);
if (entry === undefined)
yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]);
else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry);
return yamlDocument.toString();
};
/** Build the stdio entry shape expected by one client's configuration dialect. */
export const clientRegistrationEntry = (
format: ClientRegistrationDialect,
@@ -1353,6 +1391,15 @@ export const clientRegistrationEntry = (
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "hermes":
// Hermes reads stdio servers from `mcp_servers.<name>` and connects them
// unless `enabled` is false, so the entry states it explicitly.
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
enabled: true,
};
default:
return {
command: executable,