Skip to content

Commit 80e204d

Browse files
qozleclaude
andcommitted
fix: read and merge file-path MCP configs instead of silently dropping them
mergeMcpConfigs() stored file paths in a variable but always returned only the inline-JSON merged result when inline configs were present. Since the action always prepends its own inline JSON config (e.g. github_comment server), user-provided file paths (like --mcp-config /tmp/mcp-notion.json) were silently dropped — the server was never connected at runtime. Now reads file-path configs from disk with readFileSync and merges their mcpServers into the combined config. Invalid or missing files log a warning and continue gracefully. Closes #1191 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b2fdd80 commit 80e204d

File tree

2 files changed

+99
-33
lines changed

2 files changed

+99
-33
lines changed

base-action/src/parse-sdk-options.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { parse as parseShellArgs } from "shell-quote";
2+
import { readFileSync } from "fs";
23
import type { ClaudeOptions } from "./run-claude";
34
import type { Options as SdkOptions } from "@anthropic-ai/claude-agent-sdk";
45

@@ -31,12 +32,11 @@ type McpConfig = {
3132
/**
3233
* Merge multiple MCP config values into a single config.
3334
* Each config can be a JSON string or a file path.
34-
* For JSON strings, mcpServers objects are merged.
35-
* For file paths, they are kept as-is (user's file takes precedence and is used last).
35+
* All configs are merged into a single mcpServers object.
36+
* File paths are read from disk and their mcpServers are merged in.
3637
*/
3738
function mergeMcpConfigs(configValues: string[]): string {
3839
const merged: McpConfig = { mcpServers: {} };
39-
let lastFilePath: string | null = null;
4040

4141
for (const config of configValues) {
4242
const trimmed = config.trim();
@@ -51,32 +51,33 @@ function mergeMcpConfigs(configValues: string[]): string {
5151
}
5252
} catch {
5353
// If JSON parsing fails, treat as file path
54-
lastFilePath = trimmed;
54+
mergeFromFile(trimmed, merged);
5555
}
5656
} else {
57-
// It's a file path - store it to handle separately
58-
lastFilePath = trimmed;
57+
// It's a file path - read and merge its contents
58+
mergeFromFile(trimmed, merged);
5959
}
6060
}
6161

62-
// If we have file paths, we need to keep the merged JSON and let the file
63-
// be handled separately. Since we can only return one value, merge what we can.
64-
// If there's a file path, we need a different approach - read the file at runtime.
65-
// For now, if there's a file path, we'll stringify the merged config.
66-
// The action prepends its config as JSON, so we can safely merge inline JSON configs.
62+
return JSON.stringify(merged);
63+
}
6764

