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) + } +}