mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
* feat: add mcp_config input that merges with existing mcp server - Add mcp_config input parameter to action.yml - Modify prepareMcpConfig() to accept and merge additional config - Provided config overrides built-in servers in case of naming collisions - Pass MCP_CONFIG environment variable from action to prepare step Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com> * refactor: improve MCP config validation and merging logic - Add JSON validation to ensure parsed config is an object - Simplify merge logic with explicit mcpServers merging - Enhance error logging with config preview for debugging Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com> * refactor: improve MCP config logging per review feedback - Remove configPreview from error logging to avoid cluttering output - Add informational log when merging MCP server configurations - Simplify error message for failed config parsing Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com> * test: add comprehensive unit tests for prepareMcpConfig Add tests covering: - Basic functionality with no additional config - Valid JSON merging scenarios - Invalid JSON handling - Empty/null config handling - Server name collision scenarios - Complex nested configurations - Environment variable handling Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com> * docs: add mcp_config example with sequential-thinking server - Add mcp_config to inputs table - Add example section showing how to use mcp_config with sequential-thinking MCP server - Include clear explanation that custom servers override built-in servers Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com> * readme --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
|
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
|
import * as core from "@actions/core";
|
|
|
|
describe("prepareMcpConfig", () => {
|
|
let consoleInfoSpy: any;
|
|
let consoleWarningSpy: any;
|
|
let setFailedSpy: any;
|
|
let processExitSpy: any;
|
|
|
|
beforeEach(() => {
|
|
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
|
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
|
setFailedSpy = spyOn(core, "setFailed").mockImplementation(() => {});
|
|
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
|
throw new Error("Process exit");
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleInfoSpy.mockRestore();
|
|
consoleWarningSpy.mockRestore();
|
|
setFailedSpy.mockRestore();
|
|
processExitSpy.mockRestore();
|
|
});
|
|
|
|
test("should return base config when no additional config is provided", async () => {
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.mcpServers).toBeDefined();
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
|
"test-token",
|
|
);
|
|
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
|
|
"test-token",
|
|
);
|
|
expect(parsed.mcpServers.github_file_ops.env.REPO_OWNER).toBe("test-owner");
|
|
expect(parsed.mcpServers.github_file_ops.env.REPO_NAME).toBe("test-repo");
|
|
expect(parsed.mcpServers.github_file_ops.env.BRANCH_NAME).toBe(
|
|
"test-branch",
|
|
);
|
|
});
|
|
|
|
test("should return base config when additional config is empty string", async () => {
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
"",
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.mcpServers).toBeDefined();
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("should return base config when additional config is whitespace only", async () => {
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
" \n\t ",
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.mcpServers).toBeDefined();
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("should merge valid additional config with base config", async () => {
|
|
const additionalConfig = JSON.stringify({
|
|
mcpServers: {
|
|
custom_server: {
|
|
command: "custom-command",
|
|
args: ["arg1", "arg2"],
|
|
env: {
|
|
CUSTOM_ENV: "custom-value",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
additionalConfig,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
"Merging additional MCP server configuration with built-in servers",
|
|
);
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
expect(parsed.mcpServers.custom_server).toBeDefined();
|
|
expect(parsed.mcpServers.custom_server.command).toBe("custom-command");
|
|
expect(parsed.mcpServers.custom_server.args).toEqual(["arg1", "arg2"]);
|
|
expect(parsed.mcpServers.custom_server.env.CUSTOM_ENV).toBe("custom-value");
|
|
});
|
|
|
|
test("should override built-in servers when additional config has same server names", async () => {
|
|
const additionalConfig = JSON.stringify({
|
|
mcpServers: {
|
|
github: {
|
|
command: "overridden-command",
|
|
args: ["overridden-arg"],
|
|
env: {
|
|
OVERRIDDEN_ENV: "overridden-value",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
additionalConfig,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
"Merging additional MCP server configuration with built-in servers",
|
|
);
|
|
expect(parsed.mcpServers.github.command).toBe("overridden-command");
|
|
expect(parsed.mcpServers.github.args).toEqual(["overridden-arg"]);
|
|
expect(parsed.mcpServers.github.env.OVERRIDDEN_ENV).toBe(
|
|
"overridden-value",
|
|
);
|
|
expect(
|
|
parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN,
|
|
).toBeUndefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
});
|
|
|
|
test("should merge additional root-level properties", async () => {
|
|
const additionalConfig = JSON.stringify({
|
|
customProperty: "custom-value",
|
|
anotherProperty: {
|
|
nested: "value",
|
|
},
|
|
mcpServers: {
|
|
custom_server: {
|
|
command: "custom",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
additionalConfig,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.customProperty).toBe("custom-value");
|
|
expect(parsed.anotherProperty).toEqual({ nested: "value" });
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.custom_server).toBeDefined();
|
|
});
|
|
|
|
test("should handle invalid JSON gracefully", async () => {
|
|
const invalidJson = "{ invalid json }";
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
invalidJson,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to parse additional MCP config:"),
|
|
);
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
});
|
|
|
|
test("should handle non-object JSON values", async () => {
|
|
const nonObjectJson = JSON.stringify("string value");
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
nonObjectJson,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to parse additional MCP config:"),
|
|
);
|
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("MCP config must be a valid JSON object"),
|
|
);
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
});
|
|
|
|
test("should handle null JSON value", async () => {
|
|
const nullJson = JSON.stringify(null);
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
nullJson,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to parse additional MCP config:"),
|
|
);
|
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("MCP config must be a valid JSON object"),
|
|
);
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
});
|
|
|
|
test("should handle array JSON value", async () => {
|
|
const arrayJson = JSON.stringify([1, 2, 3]);
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
arrayJson,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
// Arrays are objects in JavaScript, so they pass the object check
|
|
// But they'll fail when trying to spread or access mcpServers property
|
|
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
"Merging additional MCP server configuration with built-in servers",
|
|
);
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
|
// The array will be spread into the config (0: 1, 1: 2, 2: 3)
|
|
expect(parsed[0]).toBe(1);
|
|
expect(parsed[1]).toBe(2);
|
|
expect(parsed[2]).toBe(3);
|
|
});
|
|
|
|
test("should merge complex nested configurations", async () => {
|
|
const additionalConfig = JSON.stringify({
|
|
mcpServers: {
|
|
server1: {
|
|
command: "cmd1",
|
|
env: { KEY1: "value1" },
|
|
},
|
|
server2: {
|
|
command: "cmd2",
|
|
env: { KEY2: "value2" },
|
|
},
|
|
github_file_ops: {
|
|
command: "overridden",
|
|
env: { CUSTOM: "value" },
|
|
},
|
|
},
|
|
otherConfig: {
|
|
nested: {
|
|
deeply: "value",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
additionalConfig,
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.mcpServers.server1).toBeDefined();
|
|
expect(parsed.mcpServers.server2).toBeDefined();
|
|
expect(parsed.mcpServers.github).toBeDefined();
|
|
expect(parsed.mcpServers.github_file_ops.command).toBe("overridden");
|
|
expect(parsed.mcpServers.github_file_ops.env.CUSTOM).toBe("value");
|
|
expect(parsed.otherConfig.nested.deeply).toBe("value");
|
|
});
|
|
|
|
test("should preserve GITHUB_ACTION_PATH in file_ops server args", async () => {
|
|
const oldEnv = process.env.GITHUB_ACTION_PATH;
|
|
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.mcpServers.github_file_ops.args[1]).toBe(
|
|
"/test/action/path/src/mcp/github-file-ops-server.ts",
|
|
);
|
|
|
|
process.env.GITHUB_ACTION_PATH = oldEnv;
|
|
});
|
|
|
|
test("should use process.cwd() when GITHUB_WORKSPACE is not set", async () => {
|
|
const oldEnv = process.env.GITHUB_WORKSPACE;
|
|
delete process.env.GITHUB_WORKSPACE;
|
|
|
|
const result = await prepareMcpConfig(
|
|
"test-token",
|
|
"test-owner",
|
|
"test-repo",
|
|
"test-branch",
|
|
);
|
|
|
|
const parsed = JSON.parse(result);
|
|
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd());
|
|
|
|
process.env.GITHUB_WORKSPACE = oldEnv;
|
|
});
|
|
});
|