MCP Server: Expose Your SaaS to AI Clients
Your AI assistant’s tools are now reachable from outside the app. Claude, Cursor, Claude Code, or a script your customer writes can list and call the same organization-scoped tools the in-app chat uses — over the Model Context Protocol (MCP), with Clerk as the OAuth authorization server and API keys minted in the product for headless use.
Everything a caller can do is bounded three ways: by the organization its token names, by the role we read from our own membership table, and by the scopes on the token (mcp:read, mcp:write). There is no approval card over MCP — the write scope is the approval.
This guide is the concepts + setup tour. The code-level notes ship in your copy of the kit:
src/lib/mcp/README.md,src/app/api/mcp/README.md, andsrc/app/api/mcp/keys/README.md.
What ships
| Piece | Where |
|---|---|
| MCP endpoint (Streamable HTTP, stateless) | POST /api/mcp |
| OAuth discovery (RFC 9728 / RFC 8414) | /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server |
| Per-organization on/off switch | /dashboard/settings → MCP access (owner/admin) |
| API keys for headless callers | /dashboard/integrations (every member) |
| Tool registry manifest | src/lib/ai/tool-manifest.ts |
Built on MCP SDK 2, whose schema follows spec revision 2026-07-28; the protocolVersion it negotiates on the wire is 2025-11-25, the SDK’s latest. No sessions, no Redis, one server built per request. 2025-era Streamable HTTP clients are served by the SDK’s legacy fallback from the same endpoint.
One-time setup
- Clerk Dashboard — follow Clerk Configuration → 5. MCP Server: enable API keys (press Enable with both self-serve switches off — the app mints keys through the Backend API), the
user:org:readscope, the custom scopesmcp:read/mcp:write, and CIMD or dynamic client registration. No new environment variables. - Public URL —
NEXT_PUBLIC_APP_URLmust be the address clients can reach (it is what the Integrations page prints and what the discovery documents name). - Enable it per organization — an owner or admin opens Organization Settings (
/dashboard/settings) and switches Allow MCP access on. It is off by default in every organization; until then/dashboard/integrationsshows a placeholder asking members to contact an admin.
Connect a client
Claude (claude.ai, Claude Desktop) — OAuth, no key
Settings → Connectors → Add custom connector → paste https://<your-app>/api/mcp. Claude discovers Clerk through the well-known documents, sends the user through Clerk’s consent screen — where they choose the organization to act in — and receives a token scoped to it. Claude Desktop always uses OAuth; it has no static-token option.
Cursor, VS Code, Claude Code — API key
Create a key on /dashboard/integrations (read only, or read and write), then:
{
"mcpServers": {
"vibeready": {
"url": "https://<your-app>/api/mcp",
"headers": { "Authorization": "Bearer <your API key>" }
}
}
}
claude mcp add --transport http vibeready https://<your-app>/api/mcp \
--header "Authorization: Bearer <your API key>"
A script — the official client
npm install @modelcontextprotocol/client
import {
Client,
StreamableHTTPClientTransport,
} from '@modelcontextprotocol/client'
const client = new Client({ name: 'nightly-report', version: '1.0.0' })
await client.connect(
new StreamableHTTPClientTransport(new URL('https://<your-app>/api/mcp'), {
requestInit: {
headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` },
},
})
)
const { tools } = await client.listTools()
console.log(tools.map(t => t.name))
const usage = await client.callTool({
name: 'getAIUsageSummary',
arguments: { range: '7d' },
})
console.log(usage.structuredContent)
The first call worth making from any client is getCurrentContext: it tells the model who it is acting as, in which organization, with which role and scopes.
How access is enforced
Every request runs the same chain, in this order, and fails closed:
- Token —
Authorization: Bearer …must be a Clerk OAuth access token or API key. Session cookies and session JWTs are refused (401 with an RFC 9728 challenge pointing at the discovery document). - Rate limit — 120 tool calls per minute per token (429 +
Retry-After). - Organization — from the token: an API key’s
claims.organizationId(pinned when it was minted), or theorg_idclaim Clerk stamps on OAuth tokens when the consent screen’s org picker ran. An OAuth token without one gets a 403 step-up challenge naminguser:org:read. - Membership and role — looked up in our
Membershiptable, never taken from the token. No active membership → 404 (the organization’s existence is not confirmed). Suspended organization → 403. - Organization toggle — re-read on every call; off → 403.
- Tools —
tools/listis filtered by role (members see the member tier) in a fixed order. Write tools requiremcp:write; without it they answer a tool error, not an HTTP error, so the client’s conversation keeps flowing.
What gets recorded: one structured log line per call (ids and outcome, never arguments), one AIUsage row with kind: 'mcp' (visible on the AI Usage dashboard), and for write tools the usual audit row carrying triggered_by: 'mcp' plus the token and client ids. Key creation, revocation, and the toggle are audited too.
Add a tool (chat and MCP at once)
The manifest is the hub. A tool exists once and is exposed everywhere the manifest says:
- Factory entry in
src/lib/ai/tools.ts—{ description, inputSchema, execute, needsApproval? }, closure-scoped byorganizationId, returning{ success, data | error }. - Manifest entry in
src/lib/ai/tool-manifest.ts—minRole,access(read|write),needsApproval,mcp: true. Member-tier entries go before admin-tier ones; order is thetools/listorder. - Chat UI — a display config in
chat-messages.tsx; for a write tool alsoapproval-request-card.tsxandprompt-suggestions.tsx. - Tests — unit tests in
src/lib/ai/__tests__/tools.test.ts(the manifest-completeness test fails until the factory and manifest agree). - Eval case — a
tool-select-*line insrc/lib/ai/evals/datasets/, then run the suite (Agent Evals).
Nothing MCP-specific is needed: the bridge registers whatever the manifest exposes, with the caller’s role and scopes applied. Ask your AI assistant to use the new-ai-tool skill and it will walk these steps.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Client shows the sign-in page instead of a 401 | /api/mcp must stay in the middleware’s public routes (it verifies the bearer itself) |
| 403 “Reconnect and choose an organization” | Enable user:org:read on the Clerk OAuth application; reconnect so consent runs again |
| 403 “MCP access is not enabled” | An admin has to switch it on in Organization Settings (/dashboard/settings) |
| Keys card says “API keys are not enabled for this Clerk instance” | Enable API keys in the Clerk Dashboard, both switches off (step 1); the card recovers on the next visit |
| 404 on every call with a valid key | The key’s user is no longer an active member of the key’s organization |
503 from /.well-known/oauth-protected-resource | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY missing or a placeholder |
| Write tool answers “lacks the mcp:write scope” | Mint a read-and-write key, or reconnect the OAuth client so it requests mcp:write |
| Claude Desktop cannot register | Enable CIMD or dynamic client registration in Clerk (step 5 of the Clerk guide) |
What is deliberately not here (yet)
- Organization-subject “service” keys (every tool acts as a user).
- MCP resources and prompts; only tools are exposed.
- Connector tools (Slack, GitHub, …) — that is the Integrations hub on the roadmap; when it lands, connector tools join the same manifest and can be exposed here with a per-organization switch.
Ready to build with VibeReady?
Get the full AI-native SaaS foundation with production infrastructure, AI development framework, and all integrations.
Get VibeReady — From $99