Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ already have:
- **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and
events live in `~/.openmausbot`, not a cloud.
- **Agents with hands.** Each bot can get a real computer — a cloud Linux desktop it drives while you watch
live, or your own Mac — plus 500+ apps through Composio Connect.
live, or your own Mac — plus custom tools through remote MCP servers (HTTP or SSE).

## Features

Expand Down Expand Up @@ -91,12 +91,12 @@ permission broker turns every risky action into a decision you make, for cloud a
</td>
<td width="50%" valign="top">

### 🔌 Connected apps
### 🔌 Custom remote MCP servers

A one-click marketplace over Composio Connect: Gmail, Slack, GitHub, Notion, Linear and hundreds more.
OAuth once, and every bot can use them as tools.
Add your own HTTP or SSE MCP servers in the Plugins panel. Point at any Model Context Protocol server —
Notion, GitHub, custom APIs — and every Claude bot can use those tools. Composio is optional.

<img src="docs/screenshots/marketplace.png" alt="Connected apps marketplace" width="100%">
<img src="docs/screenshots/marketplace.png" alt="Remote MCP servers panel" width="100%">

</td>
</tr>
Expand Down Expand Up @@ -162,7 +162,7 @@ flowchart LR
REG --> CL & CX & GR
CL & CX & GR -- "permission requests" --> BROKER
server -- "Box API" --> BOX[("Cloud computer<br/>box.ascii.dev")]
server -- "Composio Connect" --> APPS[("Gmail · Slack · GitHub · …")]
server -- "Custom MCP servers" --> APPS[("Your HTTP/SSE MCP tools")]
```

| Layer | Where | What it does |
Expand Down Expand Up @@ -215,7 +215,7 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required
| Capability | macOS | Ubuntu 24.04 Xorg | Ubuntu 24.04 Wayland |
|---|---|---|---|
| Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta |
| Composio and Box/cloud computers | Supported | Beta | Beta |
| Remote MCP servers and Box/cloud computers | Supported | Beta | Beta |
| Local screen preview and computer control | Supported | Planned | Planned after compositor validation |
| Native on-device dictation | Supported | Planned | Planned |

Expand All @@ -228,13 +228,32 @@ in the sidebar footer) when you want to enable its integration:

| Credential | What it enables | Where to get it |
|---|---|---|
| Composio Connect key (`ck_…`) | Connect Gmail, GitHub, Slack, Notion, and other apps to your bots | [Composio Connect setup guide](https://docs.composio.dev/docs/composio-connect) |
| Composio API key (`ak_…`) | Browse the full app catalog with official names and logos | [Composio project API key guide](https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions) |
| Box API key | Give bots an isolated remote Linux computer with a desktop and terminal | [Box API key guide](https://docs.ascii.dev/box/api-keys) |
| ElevenLabs key | Read replies aloud, and call your bots | [ElevenLabs API keys](https://elevenlabs.io/app/settings/api-keys) |

Composio and Box are third-party services with their own accounts and terms. Box is a paid service after
its trial, and using a cloud computer may incur charges.
| Composio Connect key (`ck_…`) (optional) | Use Composio's connected apps marketplace instead of custom MCP servers | [Composio Connect setup guide](https://docs.composio.dev/docs/composio-connect) |

**Custom MCP servers** are configured in the Plugins panel (puzzle icon in the chat header). No account
or API key required — just point at your HTTP or SSE MCP server URL. See the [MCP servers
directory](https://github.com/modelcontextprotocol/servers) for examples.

### Adding a custom remote MCP server

1. Click the puzzle icon (🧩) in the chat header to open the Plugins panel
2. Click "Add Server"
3. Fill in:
- **Name**: A unique identifier (lowercase, alphanumeric, dash, underscore) — used as `mcp__<name>` in tool allowlists
- **Transport**: HTTP (streamable HTTP) or SSE (Server-Sent Events)
- **URL**: Your MCP server endpoint
- **Headers** (optional): Add auth headers, API keys, etc. These are stored securely and never echoed back
4. Click "Save"

Your Claude bots can now use tools from that server. Example custom servers:
- **Notion**: Read and write pages, databases
- **GitHub**: Issues, PRs, code search
- **APIs.guru**: Browse and test public APIs
- **Custom APIs**: Your own internal tools

MCP servers can be enabled/disabled per server without losing their configuration.

```sh
pnpm typecheck # app + server
Expand Down
17 changes: 17 additions & 0 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { join } from "node:path";
import { writeFileAtomic } from "./atomic.ts";
import type { InstanceConfigMap } from "./contracts.ts";

export interface McpServer {
name: string;
transport: "http" | "sse";
url: string;
/** Optional headers (e.g. Authorization, API keys) — stored on the harness,
* never echoed back in GET /api/config (same write-only rule as other secrets). */
headers?: Record<string, string>;
enabled?: boolean;
}

