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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user