MCP Servers¶
The Model Context Protocol (MCP) is the open standard Doable uses to plug external tools into the AI. If skills are the cheapest extension, MCP servers are the most powerful: a real process (written in any language that speaks JSON-RPC over stdio or HTTP) that hands the AI a set of typed tools.
Doable is also MCP-Apps compatible, which means an MCP server can render rich UI inside the chat surface, not just return text.
How MCP fits into Doable¶
┌──────────────────┐ JSON-RPC ┌──────────────────┐
│ Doable AI host │ ---------------> │ MCP server │
│ (Copilot SDK) │ <--------------- │ (your process) │
└──────────────────┘ tools, calls └──────────────────┘
│
▼
initialize, listTools, callTool, ..., disconnect
Each workspace has a set of connectors. A connector points at one MCP server (an HTTP URL or a local subprocess) and tracks its transport, credentials, and capability cache. When a chat session starts, Doable's ConnectorManager lazily connects to each active connector, lists its tools, and exposes them to the AI as if they were native.
The runtime code lives under services/api/src/mcp/:
| File | Role |
|---|---|
types.ts |
Protocol & config types |
transport-http.ts |
Streamable HTTP + Legacy SSE transports |
transport-stdio.ts |
Subprocess transport |
client.ts |
JSON-RPC client (initialize / list / call) |
connector-manager.ts |
Connection pool, LRU eviction, retries |
tool-bridge.ts |
Wires MCP tools into the AI's tool-call loop |
discovery.ts |
Probes a URL to discover MCP servers |
builtin-connectors.ts |
Per-workspace built-in MCP Apps |
Built-in connectors¶
Every workspace gets four built-in MCP Apps provisioned on first use (builtin-connectors.ts):
| App | Purpose |
|---|---|
| Presentation Builder | Generates editable .pptx decks with a live picker |
| Spreadsheet Builder | Generates .xlsx + CSV workbooks with formulas |
| Markdown Builder | Polished Markdown with frontmatter + rendered HTML preview |
| PDF Builder | HTML to print-ready PDF via headless Chrome |
All four ship in the mcp-servers/ directory of the repo and are excellent reference implementations of standards-compliant MCP Apps (mcpui.dev). Read those before writing your own.
The connector rows for built-ins are tracked in workspace_builtin_provisioned; the delete endpoint refuses to remove them (routes/connectors.ts around BUG-MCP-002).
Registering your own MCP server¶
Workspace admins can add an MCP connector via Workspace Settings → Connectors → Add. Under the hood this is a POST /:workspaceId/connectors to routes/connectors.ts.
The config schema (createConnectorSchema in that file):
| Field | Type | Notes |
|---|---|---|
scope |
"workspace" \| "project" \| "user" |
Visibility |
name |
string, 1-200 | Display name |
description |
string, <= 1000 | Shown in the connector picker |
transportType |
"streamable_http" \| "http_sse" \| "stdio" |
See Transports |
serverUrl |
URL | Required for HTTP transports; must be HTTPS (or localhost) |
serverCommand |
string | Required for stdio (admin-only, see below) |
serverArgs |
string[] | Args passed to the stdio command |
authType |
"none" \| "api_key" \| "oauth2" \| "bearer_token" |
Auth scheme |
credentials |
object | Opaque, shape depends on authType (encrypted at rest) |
serverEnv |
Record<string,string> |
Env vars for stdio (encrypted at rest) |
projectId |
UUID | Required when scope === "project" |
stdio is admin-only
User-created connectors cannot use the stdio transport; that would let any workspace member spawn arbitrary processes on the server. Only platform admins can add stdio connectors via the built-ins mechanism. The API rejects user POSTs with stdio outright (routes/connectors.ts).
Transports¶
Streamable HTTP (streamable_http)¶
The current MCP standard. Doable POSTs JSON-RPC frames to your server URL, and the server may respond with either application/json or text/event-stream. Sessions are tracked via the mcp-session-id header.
{
"scope": "workspace",
"name": "My MCP",
"transportType": "streamable_http",
"serverUrl": "https://mcp.example.com/v1",
"authType": "bearer_token",
"credentials": { "token": "sk-..." }
}
Use this for anything new. See transport-http.ts.
Legacy SSE (http_sse)¶
The older two-endpoint MCP transport: GET for the SSE stream that announces the message endpoint, POST for the request. Supported for back-compat with older servers.
Stdio (stdio)¶
Doable spawns your binary as a subprocess and speaks JSON-RPC over stdin/stdout. Built-ins only. The implementation is in transport-stdio.ts.
Notable safety details if you're adding a platform built-in:
- The child process gets a scrubbed env: only
PATH,HOME,NODE_ENV, and explicitserverEnvkeys are inherited. DB passwords and encryption keys are never leaked to the child. - A 150 ms post-spawn wait detects early crashes (
ENOENT, bad command) so requests fail fast instead of hanging on the 120 s read timeout. - Stderr is streamed to API logs as
[MCP:stdio:<command>].
Authentication¶
For HTTP transports, four schemes are supported:
authType |
credentials shape |
Header sent |
|---|---|---|
none |
-- | -- |
bearer_token |
{ token: string } |
Authorization: Bearer <token> |
api_key |
{ apiKey: string, header?: string } |
<header>: <apiKey> (default X-API-Key) |
oauth2 |
{ access_token, refresh_token, ... } |
Authorization: Bearer <access_token> |
Credentials are encrypted at rest using ENCRYPTION_KEY. The connector-manager decrypts them only when establishing a transport (connector-manager.ts private async connect).
OAuth flow¶
For oauth2, Doable implements PKCE. The flow:
- Frontend opens a popup pointed at
POST /:workspaceId/connectors/mcp-oauth/authorize. - Doable builds the authorization URL with a PKCE challenge and an encrypted
state. - User authorizes upstream and gets redirected to
/connectors/mcp-oauth/callback. - Doable exchanges the code for tokens and writes them into the connector's
credentials_encryptedcolumn. - The popup posts
doable:mcp-oauth-completeto the opener and self-closes.
See mcpOAuthCallbackRoute in routes/connectors.ts.
Tool discovery & invocation¶
When a chat session needs MCP tools:
connectorQueries.getEffectiveConnectors(workspaceId, projectId, userId)resolves the union ofworkspace + project + userconnectors withstatus === "active".ConnectorManager.getEffectiveTools(connectors)walks each one in parallel, callinglistTools()(with a one-shot retry on cold-start failures).- The resolved
ResolvedMcpTool[]is passed to the AI host. Each entry is{ connectorId, connectorName, tool: { name, description, inputSchema } }. - When the AI calls a tool,
tool-bridge.tsroutes the call back to the right connector, sendstools/call, and streams the response. Anyui://resources in the response are emitted asmcp_ui_resourceSSE events to the browser (MCP Apps).
The bridge wraps the connector's reply in an McpToolEnvelope ({ success, result, error, _mcpTrace }) so a failing tool surfaces as a clean tool-error to the AI, not a fatal stream abort.
Capability caching¶
After listTools succeeds, the tool list is stored in mcp_connectors.capabilities_cache (as { tools: { count, list } }). If the connector goes down later, GET /:workspaceId/connectors/:id/tools returns the cached list with a 503 and status: "inactive" rather than 500.
Connection pool¶
- Max 50 simultaneous connections (
maxConnectionsinConnectorManager). - 30-minute idle timeout; an eviction sweep runs every 5 minutes.
- LRU eviction when the pool is full.
- Per-process: there's one
ConnectorManagersingleton per API process.
Security¶
| Concern | Mitigation |
|---|---|
| HTTPS leak of credentials | routes/connectors.ts rejects non-HTTPS serverUrl unless localhost/127.0.0.1 |
| Process injection via stdio | stdio is admin-only; built-ins are absolute paths under DOABLE_MCP_SERVERS_DIR |
| Env leak to subprocess | Scrubbed env (transport-stdio.ts); only explicit serverEnv is passed |
| Network egress from MCP servers | Respects the workspace's egress allowlist; see Egress Firewall |
| SSRF via the discovery endpoint | ssrf-guard.ts blocks RFC-1918 IPs and metadata addresses unless explicitly allowed |
| Built-in deletion | workspace_builtin_provisioned markers + BUG-MCP-002 guard refuse deletes on shipped MCP Apps |
For the broader picture see Sandboxing and Security Model.
Publishing as an MCP App¶
If your server returns ui:// resources alongside its text content, the Doable chat surface renders them inline as interactive cards. That's MCP Apps.
The four built-ins in mcp-servers/ all do this. Each is a small Node module that:
- Speaks streamable HTTP or stdio JSON-RPC.
- Returns
textcontent for the AI plusui://content for the user. - Uses mcpui.dev primitives so the UI works in any standards-compliant host.
To publish your own MCP App publicly:
- Build a standards-compliant MCP server (stdio or streamable HTTP); start by adapting one of the
mcp-servers/*examples. - Emit at least one
ui://resource per interactive tool, following mcpui.dev. - Host it on any HTTPS endpoint (Cloudflare Worker, Render, your own server) and document the install URL.
- Optionally PR a built-in entry to
mcp-servers/+BUILTIN_MCP_APPSso every Doable install ships your app. That's a platform contribution; see Contributing Providers & Integrations.
Common patterns¶
HTTP-only MCP¶
The simplest path: a public HTTPS endpoint with one POST that handles all JSON-RPC frames. Works from any deployment. No server-side state in Doable; credentials are encrypted in the connector row.
stdio MCP (built-in)¶
A Node module that reads JSON-RPC lines from stdin and writes responses to stdout. Cheapest for tools that don't need to be reachable from outside Doable's host. Platform admins only.
OAuth-protected MCP¶
Your server requires an OAuth bearer token to call any tool. Users add the connector, then click "Connect": Doable runs the PKCE flow, stores the tokens, refreshes them on expiry. The user's identity inside your server is whatever your authorization server says.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
400 - MCP server URL must use HTTPS |
You configured a non-HTTPS, non-localhost URL |
403 - stdio transport is not available for user-created connectors |
You're trying to add stdio outside of the built-ins path |
Connector stays in error status |
Test it via POST /:workspaceId/connectors/:id/test and check error_message; logs show [ConnectorManager] Failed to connect to <name> |
| Tool list empty after first chat | Connector may be inactive: the route returns cached tools or 503; check connector status |
MCP request timed out after 60s (HTTP) or 120s (stdio) |
Your server is slow or hung; the transport has hard timeouts and reconnects on next call |
MCP process exited immediately with code N |
Stdio server crashed on startup; check [MCP:stdio:<command>] lines in API logs |
Related¶
- Custom Skills: when you need instructions, not tools
- Tools & MCP overview: end-user perspective
- Egress Firewall: what your MCP server can call out to
mcp-servers/: four reference implementations