export interface AppConfig {
xai?: { key?: string; url?: string };
/** key = ck_… Connect consumer key (connections + agent tools);
Expand All @@ -21,6 +31,9 @@ export interface AppConfig {
/** The person using the app (collected in onboarding, shown in the
* sidebar). Not a secret — echoed back by GET /api/config. */
profile?: { name?: string; email?: string };
/** Custom remote MCP servers: user-configured HTTP or SSE servers. Persisted
* in ~/.openmausbot/config.json; headers are write-only like other secrets. */
mcpServers?: McpServer[];
instances?: InstanceConfigMap;
}

Expand Down Expand Up @@ -72,6 +85,10 @@ export function saveConfig(patch: Partial<AppConfig>): void {
disk[key] = { ...(disk[key] as object), ...patch[key] };
}
}
// mcpServers is an array, not an object to merge — replace wholesale
if (Array.isArray(patch.mcpServers)) {
disk.mcpServers = patch.mcpServers;
}
mkdirSync(DATA_DIR, { recursive: true });
writeFileAtomic(p, JSON.stringify(disk, null, 2));
}
Expand Down
8 changes: 8 additions & 0 deletions server/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export interface SendTurnInput {
* through the harness so this bot can message other bots. The harness
* owns turns, permissions, and recursion limits; the proxy only forwards. */
agents?: { command: string; args: string[]; env: Record<string, string> };
/** Custom remote MCP servers: user-configured HTTP or SSE servers. */
mcpServers?: Array<{
name: string;
transport: "http" | "sse";
url: string;
headers?: Record<string, string>;
enabled?: boolean;
}>;
};
cwd?: string;
}
Expand Down
94 changes: 94 additions & 0 deletions server/drivers/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,100 @@ describe("ClaudeDriver turns (fake CLI)", () => {
expect(allowed).toContain("mcp__agents");
});

it("mounts custom remote MCP servers (HTTP/SSE) and pre-allows their tools", async () => {
await create();
const dump = join(scratch, "dump.json");
process.env.FAKE_CLAUDE_DUMP = dump;

await instance.adapter.sendTurn({
threadId: "t-custom-mcp",
text: "hi",
integrations: {
mcpServers: [
{
name: "notion",
transport: "http",
url: "https://api.example.com/mcp/notion",
headers: { Authorization: "Bearer secret-token" },
enabled: true,
},
{
name: "deepwiki",
transport: "sse",
url: "https://api.example.com/mcp/deepwiki",
enabled: true,
},
{
name: "disabled-server",
transport: "http",
url: "https://disabled.example.com/mcp",
enabled: false,
},
],
},
});
await recorder.until((e) => e.type === "turn.completed");

const seen = JSON.parse(readFileSync(dump, "utf8"));
const mcpConfig = JSON.parse(seen.argv[seen.argv.indexOf("--mcp-config") + 1]);

// Enabled servers are mounted
expect(mcpConfig.mcpServers.notion).toMatchObject({
type: "http",
url: "https://api.example.com/mcp/notion",
headers: { Authorization: "Bearer secret-token" },
});
expect(mcpConfig.mcpServers.deepwiki).toMatchObject({
type: "sse",
url: "https://api.example.com/mcp/deepwiki",
});

// Disabled server is not mounted
expect(mcpConfig.mcpServers["disabled-server"]).toBeUndefined();

// Tools are pre-allowed
const allowed = seen.argv[seen.argv.indexOf("--allowedTools") + 1];
expect(allowed).toContain("mcp__notion");
expect(allowed).toContain("mcp__deepwiki");
expect(allowed).not.toContain("mcp__disabled-server");
});

it("mounts Composio alongside custom MCP servers when both are present", async () => {
await create();
const dump = join(scratch, "dump.json");
process.env.FAKE_CLAUDE_DUMP = dump;

await instance.adapter.sendTurn({
threadId: "t-both",
text: "hi",
integrations: {
composio: { key: "ck_test123" },
mcpServers: [
{ name: "custom", transport: "http", url: "https://custom.example.com/mcp", enabled: true },
],
},
});
await recorder.until((e) => e.type === "turn.completed");

const seen = JSON.parse(readFileSync(dump, "utf8"));
const mcpConfig = JSON.parse(seen.argv[seen.argv.indexOf("--mcp-config") + 1]);

// Both are mounted
expect(mcpConfig.mcpServers.custom).toMatchObject({
type: "http",
url: "https://custom.example.com/mcp",
});
expect(mcpConfig.mcpServers.composio).toMatchObject({
type: "http",
url: "https://connect.composio.dev/mcp",
headers: { "x-consumer-api-key": "ck_test123" },
});

const allowed = seen.argv[seen.argv.indexOf("--allowedTools") + 1];
expect(allowed).toContain("mcp__custom");
expect(allowed).toContain("mcp__composio");
});

