-
Notifications
You must be signed in to change notification settings - Fork 343
feat(mcp): give a bot MCP servers of your own #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.ts"; | |
|
|
||
| import { approvalKey, autoVerdict } from "./auto-approve.ts"; | ||
| import { appendDecision, readDecisions } from "./decision-log.ts"; | ||
| import { enabledMcpServers, mcpKey, parseMcpServers, redactMcpServers } from "./mcp-servers.ts"; | ||
| import { validateBotCwd } from "./bot-cwd.ts"; | ||
| import { attachmentExists, extensionForMime, IMAGE_MAX_BYTES, readAttachment, saveImage, type SavedAttachment } from "./attachments.ts"; | ||
| import { | ||
|
|
@@ -271,8 +272,13 @@ store.seedIfEmpty(); | |
| const wireTask = ({ resumeCursors, lastInstanceId, ...task }: TaskRecord) => task; | ||
|
|
||
| const wireBot = (bot: NonNullable<ReturnType<typeof store.bot>>) => { | ||
| const { resumeCursors, tasks, ...rest } = bot; | ||
| return { ...rest, avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) }; | ||
| const { resumeCursors, tasks, mcpServers, ...rest } = bot; | ||
| // The ONE projection every bot payload passes through, which is why the | ||
| // env values are dropped here: a second stripping site is a second place to | ||
| // forget one. `undefined` is omitted by JSON, so a bot with no servers is | ||
| // wired exactly as it was before. | ||
| const wiredMcp = mcpServers ? redactMcpServers(mcpServers) : undefined; | ||
| return { ...rest, avatarUrl: rest.avatarUrl ?? null, mcpServers: wiredMcp, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) }; | ||
| }; | ||
|
|
||
| /** Profile URLs are app-owned references, not merely strings with a trusted | ||
|
|
@@ -1459,6 +1465,15 @@ async function startTurn( | |
| // tools that would fail on every call or spawn an unnecessary proxy. | ||
| const dwebUrl = process.env.DWEB_URL?.trim(); | ||
| if (dwebUrl) integrations.dweb = { url: dwebUrl }; | ||
| const custom = enabledMcpServers(bot.mcpServers); | ||
| if (custom.length) { | ||
| integrations.custom = custom.map((server) => ({ | ||
| key: mcpKey(server.name), | ||
| command: server.command, | ||
| args: server.args, | ||
| env: server.env, | ||
| })); | ||
| } | ||
|
Comment on lines
+1468
to
+1476
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Mount custom MCP servers for group member turns. This path mounts custom servers only for Build the same enabled custom integration list in 🤖 Prompt for AI Agents |
||
| const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; // cloud routine overrides the MAUS default | ||
| // Cloud routines always use Box/BoxAgent. The per-bot backend applies | ||
| // only to ordinary turns that mount a computer into the local agent. | ||
|
|
@@ -3524,6 +3539,14 @@ const server = createServer(async (req, res) => { | |
| for (const key of ["modelSelection", "unread", "computer", "cloudBackend", "color", "mascotExpression", "pinned", "hidden"] as const) { | ||
| if (body[key] !== undefined) patch[key] = body[key]; | ||
| } | ||
| if (body.mcpServers !== undefined) { | ||
| // Parsed against what is STORED, so an editor that only ever saw | ||
| // `env: { KEY: true }` can save without sending the value back — | ||
| // and so a rename keeps the id it arrived with. | ||
| const parsed = parseMcpServers(body.mcpServers, store.bot(m[1])?.mcpServers ?? []); | ||
| if (!parsed.ok) return json(res, 400, { error: parsed.error }); | ||
| patch.mcpServers = parsed.servers; | ||
| } | ||
| // one pinned message per thread; null/"" clears. The id is not | ||
| // validated against the transcript here — a pin whose message was | ||
| // edited to another branch or deleted simply resolves to nothing. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| // The invariants a custom MCP server has to hold, taken from the review of | ||
| // the earlier attempt (PR #61): a name can never become a routing identity, | ||
| // two servers can never collide into one, and an env value can never leave | ||
| // the server. | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| enabledMcpServers, | ||
| mcpKey, | ||
| parseMcpServers, | ||
| redactMcpServers, | ||
| type McpServerSpec, | ||
| type McpServersInput, | ||
| } from "./mcp-servers.ts"; | ||
|
|
||
| let n = 0; | ||
| const ids = () => `id-${++n}`; | ||
| const spec = (over: Partial<McpServerSpec> = {}): McpServerSpec => ({ | ||
| id: "stored-1", | ||
| name: "Filesystem", | ||
| command: "npx", | ||
| args: ["-y", "@modelcontextprotocol/server-filesystem"], | ||
| env: { TOKEN: "s3cret" }, | ||
| enabled: true, | ||
| ...over, | ||
| }); | ||
|
|
||
| describe("mcpKey", () => { | ||
| it("folds a label into something an agent can prefix a tool with", () => { | ||
| expect(mcpKey("Filesystem")).toBe("filesystem"); | ||
| expect(mcpKey("My Files (work)")).toBe("my-files-work"); | ||
| expect(mcpKey(" spaced out ")).toBe("spaced-out"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("parseMcpServers", () => { | ||
| // Half of these cases feed shapes the type forbids — which is the point: | ||
| // this parser IS the boundary, and a hand-written PATCH never typechecks. | ||
| // SAFETY: the cast reaches only the runtime schema under test. | ||
| // oxlint-disable-next-line anti-slop/no-unknown-parameters | ||
| const parse = (value: unknown, existing: McpServerSpec[] = []) => | ||
| parseMcpServers(value as McpServersInput, existing, ids); | ||
|
|
||
| it("accepts a minimal server and fills in the rest", () => { | ||
| const out = parse([{ name: "files", command: "npx" }]); | ||
| expect(out.ok).toBe(true); | ||
| if (!out.ok) return; | ||
| expect(out.servers[0]).toMatchObject({ name: "files", command: "npx", args: [], env: {}, enabled: true }); | ||
| expect(out.servers[0].id).toMatch(/^id-/); | ||
| }); | ||
|
|
||
| it("refuses two servers that would answer to the same name", () => { | ||
| const out = parse([ | ||
| { name: "My Files", command: "a" }, | ||
| { name: "my files", command: "b" }, | ||
| ]); | ||
| expect(out.ok).toBe(false); | ||
| if (out.ok) return; | ||
| expect(out.error).toMatch(/same name/i); | ||
| }); | ||
|
|
||
| it("keeps a server's id across a rename, so a turn cannot be re-pointed", () => { | ||
| const stored = spec({ id: "stored-1", name: "Filesystem" }); | ||
| const out = parse([{ id: "stored-1", name: "Documents", command: "npx" }], [stored]); | ||
| expect(out.ok).toBe(true); | ||
| if (!out.ok) return; | ||
| expect(out.servers[0].id).toBe("stored-1"); | ||
| expect(out.servers[0].name).toBe("Documents"); | ||
| }); | ||
|
|
||
| it("keeps a stored env value when the editor sends it back untouched", () => { | ||
| const stored = spec(); | ||
| const out = parse([{ id: "stored-1", name: "Filesystem", command: "npx", env: { TOKEN: true } }], [stored]); | ||
| expect(out.ok).toBe(true); | ||
| if (!out.ok) return; | ||
| expect(out.servers[0].env).toEqual({ TOKEN: "s3cret" }); | ||
| }); | ||
|
|
||
| it("refuses a placeholder env value with nothing stored behind it", () => { | ||
| const out = parse([{ name: "files", command: "npx", env: { TOKEN: true } }]); | ||
| expect(out.ok).toBe(false); | ||
| if (out.ok) return; | ||
| expect(out.error).toMatch(/TOKEN/); | ||
| }); | ||
|
|
||
| it("rejects the shapes a hand-written PATCH gets wrong", () => { | ||
| expect(parse("nope").ok).toBe(false); | ||
| expect(parse([{ command: "npx" }]).ok).toBe(false); | ||
| expect(parse([{ name: "files" }]).ok).toBe(false); | ||
| expect(parse([{ name: "files", command: "npx", args: "-y" }]).ok).toBe(false); | ||
| expect(parse([{ name: "files", command: "npx", env: { A: 3 } }]).ok).toBe(false); | ||
| expect(parse([{ name: " ", command: "npx" }]).ok).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("redactMcpServers", () => { | ||
| it("replaces every env value with a marker, keeping the names", () => { | ||
| const wire = redactMcpServers([spec({ env: { TOKEN: "s3cret", REGION: "eu" } })]); | ||
| expect(wire[0].env).toEqual({ TOKEN: true, REGION: true }); | ||
| expect(JSON.stringify(wire)).not.toContain("s3cret"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("enabledMcpServers", () => { | ||
| it("drops the disabled ones and tolerates a bot with none", () => { | ||
| expect(enabledMcpServers([spec({ id: "a" }), spec({ id: "b", enabled: false })]).map((s) => s.id)).toEqual(["a"]); | ||
| expect(enabledMcpServers(undefined)).toEqual([]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50379
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50379
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50379
Redact all ACP
mcpServers[*].envvalues before persistence.redactSecretsleaves ACP environment entries unchanged when their names are not secret-shaped. Custom MCP servers allow arbitrary environment names, so credentials with names such asLICENSEcan reach the native log and/api/threads/:id/events. Add an API-level regression test for this case.🤖 Prompt for AI Agents