fix(installer): Hermes keeps your MCP entry and gets no platform_toolsets line (#2774)

Root cause, two parts:

1. Install and refresh appended `mcp-codegraph` to
   `platform_toolsets.cli` in `~/.hermes/config.yaml`. It was never
   needed. Hermes loads every enabled MCP server on a platform whose list
   names none (hermes_cli/tools_config.py `_merge_mcp_servers`, a rule
   present back in May 2026 when the target was written). The name
   itself is harmful:
   - Hermes up to 0.22.2 rejects it at startup ("Unknown toolsets").
   - Hermes 0.22.4 maps `mcp-<server>` onto the server, so the list
     becomes an MCP allowlist and every other server disappears from CLI
     sessions.
   A user who deleted the line got it back on the next upgrade.
2. When the `mcp_servers.codegraph` block differed from the template, it
   was replaced wholesale. That reverted an absolute command path, the
   timeouts, `enabled: false` and `env`. The line-based YAML code also
   missed some shapes:
   - a codegraph key written on one line or quoted, which got a second
     codegraph key;
   - a 4-space-indented `mcp_servers`, which came out unparseable;
   - a one-line `mcp_servers: {…}`, which got a second top-level key, and
     Hermes's PyYAML reader then dropped the user's servers.
   CRLF files came back LF.

Fix:
- CodeGraph never writes `platform_toolsets`.
- A `- mcp-codegraph` line an earlier install left in
  `platform_toolsets.cli` is removed, or becomes `- codegraph` when the
  list is an allowlist naming other MCP servers (the spelling every Hermes
  version reads as the server). An allowlist that leaves CodeGraph out
  gets a note, not an edit.
- An existing `mcp_servers.codegraph` entry is the user's: nothing in it
  changes. A block entry missing `command` or `args` gets just those, at
  its own indent.
- A new entry goes in at the indent its siblings use, and a codegraph key
  is found however it is written. `mcp_servers: {}` becomes a block.
- A one-line mapping that holds other servers makes install refuse with a
  clear error and change nothing. One that holds codegraph is left as
  written, with a note, and uninstall says it can't remove it.
- The file keeps its line breaks, and is written only when something was
  edited.
- Uninstall also removes a `- codegraph` the refresh may have written.

Hermes references (NousResearch/hermes-agent v0.22.4, 64dbae64):
- hermes_cli/tools_config.py `_get_platform_tools` / `_merge_mcp_servers`
  ("mcp-<server> names the same server as its bare alias: either spelling
  selects only that server").
- hermes_cli/toolset_validation.py `saved_toolset_resolver`.
- website/docs/reference/mcp-config-reference.md (command, args, env,
  enabled, timeout, connect_timeout).
- hermes_cli/tools_config.py at 1b9c539c (2026-05-17) for the May 2026
  rule.

Validation:
- installer-targets.test.ts: 13 new Hermes tests. 3 existing tests that
  asserted the `mcp-codegraph` write are updated; the #456 same-indent
  list case now proves the old line is removed cleanly. With main's
  hermes.ts put back, those 16 fail, all on behavior. The 7 other Hermes
  tests pass on both.
- The real CLI on this Windows machine against a private HERMES_HOME:
  `install --yes`, a second `install --yes`, `install --refresh` and
  `uninstall --yes --keep-cli`, for 14 config.yaml shapes. Each result
  was loaded with PyYAML (the reader Hermes uses), checked for duplicate
  keys, and run through a re-implementation of the cited selection rule.
  Of 42 results:
  - main: 2 unparseable, 10 with duplicate keys, 26 carrying
    `mcp-codegraph`, 10 where Hermes 0.22.4 would load only codegraph in
    CLI sessions, 3 converted CRLF to LF, and the customized entry
    reverted.
  - The fix: 0, 0, 0, 0, 0, the customized entry kept byte-for-byte, and
    1 install refused by design (a one-line `mcp_servers` holding other
    servers).

Fixes #2485

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-22 02:29:50 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5a6917b437
commit 7dfef8451f
3 changed files with 481 additions and 248 deletions
+1
View File
@@ -38,6 +38,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph upgrade` no longer wipes your agent instructions file, such as a global `AGENTS.md`, `CLAUDE.md` or `GEMINI.md`, when it refreshes CodeGraph's section in it: a stray `<!-- CODEGRAPH_START -->` line above that section, like one left behind when part of an older section was deleted by hand, made the refresh replace everything in between, often the whole file. CodeGraph now rewrites only its own section, in `codegraph install` and `codegraph uninstall` too, and keeps everything around it exactly as it was, Windows line endings included. Thanks @pzaj2 for the report. (#2506)
- `codegraph install`, `upgrade` and `uninstall` now write through an instructions or config file that is a symbolic link, such as a `~/.codex/AGENTS.md` or `~/.claude.json` linked from your dotfiles, instead of replacing the link with a copy: the shared file gets CodeGraph's settings, uninstall takes them out again, and the link stays in place. A link CodeGraph can't write through, like one to a read-only file or into a folder that doesn't exist, is now reported by name and left alone, and an upgrade still refreshes your other agents. Thanks @jguerra6 for the report and @danusha2345 for the first fix. (#2721)
- `codegraph install` and `upgrade` no longer break Codex's `config.toml` when CodeGraph's `[mcp_servers.codegraph]` entry is written differently from how CodeGraph writes it, such as indented, with spaces or quotes inside the brackets, or with Windows line endings: the entry is now updated in place, where before a second copy was added and Codex could load none of the file. A file already broken that way is repaired on the next install or upgrade, an example of the entry inside a multiline setting such as `developer_instructions` is no longer mistaken for it, uninstall removes the entry however it is written, and an entry you wrote with dotted keys or as an inline table is left as you wrote it. When your `mcp_servers` settings are one inline table, CodeGraph now says it can't add itself instead of breaking the file. Thanks @dencoseca for the report, and @danusha2345 and @rudycelekli for the first fixes. (#2562)
- For Hermes Agent, `codegraph install` and `upgrade` no longer add `mcp-codegraph` to `platform_toolsets.cli`. Hermes up to 0.22.2 reported that name as an unknown toolset on every start, and Hermes 0.22.4 reads it as the only MCP server to load, hiding your others from CLI sessions; an entry an earlier install added is taken out again. A refresh also no longer resets your own `mcp_servers.codegraph` settings, such as an absolute command path, timeouts or `enabled: false`, and a config indented or written differently from Hermes's default no longer gets a duplicate or unreadable entry. Thanks @Huy3ko for the report and @conifercone for confirming it. (#2485)
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
- Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332)
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)
+179 -35
View File
@@ -802,33 +802,28 @@ describe('Installer targets — partial-state idempotency', () => {
expect(cliAfter.mcpServers.codegraph).toBeDefined();
});
it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => {
it('hermes: install adds the codegraph MCP server and leaves platform_toolsets alone, preserving existing yaml (#2485)', () => {
const hermes = getTarget('hermes')!;
const config = path.join(tmpHome, '.hermes', 'config.yaml');
fs.mkdirSync(path.dirname(config), { recursive: true });
fs.writeFileSync(config, [
'model:',
' default: qwen-3.7',
'mcp_servers:',
' other:',
' command: other',
const toolsets = [
'platform_toolsets:',
' cli:',
' - hermes-cli',
' discord:',
' - hermes-discord',
'',
].join('\n'));
].join('\n');
fs.writeFileSync(config, ['model:', ' default: qwen-3.7', 'mcp_servers:', ' other:', ' command: other', toolsets].join('\n'));
const result = hermes.install('global', { autoAllow: true });
expect(result.files[0].action).toBe('updated');
const body = fs.readFileSync(config, 'utf-8');
expect(body).toContain('model:\n default: qwen-3.7');
expect(body).toContain('mcp_servers:\n other:\n command: other');
expect(body).toContain(' codegraph:\n command: codegraph');
expect(body).toContain(' - hermes-cli');
expect(body).toContain(' - mcp-codegraph');
expect(body).toContain(' discord:\n - hermes-discord');
expect(body).toContain('mcp_servers:\n other:\n command: other\n codegraph:\n command: codegraph');
// No toolset entry: a list naming no MCP server loads every enabled one.
expect(body.endsWith(toolsets)).toBe(true);
expect(body).not.toContain('mcp-codegraph');
const second = hermes.install('global', { autoAllow: true });
expect(second.files[0].action).toBe('unchanged');
@@ -852,13 +847,14 @@ describe('Installer targets — partial-state idempotency', () => {
// Regression for #456: PyYAML's default block style writes list items at the
// SAME indent as the parent key (`cli:` and its `- hermes-cli` are both at
// indent 2). The pre-fix line-based patcher mistook that first list item for
// the next sibling key, truncated the cli block, and spliced `- mcp-codegraph`
// at indent 4 BEFORE the existing items — producing unparseable YAML.
it('hermes: install preserves PyYAML-default list-at-same-indent style (issue #456)', () => {
// the next sibling key and truncated the cli block. Install no longer adds to
// that list (#2485), but it still has to read it to take out the
// `- mcp-codegraph` line an earlier install appended there.
it('hermes: install reads the PyYAML-default list-at-same-indent style (issue #456)', () => {
const hermes = getTarget('hermes')!;
const config = path.join(tmpHome, '.hermes', 'config.yaml');
fs.mkdirSync(path.dirname(config), { recursive: true });
const original = [
const lines = [
'model:',
' default: gpt-4o',
'platform_toolsets:',
@@ -873,29 +869,20 @@ describe('Installer targets — partial-state idempotency', () => {
' discord:',
' - hermes-discord',
'',
].join('\n');
fs.writeFileSync(config, original);
];
// As an earlier install left it: our entry appended at the items' indent.
fs.writeFileSync(config, [...lines.slice(0, 9), ' - mcp-codegraph', ...lines.slice(9)].join('\n'));
hermes.install('global', { autoAllow: true });
const body = fs.readFileSync(config, 'utf-8');
// mcp-codegraph appended at the same 2-space indent as existing items
expect(body).toContain('\n - mcp-codegraph\n');
// hermes-cli preserved
expect(body).toContain('\n - hermes-cli\n');
// Sibling sections kept their indent — `telegram:` is still a key under
// platform_toolsets, not promoted up.
expect(body).toContain('\n telegram:\n - hermes-telegram\n');
expect(body).toContain('\n discord:\n - hermes-discord\n');
// The old entry is gone and every other line is where it was.
expect(body).not.toContain('mcp-codegraph');
expect(body.startsWith(lines.join('\n'))).toBe(true);
// No list items leaked to the platform_toolsets level (indent 0).
expect(body).not.toMatch(/^- browser/m);
expect(body).not.toMatch(/^- hermes-telegram/m);
// The whole platform_toolsets block extracted by line search should
// start with `cli:` and not contain a stray 4-space `mcp-codegraph`
// appearing before the rest of the existing items.
expect(body).toContain(' cli:\n - hermes-cli\n - browser');
// Idempotent
const second = hermes.install('global', { autoAllow: true });
expect(second.files[0]?.action).toBe('unchanged');
@@ -918,15 +905,15 @@ describe('Installer targets — partial-state idempotency', () => {
hermes.install('global', { autoAllow: true });
const installed = fs.readFileSync(config, 'utf-8');
expect(installed).toContain('- mcp-codegraph');
expect(installed).not.toContain('mcp-codegraph');
expect(installed.startsWith(original)).toBe(true);
expect(installed).toContain('codegraph:');
hermes.uninstall('global');
const body = fs.readFileSync(config, 'utf-8');
expect(body).not.toContain('mcp-codegraph');
expect(body).not.toContain('command: codegraph');
expect(body).toContain(' cli:\n - hermes-cli\n - browser');
expect(body).toContain(' telegram:\n - hermes-telegram');
expect(body.startsWith(original)).toBe(true);
});
it('opencode: uninstall removes only mcp.servers.codegraph, preserves comments and siblings', () => {
@@ -1850,6 +1837,163 @@ describe('Installer — Codex install, re-install, refresh and uninstall find th
});
});
// ---------------------------------------------------------------------------
// Hermes: no toolset entry, and the user's MCP entry is theirs (#2485)
//
// Install and refresh appended `mcp-codegraph` to `platform_toolsets.cli`.
// Hermes up to 0.22.2 rejects that name at startup ("Unknown toolsets"), and
// 0.22.4 reads it as naming codegraph, which turns the list into an MCP
// allowlist and hides every other server from CLI sessions
// (hermes_cli/tools_config.py `_merge_mcp_servers`). No entry is needed: a
// list that names no MCP server loads every enabled one. The refresh also
// replaced the whole `mcp_servers.codegraph` block with CodeGraph's template,
// undoing an absolute command path, timeouts, `enabled: false` and env.
// ---------------------------------------------------------------------------
describe('Installer — Hermes never writes platform_toolsets and keeps the user\'s MCP entry (#2485)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('hermes2485-home');
tmpCwd = mkTmpDir('hermes2485-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
const hermes = getTarget('hermes')!;
const config = () => path.join(tmpHome, '.hermes', 'config.yaml');
function writeConfig(text: string): void {
fs.mkdirSync(path.dirname(config()), { recursive: true });
fs.writeFileSync(config(), text);
}
const read = () => fs.readFileSync(config(), 'utf-8');
const codegraphKeys = (text: string) => text.split(/\r?\n/).filter((l) => /^\s*["']?codegraph["']?\s*:/.test(l)).length;
const OTHERS = 'mcp_servers:\n agentmail:\n command: npx\n args:\n - -y\n - agentmail-mcp\n';
const ENTRY = ' codegraph:\n command: codegraph\n args:\n - serve\n - --mcp\n timeout: 120\n connect_timeout: 60\n enabled: true\n';
it('a fresh install writes the server and no platform_toolsets', () => {
hermes.install('global', { autoAllow: false });
expect(read()).toBe(`mcp_servers:\n${ENTRY}`);
expect(hermes.install('global', { autoAllow: false }).files[0]!.action).toBe('unchanged');
expect(hermes.printConfig('global')).not.toContain('platform_toolsets');
});
it('the mcp-codegraph line an earlier install added is taken out, so every server loads again; a second refresh is unchanged', () => {
const before = `model: big\n${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n - browser\n`;
writeConfig(`model: big\n${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n - browser\n - mcp-codegraph\n`);
const report = refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === 'hermes')!;
expect(report.status).toBe('refreshed');
expect(read()).toBe(before);
expect(refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === 'hermes')!.status).toBe('unchanged');
});
it('a line the user removed is not put back', () => {
const text = `model: big\n${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n`;
writeConfig(text);
expect(refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === 'hermes')!.status).toBe('unchanged');
expect(read()).toBe(text);
});
it('in an allowlist of MCP servers, mcp-codegraph becomes codegraph, the name every Hermes version reads', () => {
writeConfig(`${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n - agentmail\n - mcp-codegraph # added by codegraph\n`);
hermes.install('global', { autoAllow: false });
expect(read()).toBe(`${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n - agentmail\n - codegraph\n`);
});
it('an allowlist that leaves CodeGraph out is left alone, with a note', () => {
const text = `${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n - agentmail\n`;
writeConfig(text);
const result = hermes.install('global', { autoAllow: false });
expect(result.files[0]!.action).toBe('unchanged');
expect(result.notes?.join('\n')).toMatch(/platform_toolsets\.cli in .*config\.yaml names MCP servers, so CLI sessions load only those\. Add `codegraph`/);
expect(read()).toBe(text);
});
it('a customized entry is kept byte-for-byte by install and refresh', () => {
const text = `model: big\n${OTHERS} codegraph:\n command: /home/u/.local/bin/codegraph\n args: [serve, --mcp]\n timeout: 300\n connect_timeout: 90\n enabled: false\n env:\n CODEGRAPH_MCP_TOOLS: all\n`;
writeConfig(text);
expect(hermes.detect('global').alreadyConfigured).toBe(true);
expect(hermes.install('global', { autoAllow: false }).files[0]!.action).toBe('unchanged');
expect(refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === 'hermes')!.status).toBe('unchanged');
expect(read()).toBe(text);
});
it('an entry missing command or args gets only those, at its own indent', () => {
writeConfig('mcp_servers:\n codegraph:\n enabled: true\n timeout: 300\n');
hermes.install('global', { autoAllow: false });
expect(read()).toBe('mcp_servers:\n codegraph:\n enabled: true\n timeout: 300\n command: codegraph\n args: [serve, --mcp]\n');
});
it('a 4-space-indented mcp_servers gets the new entry at 4 spaces', () => {
writeConfig('mcp_servers:\n agentmail:\n command: npx\n');
hermes.install('global', { autoAllow: false });
expect(read()).toBe([
'mcp_servers:', ' agentmail:', ' command: npx',
' codegraph:', ' command: codegraph', ' args:', ' - serve', ' - --mcp',
' timeout: 120', ' connect_timeout: 60', ' enabled: true', '',
].join('\n'));
});
it('an entry written on one line or with a quoted key is found, so no second codegraph key is added', () => {
for (const entry of [' codegraph: {command: /opt/bin/codegraph, args: [serve, --mcp]}\n', ' "codegraph":\n command: /opt/bin/codegraph\n args: [serve, --mcp]\n']) {
const text = `mcp_servers:\n${entry}`;
writeConfig(text);
expect(hermes.detect('global').alreadyConfigured).toBe(true);
hermes.install('global', { autoAllow: false });
expect(read()).toBe(text);
hermes.uninstall('global');
expect(codegraphKeys(read())).toBe(0);
}
});
it('mcp_servers written as a one-line mapping: install refuses with a clear error and changes nothing; {} becomes a block', () => {
const text = 'model: big\nmcp_servers: {agentmail: {command: npx, args: [-y, agentmail-mcp]}}\n';
writeConfig(text);
expect(() => hermes.install('global', { autoAllow: false })).toThrow(
/config\.yaml writes mcp_servers as a one-line mapping \(line 2\), so CodeGraph can't add its server without rewriting it\. Nothing was changed/,
);
expect(read()).toBe(text);
writeConfig('model: big\nmcp_servers: {}\n');
hermes.install('global', { autoAllow: false });
expect(read()).toBe(`model: big\nmcp_servers:\n${ENTRY}`);
});
it('codegraph inside a one-line mcp_servers mapping is left as written; uninstall says it can\'t remove it', () => {
const text = 'mcp_servers: {codegraph: {command: codegraph, args: [serve, --mcp]}}\n';
writeConfig(text);
expect(hermes.detect('global').alreadyConfigured).toBe(true);
const result = hermes.install('global', { autoAllow: false });
expect(result.notes?.join('\n')).toMatch(/Left the mcp_servers\.codegraph entry in .*config\.yaml \(line 1\) as you wrote it/);
const removed = hermes.uninstall('global');
expect(removed.notes?.join('\n')).toMatch(/inside a one-line mcp_servers mapping \(line 1\), which CodeGraph can't edit safely/);
expect(read()).toBe(text);
});
it('a CRLF config stays CRLF', () => {
writeConfig(`model: big\r\nplatform_toolsets:\r\n cli:\r\n - hermes-cli\r\n - mcp-codegraph\r\n`);
hermes.install('global', { autoAllow: false });
expect(read()).toBe(`model: big\r\nplatform_toolsets:\r\n cli:\r\n - hermes-cli\r\n\r\nmcp_servers:\r\n${ENTRY.replace(/\n/g, '\r\n')}`);
});
it('uninstall removes the server and any codegraph toolset name, and keeps the rest', () => {
writeConfig(`${OTHERS}${ENTRY}platform_toolsets:\n cli:\n - hermes-cli\n - agentmail\n - codegraph\n telegram:\n - hermes-telegram\n`);
hermes.uninstall('global');
expect(read()).toBe(`${OTHERS}platform_toolsets:\n cli:\n - hermes-cli\n - agentmail\n telegram:\n - hermes-telegram\n`);
expect(hermes.detect('global').alreadyConfigured).toBe(false);
});
});
describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => {
let tmpHome: string;
let tmpCwd: string;
+301 -213
View File
@@ -2,16 +2,28 @@
* Hermes Agent target.
*
* Hermes reads MCP servers from `$HERMES_HOME/config.yaml` under the
* top-level `mcp_servers` key, and exposes discovered MCP tools through
* dynamic toolsets named `mcp-<server>`. We add:
* top-level `mcp_servers` key. We add one entry:
*
* mcp_servers.codegraph -> `codegraph serve --mcp`
* platform_toolsets.cli -> `mcp-codegraph`
*
* The second entry matters because Hermes CLI profiles often enable an
* explicit `platform_toolsets.cli` list. Without `mcp-codegraph` in that
* list, the MCP server can be configured and connected but its tools may
* still be filtered out of normal CLI sessions.
* and nothing else. In particular we never write `platform_toolsets` (#2485).
* With no MCP server named in a platform's list, Hermes loads every enabled
* MCP server on that platform; naming one, as `<server>` or `mcp-<server>`,
* turns the list into an allowlist of the servers named
* (`hermes_cli/tools_config.py` `_merge_mcp_servers` in NousResearch/hermes-agent
* v0.22.4; the same rule, bare names only, dates back to May 2026). Earlier
* installs appended `mcp-codegraph` to `platform_toolsets.cli`: Hermes up to
* 0.22.2 reported it as an unknown toolset on every start, and 0.22.4 reads
* it as naming codegraph, which hides every other MCP server from CLI
* sessions. Install and refresh take that line out again
* (`healCliToolsetEntry`).
*
* An existing `mcp_servers.codegraph` entry belongs to the user: its values
* (an absolute command path, timeouts, `enabled: false`, env) are never
* rewritten. A refresh only adds `command` / `args` when one is missing.
*
* The file is edited as text, line by line, so comments and formatting
* survive; Hermes itself writes it with a comment-preserving round-trip.
*/
import * as fs from 'fs';
@@ -26,8 +38,6 @@ import {
} from './types';
import { atomicWriteFileSync } from './shared';
type LineRange = { start: number; end: number };
class HermesTarget implements AgentTarget {
readonly id = 'hermes' as const;
readonly displayName = 'Hermes Agent';
@@ -46,7 +56,7 @@ class HermesTarget implements AgentTarget {
const installed = fs.existsSync(hermesHome()) || fs.existsSync(file);
return {
installed,
alreadyConfigured: hasCodeGraphMcpServer(content),
alreadyConfigured: findCodeGraphServer(splitText(content).lines) !== null,
configPath: file,
};
}
@@ -58,9 +68,10 @@ class HermesTarget implements AgentTarget {
notes: ['Hermes Agent uses $HERMES_HOME/config.yaml; re-run with --location=global.'],
};
}
const { file, notes } = writeHermesConfig();
return {
files: [writeHermesConfig()],
notes: ['Start a new Hermes session for MCP changes to take effect.'],
files: [file],
notes: [...notes, 'Start a new Hermes session for MCP changes to take effect.'],
};
}
@@ -71,30 +82,29 @@ class HermesTarget implements AgentTarget {
return { files: [{ path: file, action: 'not-found' }] };
}
const before = readText(file);
const after = removeCodeGraphToolset(removeCodeGraphMcpServer(before));
if (after === before) {
return { files: [{ path: file, action: 'not-found' }] };
const text = splitText(readText(file));
const notes: string[] = [];
const server = findCodeGraphServer(text.lines);
let removed = false;
if (server?.inFlowParent) {
notes.push(`${file} sets mcp_servers.codegraph inside a one-line mcp_servers mapping (line ${server.line + 1}), which CodeGraph can't edit safely. Remove it from there yourself.`);
} else if (server) {
text.lines.splice(server.line, server.end - server.line);
removed = true;
}
atomicWriteFileSync(file, ensureTrailingNewline(after));
return { files: [{ path: file, action: 'removed' }] };
// The line earlier installs wrote, and `codegraph` (what a refresh made of
// it in an allowlist): with the server gone, either would be unknown.
if (removeCliToolsetEntries(text.lines, ['mcp-codegraph', 'codegraph'])) removed = true;
const action = removed ? 'removed' : 'not-found';
if (removed) atomicWriteFileSync(file, joinText(text));
return notes.length > 0 ? { files: [{ path: file, action }], notes } : { files: [{ path: file, action }] };
}
printConfig(loc: Location): string {
if (loc !== 'global') {
return '# Hermes Agent uses $HERMES_HOME/config.yaml; use --location=global.\n';
}
return [
`# Add to ${configPath()}`,
'',
renderCodeGraphMcpBlock().join('\n'),
'',
'platform_toolsets:',
' cli:',
' - hermes-cli',
' - mcp-codegraph',
'',
].join('\n');
return [`# Add to ${configPath()}`, '', 'mcp_servers:', ...renderCodeGraphServer(2), ''].join('\n');
}
describePaths(loc: Location): string[] {
@@ -120,237 +130,315 @@ function readText(file: string): string {
}
}
function writeHermesConfig(): WriteResult['files'][number] {
function writeHermesConfig(): { file: WriteResult['files'][number]; notes: string[] } {
const file = configPath();
const existed = fs.existsSync(file);
const before = readText(file);
const afterMcp = upsertCodeGraphMcpServer(before);
const after = upsertCodeGraphToolset(afterMcp);
const text = splitText(before);
const notes: string[] = [];
if (after === before) {
return { path: file, action: 'unchanged' };
const outcome = upsertCodeGraphServer(text.lines);
if (outcome === 'blocked') {
const top = findTop(text.lines, 'mcp_servers')!;
throw new Error(
`${file} writes mcp_servers as a one-line mapping (line ${top.line + 1}), so CodeGraph can't add its server without rewriting it. ` +
'Nothing was changed: add `codegraph: {command: codegraph, args: [serve, --mcp]}` to it yourself, or write mcp_servers as a block, then run this again.',
);
}
atomicWriteFileSync(file, ensureTrailingNewline(after));
return { path: file, action: existed ? 'updated' : 'created' };
if (outcome === 'left-as-written') {
const server = findCodeGraphServer(text.lines)!;
notes.push(`Left the mcp_servers.codegraph entry in ${file} (line ${server.line + 1}) as you wrote it: CodeGraph only fills in a block-style entry.`);
}
const toolsets = healCliToolsetEntry(text.lines);
if (toolsets === 'allowlist-without-codegraph') {
notes.push(`platform_toolsets.cli in ${file} names MCP servers, so CLI sessions load only those. Add \`codegraph\` to that list to use CodeGraph there.`);
}
// Rewrite only what was edited: a file CodeGraph had nothing to change in
// is left byte-for-byte as it is.
if (outcome !== 'changed' && toolsets !== 'changed') {
return { file: { path: file, action: 'unchanged' }, notes };
}
atomicWriteFileSync(file, joinText(text));
return { file: { path: file, action: existed ? 'updated' : 'created' }, notes };
}
function ensureTrailingNewline(text: string): string {
return text.endsWith('\n') ? text : text + '\n';
// ---------------------------------------------------------------------------
// Line-level YAML: just the shapes Hermes and its users write for these keys.
// ---------------------------------------------------------------------------
interface Text {
lines: string[];
eol: '\n' | '\r\n';
}
function splitLines(content: string): string[] {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
function splitText(content: string): Text {
const first = /\r\n|\r|\n/.exec(content);
const lines = content.length === 0 ? [] : content.split(/\r\n|\r|\n/);
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return { lines, eol: first?.[0] === '\r\n' ? '\r\n' : '\n' };
}
function joinLines(lines: string[]): string {
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines.join('\n') + '\n';
/** The lines joined with the file's own line break, ending with one; no trailing blank lines. */
function joinText(text: Text): string {
const lines = [...text.lines];
while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
return lines.length === 0 ? '' : lines.join(text.eol) + text.eol;
}
function topLevelRange(lines: string[], key: string): LineRange | null {
const start = lines.findIndex((line) => line.trim() === `${key}:`);
function indentOf(line: string): number {
return /^ */.exec(line)![0].length;
}
function isBlankOrComment(line: string): boolean {
const t = line.trim();
return t === '' || t.startsWith('#');
}
/** The value text of a `key: value` remainder with any trailing `# comment` removed. */
function stripComment(rest: string): string {
let quote: string | null = null;
for (let i = 0; i < rest.length; i++) {
const ch = rest[i];
if (quote) {
if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === '#' && (i === 0 || /\s/.test(rest[i - 1]!))) {
return rest.slice(0, i).trim();
}
}
return rest.trim();
}
function unquote(key: string): string {
if (key.length >= 2 && key.startsWith('"') && key.endsWith('"')) {
return key.slice(1, -1).replace(/\\(.)/g, '$1');
}
if (key.length >= 2 && key.startsWith("'") && key.endsWith("'")) {
return key.slice(1, -1).replace(/''/g, "'");
}
return key;
}
/** ` key: value` → its indent, decoded key and inline value ('' for a block). Not for `- item` lines. */
function parseKeyLine(line: string): { indent: number; key: string; value: string } | null {
const m = /^( *)("(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s#'"\-?:,[\]{}][^:#]*?|-[^\s:#][^:#]*?)[ \t]*:(?=[ \t]|$)(.*)$/.exec(line);
if (!m) return null;
return { indent: m[1]!.length, key: unquote(m[2]!), value: stripComment(m[3]!) };
}
/** A column-0 key: its line, inline value, and where the next column-0 key starts. */
function findTop(lines: string[], key: string): { line: number; value: string; end: number } | null {
const start = lines.findIndex((l) => {
const k = parseKeyLine(l);
return k !== null && k.indent === 0 && k.key === key;
});
if (start === -1) return null;
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i] ?? '';
if (line.trim() === '') continue;
if (/^[A-Za-z_][A-Za-z0-9_-]*:\s*(?:#.*)?$/.test(line)) {
const k = parseKeyLine(lines[i]!);
if (k && k.indent === 0) {
end = i;
break;
}
}
return { start, end };
return { line: start, value: parseKeyLine(lines[start]!)!.value, end };
}
function childRange(lines: string[], parent: LineRange, child: string): LineRange | null {
const startPattern = new RegExp(`^ ${escapeRegExp(child)}:\\s*(?:#.*)?$`);
let start = -1;
for (let i = parent.start + 1; i < parent.end; i++) {
if (startPattern.test(lines[i] ?? '')) {
start = i;
break;
}
}
if (start === -1) return null;
let end = parent.end;
for (let i = start + 1; i < parent.end; i++) {
const line = lines[i] ?? '';
if (line.trim() === '') continue;
if (/^ \S/.test(line)) {
end = i;
break;
}
}
while (end > start + 1 && (lines[end - 1] ?? '').trim() === '') {
end--;
}
return { start, end };
}
/**
* Block-range for a 2-space-indented child whose value is a YAML block list.
*
* Unlike `childRange`, this handles PyYAML's default `default_flow_style=False`
* serialization, where list items sit at the SAME indent as the parent key:
*
* cli:
* - hermes-cli # indent 2 — belongs to cli, not a sibling
* - browser
*
* `childRange`'s `^ \S` heuristic mistakes that first ` - hermes-cli` line
* for the next sibling key and truncates the block, causing inserts to land
* before the existing items at a different indent (issue #456). This helper
* recognizes a ` - ` line as part of the block instead, and reports back
* the actual indent used by existing items so the inserter matches it.
*/
function listChildBlock(
lines: string[],
parent: LineRange,
child: string,
): (LineRange & { itemIndent: string }) | null {
const startPattern = new RegExp(`^ ${escapeRegExp(child)}:\\s*(?:#.*)?$`);
let start = -1;
for (let i = parent.start + 1; i < parent.end; i++) {
if (startPattern.test(lines[i] ?? '')) {
start = i;
break;
}
}
if (start === -1) return null;
let end = parent.end;
for (let i = start + 1; i < parent.end; i++) {
const line = lines[i] ?? '';
if (line.trim() === '') continue;
const indentMatch = line.match(/^( *)/);
const indent = indentMatch?.[1]?.length ?? 0;
if (indent >= 4) continue;
if (indent === 2 && /^ - /.test(line)) continue;
end = i;
break;
}
while (end > start + 1 && (lines[end - 1] ?? '').trim() === '') {
end--;
}
let itemIndent = ' ';
/** One past the last indented, non-blank line of a block: where a new child goes. */
function contentEnd(lines: string[], start: number, end: number): number {
let last = start;
for (let i = start + 1; i < end; i++) {
const m = (lines[i] ?? '').match(/^( +)- /);
if (m && m[1]) {
itemIndent = m[1];
if ((lines[i] ?? '').trim() !== '' && indentOf(lines[i]!) > 0) last = i;
}
return last + 1;
}
interface Child {
key: string;
line: number;
value: string;
/** One past the child's last line. */
end: number;
}
/** The keys one level inside the block [start, end), and the indent they use (null when empty). */
function childrenOf(lines: string[], start: number, end: number): { indent: number | null; children: Child[] } {
const stop = contentEnd(lines, start, end);
let indent: number | null = null;
for (let i = start + 1; i < stop; i++) {
if (!isBlankOrComment(lines[i]!)) {
indent = indentOf(lines[i]!);
break;
}
}
return { start, end, itemIndent };
const children: Child[] = [];
if (indent === null || indent === 0) return { indent: null, children };
for (let i = start + 1; i < stop; i++) {
const k = parseKeyLine(lines[i]!);
if (k && k.indent === indent) {
if (children.length > 0) children[children.length - 1]!.end = i;
children.push({ key: k.key, line: i, value: k.value, end: stop });
}
}
// A child's range ends at its last indented line, not on blank lines before the next sibling.
for (const c of children) {
while (c.end > c.line + 1 && (lines[c.end - 1] ?? '').trim() === '') c.end--;
}
return { indent, children };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// mcp_servers.codegraph
// ---------------------------------------------------------------------------
function renderCodeGraphMcpChild(): string[] {
/** The entry CodeGraph writes into a new install, with its key at `indent` spaces. */
function renderCodeGraphServer(indent: number): string[] {
const a = ' '.repeat(indent);
const b = ' '.repeat(indent * 2);
const c = ' '.repeat(indent * 3);
return [
' codegraph:',
' command: codegraph',
' args:',
' - serve',
' - --mcp',
' timeout: 120',
' connect_timeout: 60',
' enabled: true',
`${a}codegraph:`,
`${b}command: codegraph`,
`${b}args:`,
`${c}- serve`,
`${c}- --mcp`,
`${b}timeout: 120`,
`${b}connect_timeout: 60`,
`${b}enabled: true`,
];
}
function renderCodeGraphMcpBlock(): string[] {
return ['mcp_servers:', ...renderCodeGraphMcpChild()];
/**
* Where `mcp_servers.codegraph` is written, in any form: a block or a
* one-line value under a block `mcp_servers`, at whatever indent its
* siblings use and with its key quoted or not; or inside a one-line
* `mcp_servers: {…}` mapping (`inFlowParent`).
*/
function findCodeGraphServer(lines: string[]): (Child & { inFlowParent: boolean }) | null {
const top = findTop(lines, 'mcp_servers');
if (!top) return null;
if (top.value !== '') {
const mentions = /(^|[{,\s])["']?codegraph["']?\s*:/.test(top.value.replace(/^\{/, ' '));
return mentions ? { key: 'codegraph', line: top.line, value: top.value, end: top.line + 1, inFlowParent: true } : null;
}
const { children } = childrenOf(lines, top.line, top.end);
const child = children.find((c) => c.key === 'codegraph');
return child ? { ...child, inFlowParent: false } : null;
}
function hasCodeGraphMcpServer(content: string): boolean {
const lines = splitLines(content);
const parent = topLevelRange(lines, 'mcp_servers');
return !!parent && !!childRange(lines, parent, 'codegraph');
}
function upsertCodeGraphMcpServer(content: string): string {
const lines = splitLines(content);
const parent = topLevelRange(lines, 'mcp_servers');
const child = parent ? childRange(lines, parent, 'codegraph') : null;
const replacement = renderCodeGraphMcpChild();
if (!parent) {
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
/**
* Add the codegraph server when the file has none. An existing entry is
* the user's: nothing in it is changed, except that a block-style entry
* missing `command` or `args` (it can't start without them) gets CodeGraph's.
*/
function upsertCodeGraphServer(lines: string[]): 'changed' | 'unchanged' | 'left-as-written' | 'blocked' {
const top = findTop(lines, 'mcp_servers');
if (!top) {
while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
if (lines.length > 0) lines.push('');
lines.push(...renderCodeGraphMcpBlock());
return joinLines(lines);
lines.push('mcp_servers:', ...renderCodeGraphServer(2));
return 'changed';
}
if (child) {
const existing = lines.slice(child.start, child.end);
if (arrayEqual(existing, replacement)) return joinLines(lines);
lines.splice(child.start, child.end - child.start, ...replacement);
return joinLines(lines);
if (top.value !== '') {
if (findCodeGraphServer(lines)) return 'left-as-written';
// An empty one-line value (`{}`, `null`, `~`) is rewritten as a block; any
// other one-line mapping holds the user's servers and is never rewritten.
if (!/^(\{\s*\}|null|Null|NULL|~)$/.test(top.value)) return 'blocked';
lines.splice(top.line, 1, 'mcp_servers:', ...renderCodeGraphServer(2));
return 'changed';
}
lines.splice(parent.end, 0, ...replacement);
return joinLines(lines);
}
function removeCodeGraphMcpServer(content: string): string {
const lines = splitLines(content);
const parent = topLevelRange(lines, 'mcp_servers');
const child = parent ? childRange(lines, parent, 'codegraph') : null;
if (!child) return content;
lines.splice(child.start, child.end - child.start);
return joinLines(lines);
}
function upsertCodeGraphToolset(content: string): string {
const lines = splitLines(content);
const parent = topLevelRange(lines, 'platform_toolsets');
const cli = parent ? listChildBlock(lines, parent, 'cli') : null;
if (!parent) {
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
if (lines.length > 0) lines.push('');
lines.push('platform_toolsets:', ' cli:', ' - hermes-cli', ' - mcp-codegraph');
return joinLines(lines);
const { indent, children } = childrenOf(lines, top.line, top.end);
const existing = children.find((c) => c.key === 'codegraph');
if (!existing) {
lines.splice(contentEnd(lines, top.line, top.end), 0, ...renderCodeGraphServer(indent ?? 2));
return 'changed';
}
if (existing.value !== '') return 'unchanged';
if (!cli) {
lines.splice(parent.end, 0, ' cli:', ' - hermes-cli', ' - mcp-codegraph');
return joinLines(lines);
const own = childrenOf(lines, existing.line, existing.end);
const keyIndent = own.indent ?? (indent ?? 2) * 2;
const has = (key: string) => own.children.some((c) => c.key === key);
const missing: string[] = [];
if (!has('command')) missing.push(`${' '.repeat(keyIndent)}command: codegraph`);
if (!has('args')) missing.push(`${' '.repeat(keyIndent)}args: [serve, --mcp]`);
if (missing.length === 0) return 'unchanged';
lines.splice(contentEnd(lines, existing.line, existing.end), 0, ...missing);
return 'changed';
}
// ---------------------------------------------------------------------------
// platform_toolsets.cli
// ---------------------------------------------------------------------------
interface CliList {
/** Each `- item` line of the cli list and its unquoted value. */
items: Array<{ line: number; value: string }>;
}
/** The block-style `platform_toolsets.cli` list, or null (absent, or written on one line). */
function cliList(lines: string[]): CliList | null {
const top = findTop(lines, 'platform_toolsets');
if (!top || top.value !== '') return null;
const cli = childrenOf(lines, top.line, top.end).children.find((c) => c.key === 'cli');
if (!cli || cli.value !== '') return null;
const items: CliList['items'] = [];
for (let i = cli.line + 1; i < cli.end; i++) {
const m = /^ *- +(.*)$/.exec(lines[i]!);
if (m) items.push({ line: i, value: unquote(stripComment(m[1]!)) });
}
const hasEntry = lines
.slice(cli.start + 1, cli.end)
.some((line) => line.trim() === '- mcp-codegraph');
if (hasEntry) return joinLines(lines);
lines.splice(cli.end, 0, `${cli.itemIndent}- mcp-codegraph`);
return joinLines(lines);
return { items };
}
function removeCodeGraphToolset(content: string): string {
const lines = splitLines(content);
const parent = topLevelRange(lines, 'platform_toolsets');
const cli = parent ? listChildBlock(lines, parent, 'cli') : null;
if (!cli) return content;
const hasEntry = lines
.slice(cli.start + 1, cli.end)
.some((line) => line.trim() === '- mcp-codegraph');
if (!hasEntry) return content;
const next = lines.filter((line, idx) => {
if (idx <= cli.start || idx >= cli.end) return true;
return line.trim() !== '- mcp-codegraph';
});
return joinLines(next);
/** The names of the servers under a block-style `mcp_servers`. */
function serverNames(lines: string[]): Set<string> {
const top = findTop(lines, 'mcp_servers');
if (!top || top.value !== '') return new Set();
return new Set(childrenOf(lines, top.line, top.end).children.map((c) => c.key));
}
function arrayEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, idx) => value === b[idx]);
/**
* Take out the `- mcp-codegraph` line earlier installs appended to
* `platform_toolsets.cli`. When the list names no other MCP server, the
* line is removed, so every enabled server loads again. When it names
* others too — an allowlist the user keeps — it becomes `- codegraph`, the
* spelling every Hermes version reads as naming the server, so CodeGraph
* stays in it. Reports an allowlist that doesn't include CodeGraph.
*/
function healCliToolsetEntry(lines: string[]): 'unchanged' | 'changed' | 'allowlist-without-codegraph' {
const list = cliList(lines);
if (!list) return 'unchanged';
const servers = serverNames(lines);
const names = (value: string) => servers.has(value) ? value
: value.startsWith('mcp-') && servers.has(value.slice(4)) ? value.slice(4) : null;
const othersNamed = list.items.some((it) => { const n = names(it.value); return n !== null && n !== 'codegraph'; });
const ours = list.items.filter((it) => it.value === 'mcp-codegraph');
const bare = list.items.some((it) => it.value === 'codegraph');
if (ours.length === 0) return othersNamed && !bare ? 'allowlist-without-codegraph' : 'unchanged';
const [keep, ...drop] = ours;
for (const it of [...drop].reverse()) lines.splice(it.line, 1);
if (othersNamed && !bare) lines[keep!.line] = lines[keep!.line]!.replace(/- +.*$/, '- codegraph');
else lines.splice(keep!.line, 1);
return 'changed';
}
/** Remove `- <name>` lines for any of `values` from `platform_toolsets.cli`; true when one was. */
function removeCliToolsetEntries(lines: string[], values: string[]): boolean {
const list = cliList(lines);
if (!list) return false;
let removed = false;
for (const it of [...list.items].reverse()) {
if (values.includes(it.value)) {
lines.splice(it.line, 1);
removed = true;
}
}
return removed;
}
export const hermesTarget: AgentTarget = new HermesTarget();