68-
// If no inline configs were found (all file paths), return the last file path
69-
if (Object.keys(merged.mcpServers!).length === 0 && lastFilePath) {
70-
return lastFilePath;
65+
/**
66+
* Read an MCP config file and merge its mcpServers into the target config.
67+
* Logs a warning and continues if the file cannot be read or parsed.
68+
*/
69+
function mergeFromFile(filePath: string, target: McpConfig): void {
70+
try {
71+
const content = readFileSync(filePath, "utf-8");
72+
const parsed = JSON.parse(content) as McpConfig;
73+
if (parsed.mcpServers) {
74+
Object.assign(target.mcpServers!, parsed.mcpServers);
75+
}
76+
} catch (error) {
77+
console.warn(
78+
`Warning: Could not read or parse MCP config file "${filePath}": ${error}`,
79+
);
7180
}
72-
73-
// Note: If user passes a file path, we cannot merge it at parse time since
74-
// we don't have access to the file system here. The action's built-in MCP
75-
// servers are always passed as inline JSON, so they will be merged.
76-
// If user also passes inline JSON, it will be merged.
77-
// If user passes a file path, they should ensure it includes all needed servers.
78-
79-
return JSON.stringify(merged);
8081
}
8182

8283
/**

base-action/test/parse-sdk-options.test.ts

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
#!/usr/bin/env bun
22

3-
import { describe, test, expect } from "bun:test";
3+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
4+
import { writeFileSync, mkdirSync, unlinkSync, rmdirSync } from "fs";
45
import { parseSdkOptions } from "../src/parse-sdk-options";
56
import type { ClaudeOptions } from "../src/run-claude";
67

8+
// Temp dir for MCP config file tests
9+
const MCP_TEST_DIR = "/tmp/mcp-config-test";
10+
const MCP_NOTION_FILE = `${MCP_TEST_DIR}/notion.json`;
11+
const MCP_SLACK_FILE = `${MCP_TEST_DIR}/slack.json`;
12+
713
describe("parseSdkOptions", () => {
814
describe("allowedTools merging", () => {
915
test("should extract allowedTools from claudeArgs", () => {
@@ -177,6 +183,36 @@ describe("parseSdkOptions", () => {
177183
});
178184

179185
describe("mcp-config merging", () => {
186+
beforeAll(() => {
187+
mkdirSync(MCP_TEST_DIR, { recursive: true });
188+
writeFileSync(
189+
MCP_NOTION_FILE,
190+
JSON.stringify({
191+
mcpServers: {
192+
notion: { command: "npx", args: ["notion-mcp-server"] },
193+
},
194+
}),
195+
);
196+
writeFileSync(
197+
MCP_SLACK_FILE,
198+
JSON.stringify({
199+
mcpServers: {
200+
slack: { command: "npx", args: ["slack-mcp-server"] },
201+
},
202+
}),
203+
);
204+
});
205+
206+
afterAll(() => {
207+
try {
208+
unlinkSync(MCP_NOTION_FILE);
209+
unlinkSync(MCP_SLACK_FILE);
210+
rmdirSync(MCP_TEST_DIR);
211+
} catch {
212+
// ignore cleanup errors
213+
}
214+
});
215+
180216
test("should pass through single mcp-config in extraArgs", () => {
181217
const options: ClaudeOptions = {
182218
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}'`,
@@ -221,33 +257,62 @@ describe("parseSdkOptions", () => {
221257
expect(mcpConfig.mcpServers).toHaveProperty("server3");
222258
});
223259

224-
test("should handle mcp-config file path when no inline JSON exists", () => {
260+
test("should pass through single file path to CLI as-is", () => {
261+
// Single mcp-config values are not merged — passed through for CLI to handle
225262
const options: ClaudeOptions = {
226-
claudeArgs: `--mcp-config /tmp/user-mcp-config.json`,
263+
claudeArgs: `--mcp-config ${MCP_NOTION_FILE}`,
227264
};
228265

229266
const result = parseSdkOptions(options);
230267

231-
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
232-
"/tmp/user-mcp-config.json",
233-
);
268+
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(MCP_NOTION_FILE);
234269
});
235270

236-
test("should merge inline JSON configs when file path is also present", () => {
237-
// When action provides inline JSON and user provides a file path,
238-
// the inline JSON configs should be merged (file paths cannot be merged at parse time)
271+
test("should merge inline JSON configs and file path configs together", () => {
272+
// This is the exact scenario from issue #1191: action provides inline JSON
273+
// for github_comment, user provides a file path for their MCP server
239274
const options: ClaudeOptions = {
240-
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config /tmp/user-config.json`,
275+
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config ${MCP_NOTION_FILE}`,
241276
};
242277

243278
const result = parseSdkOptions(options);
244279

245-
// The inline JSON configs should be merged
246280
const mcpConfig = JSON.parse(
247281
result.sdkOptions.extraArgs?.["mcp-config"] as string,
248282
);
283+
// All servers should be present — including the file-path one
249284
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
250285
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
286+
expect(mcpConfig.mcpServers).toHaveProperty("notion");
287+
expect(mcpConfig.mcpServers.notion.command).toBe("npx");
288+
});
289+
290+
test("should merge multiple file paths", () => {
291+
const options: ClaudeOptions = {
292+
claudeArgs: `--mcp-config ${MCP_NOTION_FILE} --mcp-config ${MCP_SLACK_FILE}`,
293+
};
294+
295+
const result = parseSdkOptions(options);
296+
297+
const mcpConfig = JSON.parse(
298+
result.sdkOptions.extraArgs?.["mcp-config"] as string,
299+
);
300+
expect(mcpConfig.mcpServers).toHaveProperty("notion");
301+
expect(mcpConfig.mcpServers).toHaveProperty("slack");
302+
});
303+
304+
test("should warn and continue when file path does not exist", () => {
305+
const options: ClaudeOptions = {
306+
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config /tmp/nonexistent-mcp-config.json`,
307+
};
308+
309+
const result = parseSdkOptions(options);
310+
311+
// Should still have the inline config, file path failure is non-fatal
312+
const mcpConfig = JSON.parse(
313+
result.sdkOptions.extraArgs?.["mcp-config"] as string,
314+
);
315+
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
251316
});
252317

253318
test("should handle mcp-config with other flags", () => {

0 commit comments

Comments
 (0)