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:
import { config, list } from '@opensaas/stack-core'
import { authPlugin } from '@opensaas/stack-auth'
import { mcp } from '@opensaas/stack-auth/plugins'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
// Add MCP plugin to Better Auth
betterAuthPlugins: [mcp({ loginPage: '/sign-in' })],
}),
],
// ... rest of config
})
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'],
},
// 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:
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) {
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 { auth } from '@/lib/auth'
import { getContext } from '@/.opensaas/context'
const { GET, POST, DELETE } = createMcpHandlers({
config: await config,
getSession: createBetterAuthMcpAdapter(auth),
getContext,
})
export { GET, POST, DELETE }
2. Create OAuth Discovery Endpoints
Better Auth provides OAuth endpoints automatically, but some MCP clients need them at specific paths.
Create app/.well-known/oauth-authorization-server/route.ts:
export async function GET(req: Request) {
// Delegate to Better Auth's built-in handler
const authUrl = new URL('/api/auth/.well-known/oauth-authorization-server', req.url)
return fetch(authUrl.toString(), {
headers: req.headers,
})
}
Create app/.well-known/oauth-protected-resource/route.ts:
export async function GET() {
const baseUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3000'
return Response.json({
resource: baseUrl,
authorization_servers: [`${baseUrl}/api/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
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?: Record<string, any>, // Prisma where filters
orderBy?: Record<string, 'asc' | 'desc'>,
take?: number,
skip?: number,
}
Example:
{
"where": { "status": { "equals": "published" } },
"orderBy": { "createdAt": "desc" },
"take": 10
}
Create Tool
Tool Name: list_{dbKey}_create
Description: Create a new record
Input Schema:
{
data: Record<string, any> // Fields to set
}
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: { id: string },
data: Record<string, any>
}
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.
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: isSignedIn,
update: isAuthor,
},
}),
internalNotes: text({
access: {
read: isAuthor, // Only author can see
create: isAuthor,
update: isAuthor,
},
}),
}
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 JSON-RPC error ("Access denied or record not found") that deliberately does not distinguish between a missing record and denied access.
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 database directly:
pnpm db:studio - 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