diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index 2d235830..86375ca9 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -13,6 +13,8 @@ import type { ConnectorAccess, ConnectorTool, Credential, + McpServer, + McpServerInput, MissingLoginKind, Mod, Project, @@ -29,6 +31,7 @@ import { ARTIFACT_BRIDGE_DOC, ARTIFACT_KIT_DOC, MAX_ARTIFACT_DATA, + SECRET_MASK, MAX_ARTIFACT_HTML, checkSpec, specDocument, @@ -136,7 +139,7 @@ import { nameGuessMatchesHost } from "../vault/match"; import { chatWorkspaceId, createProfile, currentPage, deleteProfile, fillIntoPage, listProfiles, probeOtpPage, resolveProfileForAgent, solveCaptchaInPage } from "../browser/manager"; import { SOLVERS, activeSolver, captchaSolvingAvailable } from "../browser/captcha"; import { currentVmPage, fillIntoVm, probeOtpInVm, solveCaptchaInVm } from "../vm/guest"; -import { getMcpServer, mcpServerInAgentScope } from "../integrations/mcpServers"; +import { createMcpServer, deleteMcpServer, getMcpServer, listMcpServers, mcpServerInAgentScope, TRANSPORTS, updateMcpServer } from "../integrations/mcpServers"; import { apiToolEnvOwners, apiToolKey, apiToolsForAgent, findApiToolForAgent, hasApiTools, markApiToolUsed } from "../integrations/apiTools"; import { callApiTool, METHODS, type ApiCallResult, type CallPlaces } from "../integrations/apiToolRequest"; import { chatSources, createProject, deleteProject, getProject, projectOfChat, updateProject } from "../services/projects"; @@ -935,6 +938,38 @@ const canArtifact = (_agent: Agent, ctx: RunContext) => const androidOn = () => getSettings().android.enabled; const pluginsOn = () => getSettings().plugins.enabled; +const integrationsOn = () => getSettings().integrations.enabled; + +function mcpServerSummary(s: McpServer, names: Map, agentNames: Map) { + return { + id: s.id, + name: s.name, + description: s.description, + source: s.source, + transport: s.transport, + ...(s.transport === "stdio" ? { command: s.command, args: s.args } : { url: s.url }), + ...(s.envKeys.length ? { envKeys: s.envKeys } : {}), + ...(s.headerKeys.length ? { headerKeys: s.headerKeys } : {}), + enabled: s.enabled, + scope: s.agentId ? "agent" : s.workspaceId ? "workspace" : "global", + workspace: scopeName(s.workspaceId, names), + workspaceId: s.workspaceId, + ...(s.agentId ? { agentId: s.agentId, agent: agentNames.get(s.agentId) ?? s.agentId } : {}), + }; +} + +function mcpServerReaches(server: { id: string | null; workspaceId: string | null; agentId: string | null }, a: Agent): boolean { + if (!mcpServerInAgentScope(server, a)) return false; + return server.agentId === a.id || (server.id !== null && a.mcpServerIds.includes(server.id)) || a.inheritMcp; +} + +function mcpScopeRefusal(caller: Agent, workspaceId: string | null, agentId: string | null): string | null { + if (!caller.workspaceId) return null; + const target = agentId ? listAgents({ workspaceId: "all" }).find((a) => a.id === agentId) : null; + if (agentId && target?.workspaceId !== caller.workspaceId) return "You can only set up MCP servers for agents in your own workspace."; + if (!agentId && workspaceId !== caller.workspaceId) return "You can only set up MCP servers for your own workspace; global ones and other workspaces' stay with the human."; + return null; +} const vmsOn = () => getSettings().vm.enabled; async function artifactCode(input: { spec?: Record; html?: string; htmlPath?: string }, title: string): Promise { @@ -3457,6 +3492,147 @@ Polished, calm, real hierarchy, responsive from 360 px, light and dark; no emoji }, }), + defineTool({ + name: "mcp_servers_list", + description: + "List the custom MCP servers set up under Settings → Integrations → Tools (stdio programs, streamable HTTP or SSE endpoints) and the Composio servers of connected apps: transport, command and args or URL, whether it is on, and its scope — global (every agent that inherits MCP servers), a workspace, or one agent. Environment variables and headers show only their names, never their values. `scope` narrows the list; \"workspace\" takes `workspaceId`, \"agent\" takes `agentId`.", + schema: z.object({ + scope: z.enum(["all", "global", "workspace", "agent"]).optional().describe('Default "all"'), + workspaceId: z.string().optional().describe('Workspace id for scope "workspace"'), + agentId: z.string().optional().describe('Agent id for scope "agent"'), + }), + when: (agent) => isManager(agent) && integrationsOn(), + run: ({ scope = "all", workspaceId, agentId }, { agent }) => { + if (scope === "workspace" && !workspaceId) return fail('Scope "workspace" needs `workspaceId` (workspaces_list shows them).'); + if (scope === "agent" && !agentId) return fail('Scope "agent" needs `agentId` (agents_list shows them).'); + const team = listAgents({ workspaceId: "all" }); + const agentNames = new Map(team.map((a) => [a.id, a.name])); + const agentWs = new Map(team.map((a) => [a.id, a.workspaceId])); + const servers = listMcpServers().filter((s) => { + if (agent.workspaceId) { + if (s.agentId ? agentWs.get(s.agentId) !== agent.workspaceId : s.workspaceId !== null && s.workspaceId !== agent.workspaceId) return false; + } + if (scope === "global") return s.workspaceId === null && s.agentId === null; + if (scope === "workspace") return s.workspaceId === workspaceId && s.agentId === null; + if (scope === "agent") return s.agentId === agentId; + return true; + }); + const names = workspaceNames(); + return json(servers.map((s) => mcpServerSummary(s, names, agentNames))); + }, + }), + + defineTool({ + name: "mcp_server_save", + description: + "Add a custom MCP server, or change one with `serverId` — the same as Settings → Integrations → Tools. Without `serverId` it is created (`name` and `transport` required): \"stdio\" runs `command` with `args` (e.g. npx, [\"-y\", \"@modelcontextprotocol/server-github\"]), \"http\" and \"sse\" connect to `url`. `env` (stdio) and `headers` (http/sse) hold secrets such as API keys: they are encrypted in the vault and never shown again; on a change, a value of \"********\" keeps the stored one and a key left out is removed, and leaving `env`/`headers` out keeps them all. Scope: `agentId` for one agent, `workspaceId` for a workspace's agents, both null for every agent; agents that inherit MCP servers get it. Changes apply from each agent's next run, not to runs already going. Composio servers are managed by connecting apps, not here. The human is told what you changed.", + schema: z.object({ + serverId: z.string().optional().describe("Id of the server to change (mcp_servers_list); omit to add one"), + name: z.string().trim().min(1).max(100).optional(), + description: z.string().max(2000).optional().describe("What its tools are for"), + transport: z.enum(TRANSPORTS as [McpServerInput["transport"], ...McpServerInput["transport"][]]).optional(), + command: z.string().max(1024).optional().describe("stdio: the program to run"), + args: z.array(z.string().max(8192)).max(200).optional().describe("stdio: its arguments"), + url: z.string().max(4096).optional().describe("http/sse: the endpoint"), + env: z.record(z.string().max(256), z.string().max(16_384)).optional().describe("stdio: environment variables, name → value"), + headers: z.record(z.string().max(256), z.string().max(16_384)).optional().describe("http/sse: request headers, name → value"), + enabled: z.boolean().optional(), + workspaceId: z.string().min(1).nullable().optional().describe("Workspace id, or null for global"), + agentId: z.string().min(1).nullable().optional().describe("Agent id to give it to only that agent, or null"), + }), + when: (agent) => managesSetup(agent) && integrationsOn(), + run: async ({ serverId, ...input }, { agent, ctx }) => { + if (lockedVm(ctx)) return fail("This task runs in a virtual machine and is kept off the human's computer, and MCP servers run in agents' runs there — only the human sets them up."); + const actor = `agent:${agent.id}`; + let existing: McpServer | null = null; + if (serverId) { + try { + existing = getMcpServer(serverId); + } catch { + return fail(`There is no MCP server "${serverId}". mcp_servers_list shows them.`); + } + if (existing.source === "composio") return fail(`"${existing.name}" comes from a connected Composio app: it changes with the connection, not here.`); + } else { + if (!input.name) return fail("A new MCP server needs a `name`."); + if (!input.transport) return fail('A new MCP server needs a `transport`: "stdio", "http" or "sse".'); + } + const team = listAgents({ workspaceId: "all" }); + const agentId = input.agentId !== undefined ? input.agentId : (existing?.agentId ?? null); + const pinned = agentId ? team.find((a) => a.id === agentId) : null; + if (agentId && !pinned) return fail(`There is no agent "${agentId}". agents_list shows them.`); + const workspaceId = + input.workspaceId !== undefined ? input.workspaceId : input.agentId !== undefined && pinned ? pinned.workspaceId : existing ? existing.workspaceId : agent.workspaceId; + const scopeRefusal = (existing ? mcpScopeRefusal(agent, existing.workspaceId, existing.agentId) : null) ?? mcpScopeRefusal(agent, workspaceId, agentId); + if (scopeRefusal) return fail(scopeRefusal); + + if (existing && !revealsHere(agent, ctx)) { + const target = (s: { transport: string; command: string; args: string[]; url: string; workspaceId: string | null; agentId: string | null }) => + JSON.stringify([s.transport, s.command, s.args, s.url, s.workspaceId, s.agentId]); + const next = { + transport: input.transport ?? existing.transport, + command: input.command ?? existing.command, + args: input.args ?? existing.args, + url: input.url ?? existing.url, + workspaceId, + agentId, + }; + const keeps = (given: Record | undefined, stored: string[]) => + stored.length > 0 && (given === undefined || Object.values(given).includes(SECRET_MASK)); + if (target(next) !== target(existing) && (keeps(input.env, existing.envKeys) || keeps(input.headers, existing.headerKeys))) { + return fail("Its stored secrets would go along to a different program, endpoint or scope, and you can't read raw secrets. Give every `env`/`headers` value again, or ask the human to make this change."); + } + } + + const finalEnabled = input.enabled ?? existing?.enabled ?? true; + if (finalEnabled) { + const reach = { id: existing?.id ?? null, workspaceId, agentId }; + const refusal = memberRefusal(agent, ctx, (a) => mcpServerReaches(reach, a), "give it an MCP server"); + if (refusal) return fail(`${refusal} Give the server to the agents that need it (\`agentId\`) instead.`); + } + + let saved: McpServer; + try { + saved = existing + ? await updateMcpServer(existing.id, { ...input, workspaceId, agentId }, actor) + : await createMcpServer({ ...input, name: input.name!, transport: input.transport!, workspaceId, agentId }, actor); + } catch (err) { + if (err instanceof HttpError) return fail(toolErrorMessage(err)); + throw err; + } + notify( + "info", + existing ? `${agent.name} changed the MCP server ${saved.name}` : `${agent.name} added the MCP server ${saved.name}`, + saved.enabled ? "It applies from each agent's next run. Review it under Settings → Integrations → Tools." : "It is switched off. Review it under Settings → Integrations → Tools.", + "/settings/integrations?tab=tools", + ); + const agentNames = new Map(team.map((a) => [a.id, a.name])); + return json({ ...mcpServerSummary(saved, workspaceNames(), agentNames), next: "Applies from each agent's next run." }); + }, + }), + + defineTool({ + name: "mcp_server_delete", + description: + "Remove a custom MCP server and its stored secrets. Agents stop getting it from their next run. Only do this when the human asked for it or the server is broken and replaced. Composio servers go away by disconnecting their app instead.", + schema: z.object({ serverId: z.string().describe("Id of the server (mcp_servers_list)") }), + when: (agent) => managesSetup(agent) && integrationsOn(), + run: ({ serverId }, { agent, ctx }) => { + if (lockedVm(ctx)) return fail("This task runs in a virtual machine and is kept off the human's computer, and MCP servers run in agents' runs there — only the human changes them."); + let server: McpServer; + try { + server = getMcpServer(serverId); + } catch { + return fail(`There is no MCP server "${serverId}". mcp_servers_list shows them.`); + } + if (server.source === "composio") return fail(`"${server.name}" comes from a connected Composio app: disconnect the app instead.`); + const refusal = mcpScopeRefusal(agent, server.workspaceId, server.agentId); + if (refusal) return fail(refusal); + deleteMcpServer(server.id, `agent:${agent.id}`); + notify("info", `${agent.name} removed the MCP server ${server.name}`, "Agents stop getting it from their next run.", "/settings/integrations?tab=tools"); + return `Removed the MCP server "${server.name}". Agents stop getting it from their next run.`; + }, + }), + defineTool({ name: "settings_get", description: diff --git a/packages/core/src/remote/deviceTools.ts b/packages/core/src/remote/deviceTools.ts index 976425dc..eaeb3834 100644 --- a/packages/core/src/remote/deviceTools.ts +++ b/packages/core/src/remote/deviceTools.ts @@ -61,6 +61,9 @@ export const SHARED_TOOLS: ReadonlySet = new Set([ "plugin_install", "plugin_update", "plugin_save", + "mcp_servers_list", + "mcp_server_save", + "mcp_server_delete", "settings_get", "settings_update", "logins_overview", diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts index 658db2f6..757bce10 100644 --- a/packages/core/src/runner/prompt.ts +++ b/packages/core/src/runner/prompt.ts @@ -372,7 +372,11 @@ How to use them: - An agent that needs a browser of its own (separate logins) gets a new, empty profile: \`browser_profile_create\`, then \`agent_update({ browser: { profileId } })\`. Existing profiles (\`browser_profiles_list\`) hold ${human}'s logins and stay theirs. - Delete an agent only when ${human} asked for it: its history and setup can't be brought back.${ ctx.settingsTools - ? `\n\nGodmode's own settings are yours to read and change when ${human} asks for it or a task needs it: \`settings_get\` and \`settings_update\` (default and fallback model, reasoning effort, run limits, how the browser and computer control behave, virtual machines, memory and dreaming, voice, upkeep, logs). A change applies to every agent at once, so say what you changed. What guards secrets, access from outside, what agents may do on this computer, what every agent is told or their limits stays with ${human} (\`humanOnly\` in \`settings_get\`); never hand them a task for a setting you can change yourself.` + ? `\n\nGodmode's own settings are yours to read and change when ${human} asks for it or a task needs it: \`settings_get\` and \`settings_update\` (default and fallback model, reasoning effort, run limits, how the browser and computer control behave, virtual machines, memory and dreaming, voice, upkeep, logs). A change applies to every agent at once, so say what you changed. What guards secrets, access from outside, what agents may do on this computer, what every agent is told or their limits stays with ${human} (\`humanOnly\` in \`settings_get\`); never hand them a task for a setting you can change yourself.${ + settings.integrations.enabled + ? `\n\nCustom MCP servers (Settings → Integrations → Tools) are yours to set up too: \`mcp_servers_list\`, \`mcp_server_save\` (add one, or change it with \`serverId\`), \`mcp_server_delete\`. Scope one to the agent that needs it (\`agentId\`) or a workspace rather than every agent; API keys go into \`env\` or \`headers\`, which are encrypted and never shown again. A change applies from each agent's next run.` + : "" + }` : "" }${ settings.vm.enabled && vmSupport().supported diff --git a/packages/core/test/mcp-server-tools.test.ts b/packages/core/test/mcp-server-tools.test.ts new file mode 100644 index 00000000..511cf93a --- /dev/null +++ b/packages/core/test/mcp-server-tools.test.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Agent } from "@godmode/shared"; +import { SECRET_MASK } from "@godmode/shared"; +import { makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { get, insert, run as sql } from "../src/db"; +import { updateSettings } from "../src/services/settings"; +import { callTool, listToolsFor } from "../src/mcp/tools"; +import { getMcpServer, mcpServerSecrets } from "../src/integrations/mcpServers"; +import { createWorkspace } from "../src/services/workspaces"; +import * as vault from "../src/vault/vault"; +import { newId, now } from "../src/util"; + +let env: TestEnv; +let manager: Agent; +let worker: Agent; + +const TOOLS = ["mcp_servers_list", "mcp_server_save", "mcp_server_delete"]; + +function chatRun(agent: Agent) { + const conversationId = newId("cnv"); + insert("conversations", { id: conversationId, agent_id: agent.id, title: "x", origin: "chat", created_at: now(), updated_at: now() }); + const runId = newId("run"); + insert("runs", { id: runId, agent_id: agent.id, conversation_id: conversationId, trigger: "chat", status: "running", prompt: "x", created_at: now() }); + return { runId, agentId: agent.id, conversationId, workspaceId: agent.workspaceId, depth: 0 }; +} + +async function call(agent: Agent, name: string, args: unknown) { + const out = await callTool(chatRun(agent), name, args); + return { text: out.content[0]!.text, isError: out.isError === true }; +} + +beforeAll(async () => { + env = await setupEnv("godmode-mcp-tools-"); + await vault.setup("correct horse battery staple", false); + manager = await makeAgent({ name: "Boss", permissions: { canManageAgents: true, allowDelegation: true } }); + worker = await makeAgent({ name: "Worker" }); +}); + +afterAll(async () => { + await env.close(); +}); + +describe("mcp server tools", () => { + test("only managers get them, and only while integrations are on", () => { + updateSettings({ integrations: { enabled: false } }); + expect(listToolsFor(manager, chatRun(manager)).filter((t) => TOOLS.includes(t.name))).toEqual([]); + updateSettings({ integrations: { enabled: true } }); + expect(listToolsFor(manager, chatRun(manager)).map((t) => t.name).filter((n) => TOOLS.includes(n)).sort()).toEqual([...TOOLS].sort()); + expect(listToolsFor(worker, chatRun(worker)).filter((t) => TOOLS.includes(t.name))).toEqual([]); + }); + + test("save creates a server with sealed env, lists key names only and audits the agent", async () => { + const saved = await call(manager, "mcp_server_save", { + name: "GitHub", + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { GITHUB_TOKEN: "ghp_supersecret" }, + agentId: worker.id, + }); + expect(saved.isError).toBe(false); + const { id } = JSON.parse(saved.text) as { id: string }; + expect(mcpServerSecrets(id).env).toEqual({ GITHUB_TOKEN: "ghp_supersecret" }); + expect(get<{ actor: string }>("SELECT actor FROM audit_log WHERE action = 'mcp_server.create' AND target = ?", id)?.actor).toBe(`agent:${manager.id}`); + + const listed = await call(manager, "mcp_servers_list", { scope: "agent", agentId: worker.id }); + expect(listed.text).not.toContain("ghp_supersecret"); + const servers = JSON.parse(listed.text) as { id: string; envKeys: string[]; scope: string }[]; + expect(servers.map((s) => [s.id, s.envKeys, s.scope])).toEqual([[id, ["GITHUB_TOKEN"], "agent"]]); + expect(JSON.parse((await call(manager, "mcp_servers_list", { scope: "global" })).text)).toEqual([]); + }); + + test("update keeps masked secrets, but won't carry them to a new command unless they are given again", async () => { + const id = getMcpServerId("GitHub"); + const renamed = await call(manager, "mcp_server_save", { serverId: id, description: "Repos and issues", env: { GITHUB_TOKEN: SECRET_MASK } }); + expect(renamed.isError).toBe(false); + expect(mcpServerSecrets(id).env).toEqual({ GITHUB_TOKEN: "ghp_supersecret" }); + + const moved = await call(manager, "mcp_server_save", { serverId: id, command: "node", args: ["steal.js"] }); + expect(moved.isError).toBe(true); + expect(moved.text).toContain("stored secrets"); + expect(getMcpServer(id).command).toBe("npx"); + + const given = await call(manager, "mcp_server_save", { serverId: id, command: "node", args: ["server.js"], env: { GITHUB_TOKEN: "ghp_new" } }); + expect(given.isError).toBe(false); + expect(getMcpServer(id).command).toBe("node"); + expect(mcpServerSecrets(id).env).toEqual({ GITHUB_TOKEN: "ghp_new" }); + }); + + test("a server for every agent is refused while one that controls the computer would get it", async () => { + const operator = await makeAgent({ name: "Operator", computer: { enabled: true, target: null } }); + const global = await call(manager, "mcp_server_save", { name: "Docs", transport: "http", url: "https://mcp.example.com/mcp", workspaceId: null, agentId: null }); + expect(global.isError).toBe(true); + expect(global.text).toContain("Operator"); + const off = await call(manager, "mcp_server_save", { name: "Docs", transport: "http", url: "https://mcp.example.com/mcp", workspaceId: null, agentId: null, enabled: false }); + expect(off.isError).toBe(false); + sql("DELETE FROM agents WHERE id = ?", operator.id); + }); + + test("a workspace manager stays in its workspace", async () => { + const ws = createWorkspace({ name: "Client" }); + const local = await makeAgent({ name: "Local Boss", workspaceId: ws.id, permissions: { canManageAgents: true, allowDelegation: true } }); + const global = await call(local, "mcp_server_save", { name: "Wide", transport: "http", url: "https://x.example.com/mcp", workspaceId: null }); + expect(global.isError).toBe(true); + const own = await call(local, "mcp_server_save", { name: "Narrow", transport: "http", url: "https://x.example.com/mcp", headers: { Authorization: "Bearer abc" } }); + expect(own.isError).toBe(false); + expect(JSON.parse(own.text)).toMatchObject({ scope: "workspace", workspaceId: ws.id, headerKeys: ["Authorization"] }); + const seen = (JSON.parse((await call(local, "mcp_servers_list", {})).text) as { name: string }[]).map((s) => s.name); + expect(seen).toContain("Narrow"); + expect(seen).not.toContain("GitHub"); + expect((await call(local, "mcp_server_delete", { serverId: getMcpServerId("Docs") })).isError).toBe(true); + }); + + test("delete removes the server", async () => { + const id = getMcpServerId("GitHub"); + const out = await call(manager, "mcp_server_delete", { serverId: id }); + expect(out.isError).toBe(false); + expect(get("SELECT id FROM mcp_servers WHERE id = ?", id)).toBeNull(); + expect((await call(manager, "mcp_server_delete", { serverId: id })).isError).toBe(true); + }); +}); + +function getMcpServerId(name: string): string { + return get<{ id: string }>("SELECT id FROM mcp_servers WHERE name = ?", name)!.id; +} diff --git a/packages/shared/src/activity.ts b/packages/shared/src/activity.ts index 469555dd..5f88e293 100644 --- a/packages/shared/src/activity.ts +++ b/packages/shared/src/activity.ts @@ -117,6 +117,9 @@ export const GATEWAY_ACTIVITY: Record "Looking at the plugins", plugin_install: (i) => (short(i.source) ? `Installing the plugin ${short(i.source, 40)}` : "Installing a plugin"), plugin_save: (i) => (short(i.title) ? `Saving the plugin ${short(i.title, 40)} as a draft` : "Saving a plugin as a draft"), + mcp_servers_list: () => "Looking at the MCP servers", + mcp_server_save: (i) => (i.serverId ? (short(i.name) ? `Changing the MCP server ${short(i.name, 40)}` : "Changing an MCP server") : short(i.name) ? `Adding the MCP server ${short(i.name, 40)}` : "Adding an MCP server"), + mcp_server_delete: () => "Removing an MCP server", logins_overview: () => "Checking which logins are saved", missing_logins_list: () => "Checking missing logins", vms_list: () => "Looking at the virtual machines", @@ -363,6 +366,9 @@ export const GATEWAY_DONE: Record = { plugins_list: "Looked at the plugins", plugin_install: "Installed a plugin", plugin_save: "Saved a plugin as a draft", + mcp_servers_list: "Looked at the MCP servers", + mcp_server_save: "Saved an MCP server", + mcp_server_delete: "Removed an MCP server", logins_overview: "Checked which logins are saved", missing_logins_list: "Checked missing logins", vms_list: "Looked at the virtual machines",