diff --git a/README.md b/README.md index b9dd75ff086..edfd20a628b 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,8 @@ Customize Stability API url. ### `ENABLE_MCP` (optional) -Enable MCP(Model Context Protocol)Feature +Enable MCP(Model Context Protocol)Feature. For security, MCP also requires +`CODE` to be configured; all MCP server actions verify that access code. ### `SILICONFLOW_API_KEY` (optional) diff --git a/README_CN.md b/README_CN.md index 5b71a7e7deb..8c44281ba2b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -274,7 +274,8 @@ Stability API密钥 ### `ENABLE_MCP` (optional) -启用MCP(Model Context Protocol)功能 +启用 MCP(Model Context Protocol)功能。出于安全考虑,启用 MCP 时必须同时配置 +`CODE`,所有 MCP Server Action 都会校验该访问密码。 ### `SILICONFLOW_API_KEY` (optional) diff --git a/README_KO.md b/README_KO.md index 69f400b6fe4..99b33cc3e80 100644 --- a/README_KO.md +++ b/README_KO.md @@ -363,7 +363,8 @@ Stability API URL을 커스터마이징합니다. ### `ENABLE_MCP` (선택 사항) -MCP (Model Context Protocol) 기능을 활성화합니다. +MCP (Model Context Protocol) 기능을 활성화합니다. 보안을 위해 MCP를 사용할 때는 +`CODE`도 설정해야 하며 모든 MCP Server Action이 이 접근 코드를 확인합니다. ### `SILICONFLOW_API_KEY` (선택 사항) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 6691403e65b..a7f1af638e6 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -136,6 +136,7 @@ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { const MCPAction = () => { const navigate = useNavigate(); + const accessCode = useAccessStore((state) => state.accessCode); const [count, setCount] = useState(0); const [mcpEnabled, setMcpEnabled] = useState(false); @@ -144,12 +145,12 @@ const MCPAction = () => { const enabled = await isMcpEnabled(); setMcpEnabled(enabled); if (enabled) { - const count = await getAvailableClientsCount(); + const count = await getAvailableClientsCount(accessCode); setCount(count); } }; checkMcpStatus(); - }, []); + }, [accessCode]); if (!mcpEnabled) return null; diff --git a/app/components/home.tsx b/app/components/home.tsx index 59c182ef3eb..275a6d7573d 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -248,7 +248,7 @@ export function Home() { const enabled = await isMcpEnabled(); if (enabled) { console.log("[MCP] initializing..."); - await initializeMcpSystem(); + await initializeMcpSystem(useAccessStore.getState().accessCode); console.log("[MCP] initialized"); } } catch (err) { diff --git a/app/components/mcp-market.tsx b/app/components/mcp-market.tsx index 235f63b1ca3..2eef34a3e3b 100644 --- a/app/components/mcp-market.tsx +++ b/app/components/mcp-market.tsx @@ -32,6 +32,7 @@ import clsx from "clsx"; import PlayIcon from "../icons/play.svg"; import StopIcon from "../icons/pause.svg"; import { Path } from "../constant"; +import { useAccessStore } from "../store"; interface ConfigProperty { type: string; @@ -42,6 +43,7 @@ interface ConfigProperty { export function McpMarketPage() { const navigate = useNavigate(); + const accessCode = useAccessStore((state) => state.accessCode); const [mcpEnabled, setMcpEnabled] = useState(false); const [searchText, setSearchText] = useState(""); const [userConfig, setUserConfig] = useState>({}); @@ -76,7 +78,7 @@ export function McpMarketPage() { if (!mcpEnabled || !config) return; const updateStatuses = async () => { - const statuses = await getClientsStatus(); + const statuses = await getClientsStatus(accessCode); setClientStatuses(statuses); }; @@ -86,7 +88,7 @@ export function McpMarketPage() { const timer = setInterval(updateStatuses, 1000); return () => clearInterval(timer); - }, [mcpEnabled, config]); + }, [mcpEnabled, config, accessCode]); // 加载预设服务器 useEffect(() => { @@ -116,11 +118,11 @@ export function McpMarketPage() { if (!mcpEnabled) return; try { setIsLoading(true); - const config = await getMcpConfigFromFile(); + const config = await getMcpConfigFromFile(accessCode); setConfig(config); // 获取所有客户端的状态 - const statuses = await getClientsStatus(); + const statuses = await getClientsStatus(accessCode); setClientStatuses(statuses); } catch (error) { console.error("Failed to load initial state:", error); @@ -130,7 +132,7 @@ export function McpMarketPage() { } }; loadInitialState(); - }, [mcpEnabled]); + }, [mcpEnabled, accessCode]); // 加载当前编辑服务器的配置 useEffect(() => { @@ -213,7 +215,11 @@ export function McpMarketPage() { ...(Object.keys(env).length > 0 ? { env } : {}), }; - const newConfig = await addMcpServer(savingServerId, serverConfig); + const newConfig = await addMcpServer( + savingServerId, + serverConfig, + accessCode, + ); setConfig(newConfig); showToast("Server configuration updated successfully"); } catch (error) { @@ -228,7 +234,7 @@ export function McpMarketPage() { // 获取服务器支持的 Tools const loadTools = async (id: string) => { try { - const result = await getClientTools(id); + const result = await getClientTools(id, accessCode); if (result) { setTools(result); } else { @@ -263,11 +269,15 @@ export function McpMarketPage() { command: preset.command, args: [...preset.baseArgs], }; - const newConfig = await addMcpServer(preset.id, serverConfig); + const newConfig = await addMcpServer( + preset.id, + serverConfig, + accessCode, + ); setConfig(newConfig); // 更新状态 - const statuses = await getClientsStatus(); + const statuses = await getClientsStatus(accessCode); setClientStatuses(statuses); } finally { updateLoadingState(preset.id, null); @@ -283,7 +293,7 @@ export function McpMarketPage() { const pauseServer = async (id: string) => { try { updateLoadingState(id, "Stopping server..."); - const newConfig = await pauseMcpServer(id); + const newConfig = await pauseMcpServer(id, accessCode); setConfig(newConfig); showToast("Server stopped successfully"); } catch (error) { @@ -298,7 +308,7 @@ export function McpMarketPage() { const restartServer = async (id: string) => { try { updateLoadingState(id, "Starting server..."); - await resumeMcpServer(id); + await resumeMcpServer(id, accessCode); } catch (error) { showToast( error instanceof Error @@ -315,7 +325,7 @@ export function McpMarketPage() { const handleRestartAll = async () => { try { updateLoadingState("all", "Restarting all servers..."); - const newConfig = await restartAllClients(); + const newConfig = await restartAllClients(accessCode); setConfig(newConfig); showToast("Restarting all clients"); } catch (error) { diff --git a/app/mcp/actions.ts b/app/mcp/actions.ts index 9a6a69677e8..7c151f6f05b 100644 --- a/app/mcp/actions.ts +++ b/app/mcp/actions.ts @@ -16,31 +16,44 @@ import { } from "./types"; import fs from "fs/promises"; import path from "path"; -import { getServerSideConfig } from "../config/server"; +import md5 from "spark-md5"; const logger = new MCPClientLogger("MCP Actions"); const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json"); const clientsMap = new Map(); -// These functions are exported from a "use server" module, which makes every one of -// them callable directly over HTTP as a Next.js Server Action, unauthenticated, by -// anyone who has the action id (visible in the client bundle). isMcpEnabled() existed -// but nothing called it, so every action ran regardless of the ENABLE_MCP setting - -// including addMcpServer, which spawns a child process using a caller-supplied command. -// Every action here now checks this first. -async function assertMcpEnabled() { +// Every exported function in this "use server" module is directly callable over +// HTTP. MCP therefore requires both an explicit feature flag and a configured, +// valid deployment access code before exposing config or starting child processes. +async function assertMcpAuthorized(accessCode: string) { if (!(await isMcpEnabled())) { throw new Error("MCP is not enabled"); } + + const allowedCodes = new Set( + (process.env.CODE ?? "") + .split(",") + .map((code) => code.trim()) + .filter(Boolean) + .map((code) => md5.hash(code)), + ); + + if (allowedCodes.size === 0) { + throw new Error("MCP requires CODE to be configured"); + } + + if (!accessCode || !allowedCodes.has(md5.hash(accessCode.trim()))) { + throw new Error("Unauthorized MCP request"); + } } // 获取客户端状态 -export async function getClientsStatus(): Promise< - Record -> { - await assertMcpEnabled(); - const config = await getMcpConfigFromFile(); +export async function getClientsStatus( + accessCode: string, +): Promise> { + await assertMcpAuthorized(accessCode); + const config = await getMcpConfigFromFile(accessCode); const result: Record = {}; for (const clientId of Object.keys(config.mcpServers)) { @@ -88,22 +101,22 @@ export async function getClientsStatus(): Promise< } // 获取客户端工具 -export async function getClientTools(clientId: string) { - await assertMcpEnabled(); +export async function getClientTools(clientId: string, accessCode: string) { + await assertMcpAuthorized(accessCode); return clientsMap.get(clientId)?.tools ?? null; } // 获取可用客户端数量 -export async function getAvailableClientsCount() { - await assertMcpEnabled(); +export async function getAvailableClientsCount(accessCode: string) { + await assertMcpAuthorized(accessCode); let count = 0; clientsMap.forEach((map) => !map.errorMsg && count++); return count; } // 获取所有客户端工具 -export async function getAllTools() { - await assertMcpEnabled(); +export async function getAllTools(accessCode: string) { + await assertMcpAuthorized(accessCode); const result = []; for (const [clientId, status] of clientsMap.entries()) { result.push({ @@ -155,8 +168,8 @@ async function initializeSingleClient( } // 初始化系统 -export async function initializeMcpSystem() { - await assertMcpEnabled(); +export async function initializeMcpSystem(accessCode: string) { + await assertMcpAuthorized(accessCode); logger.info("MCP Actions starting..."); try { // 检查是否已有活跃的客户端 @@ -165,7 +178,7 @@ export async function initializeMcpSystem() { return; } - const config = await getMcpConfigFromFile(); + const config = await getMcpConfigFromFile(accessCode); // 初始化所有客户端 for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) { await initializeSingleClient(clientId, serverConfig); @@ -178,10 +191,14 @@ export async function initializeMcpSystem() { } // 添加服务器 -export async function addMcpServer(clientId: string, config: ServerConfig) { - await assertMcpEnabled(); +export async function addMcpServer( + clientId: string, + config: ServerConfig, + accessCode: string, +) { + await assertMcpAuthorized(accessCode); try { - const currentConfig = await getMcpConfigFromFile(); + const currentConfig = await getMcpConfigFromFile(accessCode); const isNewServer = !(clientId in currentConfig.mcpServers); // 如果是新服务器,设置默认状态为 active @@ -211,10 +228,10 @@ export async function addMcpServer(clientId: string, config: ServerConfig) { } // 暂停服务器 -export async function pauseMcpServer(clientId: string) { - await assertMcpEnabled(); +export async function pauseMcpServer(clientId: string, accessCode: string) { + await assertMcpAuthorized(accessCode); try { - const currentConfig = await getMcpConfigFromFile(); + const currentConfig = await getMcpConfigFromFile(accessCode); const serverConfig = currentConfig.mcpServers[clientId]; if (!serverConfig) { throw new Error(`Server ${clientId} not found`); @@ -248,10 +265,13 @@ export async function pauseMcpServer(clientId: string) { } // 恢复服务器 -export async function resumeMcpServer(clientId: string): Promise { - await assertMcpEnabled(); +export async function resumeMcpServer( + clientId: string, + accessCode: string, +): Promise { + await assertMcpAuthorized(accessCode); try { - const currentConfig = await getMcpConfigFromFile(); + const currentConfig = await getMcpConfigFromFile(accessCode); const serverConfig = currentConfig.mcpServers[clientId]; if (!serverConfig) { throw new Error(`Server ${clientId} not found`); @@ -278,7 +298,7 @@ export async function resumeMcpServer(clientId: string): Promise { }; await updateMcpConfig(newConfig); } catch (error) { - const currentConfig = await getMcpConfigFromFile(); + const currentConfig = await getMcpConfigFromFile(accessCode); const serverConfig = currentConfig.mcpServers[clientId]; // 如果配置中存在该服务器,则更新其状态为 error @@ -303,10 +323,10 @@ export async function resumeMcpServer(clientId: string): Promise { } // 移除服务器 -export async function removeMcpServer(clientId: string) { - await assertMcpEnabled(); +export async function removeMcpServer(clientId: string, accessCode: string) { + await assertMcpAuthorized(accessCode); try { - const currentConfig = await getMcpConfigFromFile(); + const currentConfig = await getMcpConfigFromFile(accessCode); const { [clientId]: _, ...rest } = currentConfig.mcpServers; const newConfig = { ...currentConfig, @@ -329,8 +349,8 @@ export async function removeMcpServer(clientId: string) { } // 重启所有客户端 -export async function restartAllClients() { - await assertMcpEnabled(); +export async function restartAllClients(accessCode: string) { + await assertMcpAuthorized(accessCode); logger.info("Restarting all clients..."); try { // 关闭所有客户端 @@ -344,7 +364,7 @@ export async function restartAllClients() { clientsMap.clear(); // 重新初始化 - const config = await getMcpConfigFromFile(); + const config = await getMcpConfigFromFile(accessCode); for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) { await initializeSingleClient(clientId, serverConfig); } @@ -359,8 +379,9 @@ export async function restartAllClients() { export async function executeMcpAction( clientId: string, request: McpRequestMessage, + accessCode: string, ) { - await assertMcpEnabled(); + await assertMcpAuthorized(accessCode); try { const client = clientsMap.get(clientId); if (!client?.client) { @@ -375,8 +396,10 @@ export async function executeMcpAction( } // 获取 MCP 配置文件 -export async function getMcpConfigFromFile(): Promise { - await assertMcpEnabled(); +export async function getMcpConfigFromFile( + accessCode: string, +): Promise { + await assertMcpAuthorized(accessCode); try { const configStr = await fs.readFile(CONFIG_PATH, "utf-8"); return JSON.parse(configStr); @@ -399,11 +422,5 @@ async function updateMcpConfig(config: McpConfigData): Promise { // 检查 MCP 是否启用 export async function isMcpEnabled() { - try { - const serverConfig = getServerSideConfig(); - return serverConfig.enableMcp; - } catch (error) { - logger.error(`Failed to check MCP status: ${error}`); - return false; - } + return process.env.ENABLE_MCP === "true"; } diff --git a/app/mcp/client.ts b/app/mcp/client.ts index 5c2f071e301..ee2c0474579 100644 --- a/app/mcp/client.ts +++ b/app/mcp/client.ts @@ -1,11 +1,23 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { + getDefaultEnvironment, + StdioClientTransport, +} from "@modelcontextprotocol/sdk/client/stdio.js"; import { MCPClientLogger } from "./logger"; import { ListToolsResponse, McpRequestMessage, ServerConfig } from "./types"; import { z } from "zod"; const logger = new MCPClientLogger(); +export function getMcpChildEnvironment( + configEnv?: Record, +): Record { + return { + ...getDefaultEnvironment(), + ...(configEnv ?? {}), + }; +} + export async function createClient( id: string, config: ServerConfig, @@ -15,14 +27,7 @@ export async function createClient( const transport = new StdioClientTransport({ command: config.command, args: config.args, - env: { - ...Object.fromEntries( - Object.entries(process.env) - .filter(([_, v]) => v !== undefined) - .map(([k, v]) => [k, v as string]), - ), - ...(config.env || {}), - }, + env: getMcpChildEnvironment(config.env), }); const client = new Client( diff --git a/app/store/chat.ts b/app/store/chat.ts index 87c1a8beba0..567bc4e7a54 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -202,8 +202,8 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { return output; } -async function getMcpSystemPrompt(): Promise { - const tools = await getAllTools(); +async function getMcpSystemPrompt(accessCode: string): Promise { + const tools = await getAllTools(accessCode); let toolsStr = ""; @@ -556,7 +556,9 @@ export const useChatStore = createPersistStore( session.mask.modelConfig.model.startsWith("chatgpt-")); const mcpEnabled = await isMcpEnabled(); - const mcpSystemPrompt = mcpEnabled ? await getMcpSystemPrompt() : ""; + const mcpSystemPrompt = mcpEnabled + ? await getMcpSystemPrompt(useAccessStore.getState().accessCode) + : ""; var systemPrompts: ChatMessage[] = []; @@ -834,7 +836,11 @@ export const useChatStore = createPersistStore( if (mcpRequest) { console.debug("[MCP Request]", mcpRequest); - executeMcpAction(mcpRequest.clientId, mcpRequest.mcp) + executeMcpAction( + mcpRequest.clientId, + mcpRequest.mcp, + useAccessStore.getState().accessCode, + ) .then((result) => { console.log("[MCP Response]", result); const mcpResponse = diff --git a/test/mcp-actions-require-enabled.test.ts b/test/mcp-actions-require-enabled.test.ts index 4615424429c..88d0e3e7003 100644 --- a/test/mcp-actions-require-enabled.test.ts +++ b/test/mcp-actions-require-enabled.test.ts @@ -22,6 +22,7 @@ import { // which is the default, is exactly the case exercised below). describe("MCP server actions require MCP to be enabled", () => { const ORIGINAL_ENABLE_MCP = process.env.ENABLE_MCP; + const ORIGINAL_CODE = process.env.CODE; afterEach(() => { if (ORIGINAL_ENABLE_MCP === undefined) { @@ -29,6 +30,12 @@ describe("MCP server actions require MCP to be enabled", () => { } else { process.env.ENABLE_MCP = ORIGINAL_ENABLE_MCP; } + + if (ORIGINAL_CODE === undefined) { + delete process.env.CODE; + } else { + process.env.CODE = ORIGINAL_CODE; + } }); describe("when ENABLE_MCP is unset (the default)", () => { @@ -37,25 +44,29 @@ describe("MCP server actions require MCP to be enabled", () => { }); test.each<[string, () => Promise]>([ - ["getClientsStatus", () => getClientsStatus()], - ["getClientTools", () => getClientTools("x")], - ["getAvailableClientsCount", () => getAvailableClientsCount()], - ["getAllTools", () => getAllTools()], - ["initializeMcpSystem", () => initializeMcpSystem()], + ["getClientsStatus", () => getClientsStatus("")], + ["getClientTools", () => getClientTools("x", "")], + ["getAvailableClientsCount", () => getAvailableClientsCount("")], + ["getAllTools", () => getAllTools("")], + ["initializeMcpSystem", () => initializeMcpSystem("")], [ "addMcpServer", () => - addMcpServer("attack", { - command: "touch", - args: ["/tmp/pwned"], - } as any), + addMcpServer( + "attack", + { + command: "touch", + args: ["/tmp/pwned"], + } as any, + "", + ), ], - ["pauseMcpServer", () => pauseMcpServer("x")], - ["resumeMcpServer", () => resumeMcpServer("x")], - ["removeMcpServer", () => removeMcpServer("x")], - ["restartAllClients", () => restartAllClients()], - ["executeMcpAction", () => executeMcpAction("x", {} as any)], - ["getMcpConfigFromFile", () => getMcpConfigFromFile()], + ["pauseMcpServer", () => pauseMcpServer("x", "")], + ["resumeMcpServer", () => resumeMcpServer("x", "")], + ["removeMcpServer", () => removeMcpServer("x", "")], + ["restartAllClients", () => restartAllClients("")], + ["executeMcpAction", () => executeMcpAction("x", {} as any, "")], + ["getMcpConfigFromFile", () => getMcpConfigFromFile("")], ])("%s rejects instead of running", async (_name, callAction) => { await expect(callAction()).rejects.toThrow("MCP is not enabled"); }); @@ -64,6 +75,7 @@ describe("MCP server actions require MCP to be enabled", () => { describe("when ENABLE_MCP=true", () => { beforeEach(() => { process.env.ENABLE_MCP = "true"; + process.env.CODE = "test-code"; }); test("isMcpEnabled reports enabled", async () => { @@ -71,7 +83,20 @@ describe("MCP server actions require MCP to be enabled", () => { }); test("getMcpConfigFromFile proceeds instead of rejecting", async () => { - await expect(getMcpConfigFromFile()).resolves.toBeDefined(); + await expect(getMcpConfigFromFile("test-code")).resolves.toBeDefined(); + }); + + test("getMcpConfigFromFile rejects an invalid access code", async () => { + await expect(getMcpConfigFromFile("wrong-code")).rejects.toThrow( + "Unauthorized MCP request", + ); + }); + + test("MCP fails closed when CODE is not configured", async () => { + delete process.env.CODE; + await expect(getMcpConfigFromFile("")).rejects.toThrow( + "MCP requires CODE to be configured", + ); }); }); }); diff --git a/test/mcp-client-environment.test.ts b/test/mcp-client-environment.test.ts new file mode 100644 index 00000000000..f72cc8664ec --- /dev/null +++ b/test/mcp-client-environment.test.ts @@ -0,0 +1,24 @@ +import { getMcpChildEnvironment } from "../app/mcp/client"; + +describe("MCP child process environment", () => { + const ORIGINAL_OPENAI_API_KEY = process.env.OPENAI_API_KEY; + + afterEach(() => { + if (ORIGINAL_OPENAI_API_KEY === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = ORIGINAL_OPENAI_API_KEY; + } + }); + + test("does not inherit application secrets", () => { + process.env.OPENAI_API_KEY = "should-not-be-inherited"; + + const env = getMcpChildEnvironment({ + MCP_SERVER_TOKEN: "explicit-server-token", + }); + + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.MCP_SERVER_TOKEN).toBe("explicit-server-token"); + }); +});