diff --git a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html
index d1ec3af..20bfe67 100644
--- a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html
+++ b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html
@@ -1513,7 +1513,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
- 💡 Tip: Arguments containing spaces can be quoted, e.g. --root "/Users/you/My Documents".
+ 💡 Tip: Arguments containing spaces can be quoted, e.g. --root "/Users/you/My Documents". If a server's documentation shows its config as JSON (an "args": [...] array), you can paste that array — brackets, quotes and all — directly into the Arguments field and Confab parses it correctly. Just paste the whole array, including both the opening [ and closing ] — 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 mcp-remote,, which broke npx).
Servers That Use npx (Node.js Required)
diff --git a/oAI/Services/ExternalMCPModels.swift b/oAI/Services/ExternalMCPModels.swift
index a265458..a32f198 100644
--- a/oAI/Services/ExternalMCPModels.swift
+++ b/oAI/Services/ExternalMCPModels.swift
@@ -92,7 +92,25 @@ nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable, Equatable
/// Splits a raw arguments string into tokens, respecting single/double-quoted
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
/// 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] {
+ if let jsonArgs = parseArgumentsAsJSONArray(input) { return jsonArgs }
+
var args: [String] = []
var current = ""
var inSingleQuotes = false
diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift
index 7986248..f4d4d5a 100644
--- a/oAI/Views/Screens/SettingsView.swift
+++ b/oAI/Views/Screens/SettingsView.swift
@@ -1583,7 +1583,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.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:
row("Server URL") {
diff --git a/oAITests/ExternalMCPModelsTests.swift b/oAITests/ExternalMCPModelsTests.swift
index d9f0fb7..6c2a9c6 100644
--- a/oAITests/ExternalMCPModelsTests.swift
+++ b/oAITests/ExternalMCPModelsTests.swift
@@ -235,3 +235,55 @@ struct ExternalMCPServerEquatableTests {
#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)
+ }
+}