MCP Setup Guide
Learn how to integrate the Model Context Protocol (MCP) with Stack to enable AI assistants like Claude to interact with your application's data.
Overview
The MCP integration in Stack provides:
- Automatic CRUD Tools - Read, create, update, delete operations for all lists
- Custom Tools - Add specialized operations for your business logic
- Better Auth OAuth - Secure authentication flow with AI assistants
- Access Control - All tools respect your existing access control rules
- Per-List Configuration - Enable/disable tools per list
Prerequisites
Before setting up MCP, you need:
- A working Stack application
- Better Auth configured (see Authentication Guide)
- Claude Desktop or another MCP client
Installation
MCP functionality is built into the core packages - no additional installation required!
You'll need:
@opensaas/stack-core- MCP handlers and tool generation@opensaas/stack-auth- Better Auth OAuth adapter
Configuration
1. Enable Auth Plugin with MCP
In your opensaas.config.ts, configure the auth plugin with the MCP plugin.
Four things about this block come from better-auth rather than from the stack, and each is load-bearing:
jwt()sits besidemcp(). Since better-auth 1.7,mcp()is built on the OAuth Provider, which issues JWT-based access tokens and requires better-auth's ownjwt()plugin registered alongside it.mcpis imported from@opensaas/stack-auth/plugins. better-auth 1.7 split it out ofbetter-auth/pluginsinto the optional@better-auth/mcppeer, which the stack re-exports.consentPageis required, and names the page where a user approves or denies an MCP client's requested scopes.resourceis the canonical protected-resource identifier (RFC 8707/9728). It must match themcp.basePathset in the next step, andhttpis accepted only on loopback hosts.
import { config, list } from '@opensaas/stack-core'
import { text } from '@opensaas/stack-core/fields'
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
import { jwt } from 'better-auth/plugins'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
betterAuthPlugins: [
jwt(),
mcp({
loginPage: '/sign-in',
consentPage: '/consent',
// Register the scopes the tool gate below requires
scopes: ['openid', 'profile', 'email', 'mcp:read', 'mcp:write'],
resource: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/api/mcp`,
}),
],
}),
],
db: { provider: 'postgresql' },
lists: {
Post: list({
fields: { title: text() },
access: { operation: { query: () => true } },
}),
},
})
2. Enable MCP in Config
Add MCP configuration to your config:
export default config({
// ... plugins
mcp: {
enabled: true,
basePath: '/api/mcp',
auth: {
type: 'better-auth',
loginPage: '/sign-in',
scopes: ['openid', 'profile', 'email', 'mcp:read', 'mcp:write'],
},
// Enforce OAuth scopes per tool (see "Scopes" below)
scopes: { read: 'mcp:read', write: 'mcp:write' },
// Global defaults for all lists
defaultTools: {
read: true,
create: true,
update: true,
delete: true,
},
},
// ... lists
})
3. Configure List-Level Tools
Control which tools are available per list:
lists: {
Post: list({
fields: {
// ... your fields
},
// MCP configuration for this list
mcp: {
tools: {
read: true,
create: true,
update: true,
delete: true,
},
},
}),
}
4. Add Custom Tools (Optional)
Create specialized operations for your lists. A custom tool's handler reaches the database through the same access-controlled context.db surface as the rest of your app, so a denied write returns null rather than throwing — check it and answer the client, as below:
import { z } from 'zod'
lists: {
Post: list({
fields: {
title: text(),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
}),
publishedAt: timestamp(),
},
mcp: {
tools: { read: true, create: true, update: true, delete: true },
// Add custom tools
customTools: [
{
name: 'publishPost',
description: 'Publish a draft post and set publishedAt timestamp',
inputSchema: z.object({
postId: z.string(),
}),
handler: async ({ input, context }) => {
const post = await context.db.Post.update({
where: { id: input.postId },
data: {
status: 'published',
publishedAt: new Date(),
},
})
if (post === null) {
return {
error: 'Failed to publish post. Access denied or post not found.',
}
}
return {
success: true,
message: `Post "${post.title}" published successfully`,
post,
}
},
},
],
},
}),
}
Setup Route Handlers
1. Create MCP API Route
Create app/api/mcp/[[...transport]]/route.ts:
import { createMcpHandlers } from '@opensaas/stack-core/mcp'
import { createBetterAuthMcpAdapter } from '@opensaas/stack-auth/mcp'
import config from '@/opensaas.config'
import { getContext } from '@/.opensaas/context'
const { GET, POST, DELETE } = createMcpHandlers({
config: await config,
getSession: createBetterAuthMcpAdapter({
resource: 'https://example.com/api/mcp', // the `resource` passed to mcp()
baseURL: 'https://example.com/api/auth', // Better Auth's base URL, including its base path
}),
getContext,
})
export { GET, POST, DELETE }
Pass resourceMetadataUrl to createMcpHandlers (for example https://example.com/.well-known/oauth-protected-resource) and the 401 challenge carries it as resource_metadata, per RFC 9728, so an MCP client can discover your authorization server. Serve that document with createOAuthProtectedResourceHandler, and the authorization server metadata with createOAuthDiscoveryHandler, both from @opensaas/stack-auth/mcp.
2. Create OAuth Discovery Endpoints
Better Auth provides OAuth endpoints automatically, but MCP clients look for them at the root .well-known paths. The helpers answer those paths in-process through your Better Auth instance: they make no outbound fetch, forward no request headers, and the request's host selects no server.
Create app/.well-known/oauth-authorization-server/route.ts:
import { createOAuthDiscoveryHandler } from '@opensaas/stack-auth/mcp'
import { auth } from '@/lib/auth'
export const GET = createOAuthDiscoveryHandler(auth)
Create app/.well-known/oauth-protected-resource/[[...resource]]/route.ts (the optional catch-all also serves the resource-path variant, e.g. /.well-known/oauth-protected-resource/api/mcp):
import { createOAuthProtectedResourceHandler } from '@opensaas/stack-auth/mcp'
import { auth } from '@/lib/auth'
export const GET = createOAuthProtectedResourceHandler(auth)
How Tools Are Built
MCP tools are derived from your config at request time by createMcpHandlers — nothing MCP-specific is generated to disk. Every list gets CRUD tools automatically (unless disabled via mcp.enabled: false on the list), plus any custom tools you define and any tools registered by plugins.
You only need to run pnpm generate when your schema or lists change, to keep the Prisma schema and generated context in sync.
Configure Claude Desktop
Add your MCP server to Claude Desktop's configuration file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-app": {
"url": "http://localhost:3000/api/mcp",
"transport": {
"type": "http"
},
"auth": {
"type": "oauth2",
"authorizationUrl": "http://localhost:3000/.well-known/oauth-authorization-server"
}
}
}
}
For production deployments, replace localhost:3000 with your production URL.
Authentication Flow
When Claude Desktop connects to your MCP server:
- OAuth Discovery - Claude fetches OAuth metadata from
.well-knownendpoints - Authorization - Claude opens your login page in a browser
- User Login - User signs in using your auth UI
- Token Exchange - Better Auth issues access token
- MCP Connection - Claude uses token to authenticate tool calls
- Access Control - All operations respect your access control rules
Machine clients are not supported. A
client_credentialstoken represents no user and carries the client id as itssub, so a token whosesubequals itsclient_idorazpclaim is answered with a 401. Only user-delegated tokens are accepted.
Generated Tools
The MCP handler creates CRUD tools for each list. {dbKey} is the camelCase form of the list key (Post → list_post_query, BlogPost → list_blogPost_query):
Query Tool
Tool Name: list_{dbKey}_query
Description: Query records with filters, sorting, and pagination
Input Schema: where takes the secured read's own closed vocabulary, not a Prisma filter — the same operators Access Control lists. fields is a projection, covered under "Selecting Related Data" below. take is capped at 100.
{
where?: Record<string, unknown>,
orderBy?: Record<string, 'asc' | 'desc'>,
take?: number,
skip?: number,
fields?: Record<string, unknown>,
}
Example:
{
"where": { "status": { "equals": "published" } },
"orderBy": { "createdAt": "desc" },
"take": 10
}
Selecting Related Data with fields
By default query returns a record's own scalar and virtual fields — never its relations, matching how context.db reads behave. Reaching a relation without fields means following its foreign key with a second query call.
The optional fields argument lets the assistant select relation data in the same call, without paying for a full include (every column of every touched relation) when it only needed a couple of fields:
{
"fields": {
"title": true,
"author": {
"fields": { "name": true, "email": true }
},
"comments": {
"fields": { "text": true },
"where": { "approved": { "equals": true } },
"orderBy": { "createdAt": "desc" },
"take": 5,
"count": true
}
}
}
- Scalars and virtuals are selected with
true. - A to-one relation (
authorabove) takes a nestedfieldsonly. - A to-many relation (
commentsabove) additionally acceptswhere,orderBy,take, andskip, scoped exactly like the equivalentcontext.dbread — a nestedtakedefaults to 5 when omitted and is clamped to a hard cap of 50, since nested pagination multiplies with the roottake. - A to-many's row count can be requested with
count: true, in place of its rows (omitfieldson that relation) or alongside them.
The generated tool schema enumerates exactly two levels of each list's own vocabulary — the list's own fields, and for each relation, the related list's own scalar/virtual fields. A relation named at that second level is not itself selectable further; reaching past it is a second query call, the same round-trip the bare-read default already assumes. This is what keeps the schema finite against a self-referential relationship (e.g. a Category with parent/children) and what makes an unadvertised request rare: naming a field the schema doesn't enumerate, or nesting a relation a third level deep, is refused as an isError tool result naming what was asked for — never served on a best-effort basis, and never a JSON-RPC protocol error.
The schema is generated per session: a relation whose related list denies this session's operation-level query access, or has mcp.enabled: false, is omitted from the vocabulary entirely — the same rule that governs whether a list gets tools at all (see "Access Control" below). Field-level read access cannot be evaluated at schema-generation time (it needs a fetched row), so it still applies only when the read actually runs, exactly as it does everywhere else.
Create Tool
Tool Name: list_{dbKey}_create
Description: Create a new record
Input Schema:
The tool derives one property per writable field, so data is checked against the list's own schema rather than accepting anything:
{
data: Record<string, unknown>
}
Example:
{
"data": {
"title": "My Post",
"slug": "my-post",
"content": "Post content",
"status": "draft"
}
}
Update Tool
Tool Name: list_{dbKey}_update
Description: Update an existing record
Input Schema:
where is identity-only — exactly one key, id. A secondary unique column is refused before the access gate runs.
{
where: { id: string },
data: Record<string, unknown>
}
Example:
{
"where": { "id": "post-123" },
"data": { "status": "published" }
}
Delete Tool
Tool Name: list_{dbKey}_delete
Description: Delete a record
Input Schema:
{
where: {
id: string
}
}
Example:
{
"where": { "id": "post-123" }
}
Access Control
All MCP tools respect your existing access control rules defined in opensaas.config.ts.
Tool Listing Is Per-Session
tools/list is evaluated per session. A list whose operation-level query access denies the session outright (=== false) doesn't appear in the listing at all — none of its four CRUD tools, and no relation entry elsewhere pointing at it (including in another list's fields projection schema, above). A list with mcp.enabled: false is omitted the same way, as a relation target as well as a tool owner.
Scopes
OAuth scopes are not enforced unless you configure them; a valid token otherwise gets every tool access control allows. Set mcp.scopes to gate tools by scope:
mcp: {
enabled: true,
scopes: { read: 'mcp:read', write: ['mcp:write'] },
},
lists: {
Report: list({
// Overrides the config-level value for this list
mcp: { scopes: { write: 'reports:write' } },
// ...
}),
}
querytools need thereadscope(s);create,updateanddeleteneed thewritescope(s).- A custom tool (
mcp.customToolsor a plugin'sregisterMcpTool) can declarescopes: ['reports:run']. A list custom tool that omitsscopesrequires the configuredwritescope (fail closed) whenmcp.scopesis set; declarescopes: []to leave it open to any token. - A token missing a scope gets a tool error naming it, and the tool is left out of that session's
tools/list. - Scopes gate tools only; access control still decides rows and fields.
- In your own route handlers,
hasScopes(session, [...])from@opensaas/stack-auth/mcpdoes the same check.
Operation-Level Access
Controls whether a user can perform an operation at all:
access: {
operation: {
query: ({ session }) => {
// Anonymous users can only see published posts
if (!session) {
return { status: { equals: 'published' } }
}
return true
},
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
}
Field-Level Access
Controls which fields can be read or modified:
fields: {
title: text({
access: {
read: () => true,
create: ({ session }) => !!session,
update: ({ session, item }) => !!session && item?.authorId === session.userId,
},
}),
internalNotes: text({
access: {
// Only the author can see it
read: ({ session, item }) => !!session && item?.authorId === session.userId,
create: ({ session }) => !!session,
update: ({ session, item }) => !!session && item?.authorId === session.userId,
},
}),
}
A field rule returns a boolean, not a filter: it decides per fetched item. The operation-level isAuthor above returns a filter and is not reusable here — FieldAccess types the three slots as boolean-returning, so passing it is a type error rather than a silent allow.
Silent Failures
When access is denied, query tools return empty results rather than errors — this prevents information leakage about whether records exist. Create, update, and delete tools return a successful tool result marked isError: true that deliberately does not distinguish between a missing record and denied access — this is a recoverable tool failure, not a JSON-RPC protocol error, so the calling model can see it and adjust its request. Update and delete answer "Failed to update/delete record. Access denied or record not found."; create, which has no record to miss, answers "Failed to create record. Access denied or validation failed."
Session Fields over MCP
The Better Auth MCP adapter's session carries userId only. Access rules that rely on other session fields (e.g. session.role) will see them as undefined over MCP unless you use a custom session provider that loads and attaches those fields — any extra fields on the MCP session are passed through to access control.
Testing MCP Tools
Using Claude Desktop
- Restart Claude Desktop after updating config
- Start a new conversation
- Ask Claude to interact with your data:
Can you show me all published posts?
Can you create a new post titled "Test Post"?
Can you update post-123 to published status?
Using HTTP Requests
Test tools directly via HTTP:
# Get access token (login first to get session)
ACCESS_TOKEN="your-token-here"
# List available tools
curl -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"method": "tools/list", "params": {}}'
# Call a tool
curl -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"method": "tools/call",
"params": {
"name": "list_post_query",
"arguments": {
"where": { "status": { "equals": "published" } },
"take": 10
}
}
}'
Architecture
┌─────────────────┐
│ Claude Desktop │
└────────┬────────┘
│ OAuth 2.0
↓
┌─────────────────┐
│ Better Auth │ ← MCP Plugin
│ (OAuth Flow) │
└────────┬────────┘
│ Access Token
↓
┌─────────────────┐
│ MCP Handler │ ← Tools derived from
│ (stack-core/mcp)│ config at runtime
└────────┬────────┘
│ context.db
↓
┌─────────────────┐
│ Access Control │ ← Your Rules
│ Engine │
└────────┬────────┘
│
↓
┌─────────────────┐
│ Prisma DB │ ← Your Data
└─────────────────┘
Best Practices
Security
- Always use access control - Never set all operations to
() => truein production - Validate custom tool inputs - Use Zod schemas for type safety
- Use HTTPS in production - OAuth requires secure connections
- Scope tokens appropriately - Only request needed OAuth scopes
Tool Design
- Keep tools focused - Each custom tool should do one thing well
- Provide clear descriptions - Help AI understand when to use each tool
- Return meaningful errors - Help debug issues without leaking sensitive info
- Test with real data - Verify access control works as expected
Performance
- Limit query results - Use
takeparameter to prevent large responses - Index frequently filtered fields - Mark fields with
isIndexed: true - Batch operations carefully - Consider rate limits in custom tools
Troubleshooting
Claude Desktop Can't Find Server
Problem: "Failed to connect to MCP server"
Solutions:
- Verify your app is running:
pnpm dev - Check the MCP URL in
claude_desktop_config.json - Ensure OAuth endpoints are accessible
- Restart Claude Desktop after config changes
Authentication Fails
Problem: OAuth flow redirects but doesn't complete
Solutions:
- Verify
BETTER_AUTH_URLenvironment variable is set - Check that login page exists at the configured path
- Ensure Better Auth is properly configured
- Check browser console for errors during OAuth flow
Tools Return Empty Results
Problem: Tools run but return no data
Possible Causes:
- Access control denying access - Check your access rules
- No matching records - Verify data exists in database
- Session not found - OAuth token may have expired
Debug Steps:
- Check the database directly with a Postgres client (
psql "$DATABASE_URL") - Test access control with direct context calls
- Verify session is being passed to context
Custom Tools Not Working
Problem: Custom tool defined but not available
Solutions:
- Restart the development server — tools are read from config at runtime
- Check the tool is on a list's
mcp.customTools(or registered by a plugin viaregisterMcpTool) - Verify the input schema is valid (Zod schema or plain JSON Schema object)
- Check handler function returns correct format
Example Project
See the complete working example at examples/mcp-demo/ in the repository:
- Full configuration with custom tools
- OAuth setup with Better Auth
- Access control patterns
- A
create-user.tsscript for seeding a test user
Next Steps
- Authentication Guide - Setup Better Auth
- Access Control - Secure your data
- Hooks System - Add business logic
- Better Auth MCP Plugin - OAuth details
- Model Context Protocol - MCP specification