fix: MCP Servers — preserve all entries when patching config.yaml

Fixes a bug where adding a second MCP server caused the first to disappear
from the list view, and any args containing YAML reserved characters (e.g.
"@modelcontextprotocol/server-fetch") corrupted the config file.

Three root causes in HermesFileService MCP YAML patching:

1. extractMCPBlock extended through trailing comments to EOF when
   mcp_servers was the last top-level key in config.yaml. Trailing
   comments became part of the "block", so subsequent inserts landed
   at end-of-file rather than inside the entry.

2. patchMCPServerField's entry boundary similarly absorbed trailing
   blanks/comments, making the entry "own" everything until the next
   sibling — or until EOF for the last entry.

3. yamlScalar did not quote values starting with YAML reserved
   indicators (@ * & ? | > ! % , [ ] { } < ` ' "). Args like
   "@modelcontextprotocol/server-fetch" were written bare, producing
   invalid YAML that broke subsequent reads/writes.

Fix: trim trailing blanks/comments off both the block and the entry
in the locator/extractor; quote any scalar starting with a reserved
first character.

Bumps version to 1.5.7 (build 9). Includes signed Universal + ARM64
binaries.

Note: users with an already-corrupted ~/.hermes/config.yaml from the
1.5.6 bug should manually clean up their mcp_servers block (delete the
orphan args at end of file) before upgrading. New writes will be clean.
This commit is contained in:
Alan Wizemann
2026-04-16 07:38:49 -07:00
parent 61d59ba0e4
commit 117a0ee9dd
4 changed files with 43 additions and 4 deletions
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -407,7 +407,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -422,7 +422,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.6;
MARKETING_VERSION = 1.5.7;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -459,7 +459,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.6;
MARKETING_VERSION = 1.5.7;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -437,6 +437,20 @@ struct HermesFileService: Sendable {
if blockStart < 0 {
return MCPBlockLocation(prefix: lines, block: [], suffix: [])
}
// Trim trailing blank lines and comments from the block they belong
// to the file footer, not the mcp_servers section. Without this, when
// mcp_servers is the last top-level key, the block would extend to EOF
// and any inserted content (args, env, headers, tools) would land
// after the trailing comments.
while blockEnd > blockStart + 1 {
let line = lines[blockEnd - 1]
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
blockEnd -= 1
} else {
break
}
}
return MCPBlockLocation(
prefix: Array(lines[0..<blockStart]),
block: Array(lines[blockStart..<blockEnd]),
@@ -606,6 +620,20 @@ struct HermesFileService: Sendable {
}
guard entryStart >= 0 else { return false }
// Trim trailing blank lines and comments off the entry so inserts land
// immediately after the entry's last real key, not after intervening
// comments that conceptually belong to the next entry (or the file
// footer when this is the last entry in the block).
while entryEnd > entryStart + 1 {
let line = block[entryEnd - 1]
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
entryEnd -= 1
} else {
break
}
}
var entryLines = Array(block[entryStart..<entryEnd])
mutate(&entryLines)
@@ -826,9 +854,20 @@ struct HermesFileService: Sendable {
private static func yamlScalar(_ value: String) -> String {
if value.isEmpty { return "\"\"" }
// YAML 1.2 reserved indicators that change meaning at the start of a
// scalar: @ * & ? | > ! % , [ ] { } < ` ' " plus space (would be
// trimmed) and dash (looks like a sequence). Anything starting with
// one of these must be quoted or YAML treats the value as an alias,
// tag, flow collection, etc., and parsing breaks.
let reservedFirstChars: Set<Character> = [
"@", "*", "&", "?", "|", ">", "!", "%", ",",
"[", "]", "{", "}", "<", "`", "'", "\""
]
let firstCharNeedsQuoting = value.first.map { reservedFirstChars.contains($0) } ?? false
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
|| firstCharNeedsQuoting
if needsQuoting {
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")