SDK · Growth+

Custom MCP servers

Register MCP server endpoints — internal tools, third-party MCPs we haven't pre-built, anything that speaks the MCP protocol. Apps in your org fetch { url, headers } through one platform call. The bearer token never lives in your app code.

1. Register the MCP server (once, on the dashboard)

On the org page, the “Custom MCP servers” card asks for:

  • A slug your app uses to look it up (acme-tools, internal-billing-mcp, …)
  • The MCP URL (must be HTTPS)
  • Auth: none for public MCPs, or bearer with a token (encrypted with your org's KMS key)

2. Pull the config in your app

GET /api/integrations/mcp-config/<slug> returns { url, headers } with the bearer Authorization already attached. Plug those two values into whichever MCP client your app uses — Leash is not in the request path between your app and the MCP.

app/api/acme-tools/route.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
export async function GET(req: Request) {
const res = await fetch('https://leash.build/api/integrations/mcp-config/acme-tools', {
headers: {
'X-API-Key': process.env.LEASH_API_KEY!,
cookie: req.headers.get('cookie') ?? '',
},
})
if (!res.ok) {
return Response.json({ error: 'mcp_config_unavailable' }, { status: res.status })
}
const { data: cfg } = await res.json() as { data: { url: string; headers: Record<string, string> } }
const transport = new StreamableHTTPClientTransport(new URL(cfg.url), {
requestInit: { headers: cfg.headers },
})
const mcp = new Client({ name: 'my-app', version: '1.0.0' })
await mcp.connect(transport)
const tools = await mcp.listTools()
return Response.json({ tools })
}

Why config-handoff and not proxying

We thought about putting Leash in the MCP request path — terminate the MCP client at leash.build/mcp/<slug>, forward to the customer's server. Three reasons we didn't:

  • Latency. Tool calls are on the LLM's critical path. Adding an extra hop per call is felt.
  • MCP transports change fast. Streamable HTTP, SSE, stdio. Proxying means we'd be reimplementing each transport with our own bugs.
  • The thing we're actually solving is “keep the bearer token out of the customer's code.” Once we hand them headers.Authorization, they're unblocked. Anything else is scope creep.

Failure modes

  • HTTP 404 with code: 'unknown_mcp_server' — slug not registered (or revoked). Check the dashboard.
  • HTTP 401 with code: 'api_key_required' or code: 'invalid_api_key' — check LEASH_API_KEY.
  • HTTP 402 — Growth plan or higher required.
  • The platform does not validate that the upstream MCP is up. If it's down, your MCP client surfaces the error directly.

See Error handling for the canonical LeashError code list (typed SDK calls) and the mapping from HTTP status to code.