Parse a pasted JSON args array correctly instead of mangling each token

Real incident: pasting gethomepage.dev's example args array (JSON,
quotes/commas/brackets and all) into the plain "Arguments" field
produced tokens like "mcp-remote," with the comma baked in, which
crashed npx with EINVALIDTAGNAME on the literal package name
"mcp-remote,". The existing char-by-char tokenizer treats quote
characters as its own quoting mechanism and consumes them, so a JSON
array's per-item quotes never get stripped and commas outside them
become part of the token.

parseArguments now tries decoding a well-formed JSON array of strings
first (only when the whole trimmed input is bracket-wrapped valid
JSON), falling back to the original shell-style tokenizer otherwise —
so pasting a server's args straight from its JSON config now works.
Tooltip and help doc updated; a partial paste (e.g. missing the opening
bracket) still isn't valid JSON and falls back as before, documented as
a known limitation rather than silently guessed at.
This commit is contained in:
2026-08-26 14:26:45 +02:00
parent 41f0b8f083
commit 017a0dbd9d
4 changed files with 72 additions and 2 deletions
@@ -1513,7 +1513,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</ol> </ol>
<div class="tip"> <div class="tip">
<strong>💡 Tip:</strong> Arguments containing spaces can be quoted, e.g. <code>--root "/Users/you/My Documents"</code>. <strong>💡 Tip:</strong> Arguments containing spaces can be quoted, e.g. <code>--root "/Users/you/My Documents"</code>. If a server's documentation shows its config as JSON (an <code>"args": [...]</code> array), you can paste that array — brackets, quotes and all — directly into the Arguments field and Confab parses it correctly. Just paste the <em>whole</em> array, including both the opening <code>[</code> and closing <code>]</code> — a partial paste isn't valid JSON and falls back to being parsed as plain text, which mangles each entry with a stray comma or bracket left attached (a real example: pasting an incomplete array once produced an argument literally named <code>mcp-remote,</code>, which broke <code>npx</code>).
</div> </div>
<h3>Servers That Use <code>npx</code> (Node.js Required)</h3> <h3>Servers That Use <code>npx</code> (Node.js Required)</h3>
+18
View File
@@ -92,7 +92,25 @@ nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable, Equatable
/// Splits a raw arguments string into tokens, respecting single/double-quoted /// Splits a raw arguments string into tokens, respecting single/double-quoted
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`) /// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
/// survive intact instead of being split on every space. /// survive intact instead of being split on every space.
/// If `input` (trimmed) is a syntactically valid JSON array of strings, decodes and returns it
/// directly instead of falling through to the shell-style tokenizer below. MCP server configs
/// are almost always distributed as JSON, and pasting an `"args": [...]` array's value straight
/// into this single-line field is a very natural mistake without this, the shell tokenizer
/// treats each `"",` as one token complete with its trailing comma (and the JSON array's own
/// quote characters get consumed as its OWN quoting mechanism, not stripped), producing mangled
/// tokens like `mcp-remote,` that break whatever actually consumes them. Real incident: pasting
/// gethomepage.dev's example `args` array this way produced exactly that and crashed `npx` with
/// `EINVALIDTAGNAME` on the literal package name `"mcp-remote,"`.
nonisolated static func parseArgumentsAsJSONArray(_ input: String) -> [String]? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("["), trimmed.hasSuffix("]"),
let data = trimmed.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([String].self, from: data)
}
static func parseArguments(_ input: String) -> [String] { static func parseArguments(_ input: String) -> [String] {
if let jsonArgs = parseArgumentsAsJSONArray(input) { return jsonArgs }
var args: [String] = [] var args: [String] = []
var current = "" var current = ""
var inSingleQuotes = false var inSingleQuotes = false
+1 -1
View File
@@ -1583,7 +1583,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced)) .font(.system(size: 13, design: .monospaced))
.frame(width: 240) .frame(width: 240)
.help("Space-separated arguments") .help("Space-separated arguments, e.g. -y mcp-remote https://example.com --header \"Authorization: Bearer token\". Pasting a full JSON array (with brackets and quotes, as MCP docs often show it) also works.")
} }
case .http: case .http:
row("Server URL") { row("Server URL") {
+52
View File
@@ -235,3 +235,55 @@ struct ExternalMCPServerEquatableTests {
#expect(original != edited) #expect(original != edited)
} }
} }
@Suite("ExternalMCPServer.parseArguments handles a pasted JSON array")
struct ParseArgumentsJSONArrayTests {
@Test("A well-formed JSON array of strings decodes directly, without shell-tokenizer mangling")
func fullJSONArrayParsesCleanly() {
let input = """
["-y", "mcp-remote", "https://h.rune.pm/api/mcp", "--header", "X-Homepage-MCP-Token: abc123"]
"""
#expect(ExternalMCPServer.parseArguments(input) == [
"-y", "mcp-remote", "https://h.rune.pm/api/mcp", "--header", "X-Homepage-MCP-Token: abc123"
])
}
@Test("A multi-line JSON array (as most docs format it) also parses cleanly")
func multilineJSONArrayParsesCleanly() {
let input = """
[
"-y",
"mcp-remote",
"https://h.rune.pm/api/mcp"
]
"""
#expect(ExternalMCPServer.parseArguments(input) == ["-y", "mcp-remote", "https://h.rune.pm/api/mcp"])
}
@Test("A partial paste missing the opening bracket is NOT treated as JSON — falls back to the shell tokenizer (documents the known limitation, doesn't silently guess)")
func partialPasteMissingOpenBracketFallsBackToShellParsing() {
// Real incident shape: Rune pasted from "-y" through the closing ']' but missed the '['.
let input = #""-y", "mcp-remote", "https://h.rune.pm/api/mcp"]"#
#expect(ExternalMCPServer.parseArgumentsAsJSONArray(input) == nil)
// The shell tokenizer still runs on it each token ends up with its trailing comma
// attached, which is exactly the real bug this whole feature exists to prevent when the
// full array IS pasted; this test just documents that a *partial* paste isn't rescued.
#expect(ExternalMCPServer.parseArguments(input) == ["-y,", "mcp-remote,", "https://h.rune.pm/api/mcp]"])
}
@Test("A plain shell-style string (no brackets) is unaffected — still uses the original tokenizer")
func plainShellStyleStringUnaffected() {
#expect(ExternalMCPServer.parseArguments(#"-y mcp-remote --header "X-Token: abc""#) == [
"-y", "mcp-remote", "--header", "X-Token: abc"
])
}
@Test("Malformed JSON that merely looks bracketed doesn't crash — falls back to shell parsing")
func malformedBracketedInputFallsBack() {
let input = "[not, valid, json]"
#expect(ExternalMCPServer.parseArgumentsAsJSONArray(input) == nil)
// Doesn't throw or crash; still produces *something* via the fallback tokenizer.
#expect(ExternalMCPServer.parseArguments(input).isEmpty == false)
}
}