7 min read

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, and src/app/api/mcp/keys/README.md.


What ships

PieceWhere
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 manifestsrc/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

  1. 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:read scope, the custom scopes mcp:read / mcp:write, and CIMD or dynamic client registration. No new environment variables.
  2. Public URLNEXT_PUBLIC_APP_URL must be the address clients can reach (it is what the Integrations page prints and what the discovery documents name).
  3. 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/integrations shows 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:

  1. TokenAuthorization: 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).
  2. Rate limit — 120 tool calls per minute per token (429 + Retry-After).
  3. Organization — from the token: an API key’s claims.organizationId (pinned when it was minted), or the org_id claim Clerk stamps on OAuth tokens when the consent screen’s org picker ran. An OAuth token without one gets a 403 step-up challenge naming user:org:read.
  4. Membership and role — looked up in our Membership table, never taken from the token. No active membership → 404 (the organization’s existence is not confirmed). Suspended organization → 403.
  5. Organization toggle — re-read on every call; off → 403.
  6. Toolstools/list is filtered by role (members see the member tier) in a fixed order. Write tools require mcp: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:

  1. Factory entry in src/lib/ai/tools.ts{ description, inputSchema, execute, needsApproval? }, closure-scoped by organizationId, returning { success, data | error }.
  2. Manifest entry in src/lib/ai/tool-manifest.tsminRole, access (read | write), needsApproval, mcp: true. Member-tier entries go before admin-tier ones; order is the tools/list order.
  3. Chat UI — a display config in chat-messages.tsx; for a write tool also approval-request-card.tsx and prompt-suggestions.tsx.
  4. Tests — unit tests in src/lib/ai/__tests__/tools.test.ts (the manifest-completeness test fails until the factory and manifest agree).
  5. Eval case — a tool-select-* line in src/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

SymptomCause / 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 keyThe key’s user is no longer an active member of the key’s organization
503 from /.well-known/oauth-protected-resourceNEXT_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 registerEnable 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