Skip to content

Commit 3d9f0dc

Browse files
authored
fix(mcp): align allowed-tools parser with SDK option parser (#1373)
parseAllowedTools (used to decide which GitHub MCP servers to install) hand-rolled a regex parse of claude_args, while the tools actually granted to Claude are parsed by parseClaudeArgsToExtraArgs in base-action/src/parse-sdk-options.ts using shell-quote. The two parsers diverged on two inputs (#1357): - Multiple values after a single flag: for `--allowedTools "Read" "Grep" "mcp__github__get_commit"` the regex captured only "Read", so the github MCP server was not installed even though mcp__github__get_commit was granted — tool calls then failed. - Commented-out lines: the regex counted tools on `#`-prefixed lines that the SDK parser strips, installing servers that were never used. Reimplement parseAllowedTools on the same shell-quote tokenizer and the same "accumulating flag consumes all consecutive non-flag values" semantics, stripping comment lines first, so the install decision agrees with the tools that are actually granted. Unquoted glob patterns (e.g. `mcp__github__*`), which shell-quote yields as glob objects, are recovered to their literal text to preserve existing behavior. Closes #1357 Co-authored-by: bymle <229636660+bymle@users.noreply.github.com>
1 parent a5e5d3b commit 3d9f0dc

2 files changed

Lines changed: 106 additions & 24 deletions

File tree

src/modes/agent/parse-tools.ts

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,77 @@
1+
import { parse as parseShellArgs } from "shell-quote";
2+
3+
// Flags whose values make up the allowed-tools list.
4+
// Include both camelCase and hyphenated variants for CLI compatibility.
5+
const ALLOWED_TOOLS_FLAGS = new Set(["allowedTools", "allowed-tools"]);
6+
7+
/**
8+
* Strip comment lines from a shell argument string.
9+
* Lines whose first non-whitespace character is `#` are removed entirely.
10+
* Mirrors stripShellComments in base-action/src/parse-sdk-options.ts.
11+
*/
12+
function stripShellComments(input: string): string {
13+
return input
14+
.split("\n")
15+
.filter((line) => !line.trim().startsWith("#"))
16+
.join("\n");
17+
}
18+
19+
/**
20+
* Tokenize a claude_args string the same way base-action/src/parse-sdk-options.ts
21+
* does: strip full comment lines, then run shell-quote. shell-quote returns
22+
* unquoted glob patterns (e.g. `mcp__github__*`) as `{ op: "glob", pattern }`
23+
* objects rather than strings, so recover their literal text; drop operator
24+
* tokens (`|`, `>`, `;`, ...) which carry no value.
25+
*/
26+
function tokenize(claudeArgs: string): string[] {
27+
return parseShellArgs(stripShellComments(claudeArgs))
28+
.map((token) => {
29+
if (typeof token === "string") return token;
30+
if (token && typeof token === "object" && "pattern" in token) {
31+
return (token as { pattern: string }).pattern;
32+
}
33+
return null;
34+
})
35+
.filter((token): token is string => token !== null);
36+
}
37+
38+
/**
39+
* Parse the list of allowed tool names from a user-provided claude_args string.
40+
*
41+
* This is used to decide which GitHub MCP servers to install. It MUST stay in
42+
* agreement with how the actual tool list is built for the SDK in
43+
* base-action/src/parse-sdk-options.ts (parseClaudeArgsToExtraArgs): otherwise a
44+
* tool can be granted to Claude without its MCP server being installed, or a
45+
* server can be installed for a tool that was never granted (#1357).
46+
*
47+
* To stay in agreement it uses the same shell-quote tokenizer and the same
48+
* "an accumulating flag consumes all consecutive non-flag values" semantics,
49+
* so `--allowedTools "Read" "Grep" "mcp__github__get_commit"` captures all
50+
* three values, and commented-out lines are ignored.
51+
*/
152
export function parseAllowedTools(claudeArgs: string): string[] {
2-
// Match --allowedTools or --allowed-tools followed by the value
3-
// Handle both quoted and unquoted values
4-
// Use /g flag to find ALL occurrences, not just the first one
5-
const patterns = [
6-
/--(?:allowedTools|allowed-tools)\s+"([^"]+)"/g, // Double quoted
7-
/--(?:allowedTools|allowed-tools)\s+'([^']+)'/g, // Single quoted
8-
/--(?:allowedTools|allowed-tools)\s+([^'"\s][^\s]*)/g, // Unquoted (must not start with quote)
9-
];
53+
if (!claudeArgs?.trim()) return [];
1054

55+
const args = tokenize(claudeArgs);
1156
const tools: string[] = [];
1257
const seen = new Set<string>();
1358

14-
for (const pattern of patterns) {
15-
for (const match of claudeArgs.matchAll(pattern)) {
16-
if (match[1]) {
17-
// Don't add if the value starts with -- (another flag)
18-
if (match[1].startsWith("--")) {
19-
continue;
20-
}
21-
for (const tool of match[1].split(",")) {
22-
const trimmed = tool.trim();
23-
if (trimmed && !seen.has(trimmed)) {
24-
seen.add(trimmed);
25-
tools.push(trimmed);
26-
}
59+
for (let i = 0; i < args.length; i++) {
60+
const arg = args[i];
61+
if (!arg?.startsWith("--")) continue;
62+
63+
const flag = arg.slice(2);
64+
if (!ALLOWED_TOOLS_FLAGS.has(flag)) continue;
65+
66+
// Consume all consecutive non-flag values, e.g.
67+
// --allowedTools "Read" "Grep" "mcp__github__get_commit"
68+
while (i + 1 < args.length && !args[i + 1]!.startsWith("--")) {
69+
i++;
70+
for (const tool of args[i]!.split(",")) {
71+
const trimmed = tool.trim();
72+
if (trimmed && !seen.has(trimmed)) {
73+
seen.add(trimmed);
74+
tools.push(trimmed);
2775
}
2876
}
2977
}

test/modes/parse-tools.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,43 @@ describe("parseAllowedTools", () => {
3737

3838
test("handles --allowedTools followed by another --allowedTools flag", () => {
3939
const args = "--allowedTools --allowedTools mcp__github__*";
40-
// The second --allowedTools is consumed as a value of the first, then skipped.
41-
// This is an edge case with malformed input - returns empty.
42-
expect(parseAllowedTools(args)).toEqual([]);
40+
// The first --allowedTools has no value (the next token is another flag);
41+
// the second consumes mcp__github__*. This matches how the SDK option
42+
// parser (parse-sdk-options.ts) reads the same input.
43+
expect(parseAllowedTools(args)).toEqual(["mcp__github__*"]);
44+
});
45+
46+
test("captures multiple values after a single --allowedTools flag", () => {
47+
// Regression for #1357: the install-decision parser must capture every
48+
// value, not just the first, so it agrees with the tools actually granted
49+
// to Claude. Previously only "Read" was seen, so the github MCP server was
50+
// not installed even though mcp__github__get_commit was granted.
51+
const args = '--allowedTools "Read" "Grep" "mcp__github__get_commit"';
52+
expect(parseAllowedTools(args)).toEqual([
53+
"Read",
54+
"Grep",
55+
"mcp__github__get_commit",
56+
]);
57+
});
58+
59+
test("captures multiple values spread across lines under one flag", () => {
60+
const args = `--allowedTools
61+
"Read"
62+
"Grep"
63+
"mcp__github__get_commit"`;
64+
expect(parseAllowedTools(args)).toEqual([
65+
"Read",
66+
"Grep",
67+
"mcp__github__get_commit",
68+
]);
69+
});
70+
71+
test("ignores commented-out lines", () => {
72+
// Regression for #1357: a commented-out flag must not be counted, matching
73+
// the SDK parser which strips comment lines before parsing.
74+
const args = `# --allowedTools "mcp__github__get_commit"
75+
--allowedTools "Read"`;
76+
expect(parseAllowedTools(args)).toEqual(["Read"]);
4377
});
4478

4579
test("parses multiple separate --allowed-tools flags", () => {

0 commit comments

Comments
 (0)