it("resumes with --resume when a cursor exists and reports that session id", async () => {
await create();
const dump = join(scratch, "dump.json");
Expand Down
16 changes: 16 additions & 0 deletions server/drivers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,22 @@ export const ClaudeDriver: ProviderDriver<ClaudeConfig> = {
// acceptEdits run silently denies anything unlisted)
const mcpServers: Record<string, unknown> = {};
const allowed: string[] = [];

// Custom remote MCP servers (user-configured HTTP/SSE)
if (turn.integrations?.mcpServers) {
for (const server of turn.integrations.mcpServers) {
if (!server.enabled) continue;
const headers = server.headers ?? {};
mcpServers[server.name] = {
type: server.transport,
url: server.url,
...(Object.keys(headers).length ? { headers } : {}),
};
allowed.push(`mcp__${server.name}`);
}
}

// Composio (optional preset)
if (turn.integrations?.composio?.key) {
mcpServers.composio = {
type: "http",
Expand Down
50 changes: 50 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,56 @@ describe("harness HTTP API", () => {
expect(after.body.profile).toEqual({ name: "Ada Lovelace", email: "Ada@Example.com" });
});

it("saves custom MCP servers and never echoes headers back", async () => {
const servers = [
{
name: "notion",
transport: "http",
url: "https://api.example.com/mcp/notion",
headers: { Authorization: "Bearer secret-token-123" },
enabled: true,
},
{
name: "deepwiki",
transport: "sse",
url: "https://api.example.com/mcp/deepwiki",
enabled: false,
},
];

const put = await api("PUT", "/api/config", { mcpServers: servers });
expect(put.status).toBe(200);
expect(put.body.mcpServers).toHaveLength(2);
expect(put.body.mcpServers[0]).toMatchObject({
name: "notion",
transport: "http",
url: "https://api.example.com/mcp/notion",
enabled: true,
hasHeaders: true,
});
expect(put.body.mcpServers[0].headers).toBeUndefined();
expect(JSON.stringify(put.body)).not.toContain("secret-token-123");
expect(JSON.stringify(put.body)).not.toContain("Bearer");

const after = await api("GET", "/api/config");
expect(after.body.mcpServers).toHaveLength(2);
expect(after.body.mcpServers[0]).toMatchObject({
name: "notion",
transport: "http",
url: "https://api.example.com/mcp/notion",
enabled: true,
hasHeaders: true,
});
expect(after.body.mcpServers[1]).toMatchObject({
name: "deepwiki",
transport: "sse",
url: "https://api.example.com/mcp/deepwiki",
enabled: false,
hasHeaders: false,
});
expect(JSON.stringify(after.body)).not.toContain("secret-token-123");
});

it("404s unknown routes with the route in the error", async () => {
const res = await api("GET", "/api/definitely-not-a-route");
expect(res.status).toBe(404);
Expand Down
17 changes: 17 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,10 @@ async function startTurn(
try {
const integrations: NonNullable<Parameters<typeof instance.adapter.sendTurn>[0]["integrations"]> = {};
if (cfg.composio?.key) integrations.composio = { key: cfg.composio.key, url: cfg.composio.url };
// Custom remote MCP servers (enabled servers only)
if (cfg.mcpServers?.length) {
integrations.mcpServers = cfg.mcpServers.filter((s) => s.enabled !== false);
}
const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; // cloud routine overrides the MAUS default
const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true;
const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent";
Expand Down Expand Up @@ -864,6 +868,15 @@ function configStatus() {
tts: tts.describeVoice(cfg),
// not a secret — the sidebar shows it
profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
// custom MCP servers: names, urls, and enabled state are echoed back;
// headers are write-only like other secrets
mcpServers: (cfg.mcpServers ?? []).map((s) => ({
name: s.name,
transport: s.transport,
url: s.url,
enabled: s.enabled ?? true,
hasHeaders: Boolean(s.headers && Object.keys(s.headers).length),
})),
};
}

Expand Down Expand Up @@ -1496,6 +1509,10 @@ const server = createServer(async (req, res) => {
for (const key of ["xai", "composio", "box", "tts", "profile"] as const) {
if (body[key] && typeof body[key] === "object") patch[key] = body[key];
}
// mcpServers is an array, not an object
if (Array.isArray(body.mcpServers)) {
patch.mcpServers = body.mcpServers;
}
if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" });
// check a box token against the provider before storing it: a
// rejected token used to save happily and only surface as a 401 in
Expand Down
Loading