Core Philosophy
When Auth Matters
Authentication in MCPs shines when you already have:
- An existing API with user accounts
- A database with access control
- Scopes and permissions defined
- A working SaaS with authenticated users
The goal: expose the same access control to MCP users that your existing app users have. Same scopes, same permissions, same data boundaries.
Don’t create a separate auth system for MCPs. Use your existing OAuth provider — same client ID, same tenant, same everything.
The Architecture
Setting Up Authentication
Install @leanmcp/auth:
npm install @leanmcp/auth @leanmcp/core
Provider-Specific Dependencies
npm install axios jsonwebtoken jwk-to-pem
npm install @aws-sdk/client-cognito-identity-provider axios jsonwebtoken jwk-to-pem
npm install axios jsonwebtoken jwk-to-pem
Provider Setup
Clerk (Recommended)
Clerk is the easiest option, especially if you plan to add payments later.
import { AuthProvider, Authenticated } from "@leanmcp/auth";
const authProvider = new AuthProvider('clerk', {
frontendApi: process.env.CLERK_FRONTEND_API, // e.g., 'xxx.clerk.accounts.dev'
secretKey: process.env.CLERK_SECRET_KEY // e.g., 'sk_test_xxx'
});
await authProvider.init();
Environment variables:
CLERK_FRONTEND_API=your-app.clerk.accounts.dev
CLERK_SECRET_KEY=sk_test_...
AWS Cognito
If you’re using Amazon Amplify, use the same client ID and user pool:
const authProvider = new AuthProvider('cognito', {
region: process.env.AWS_REGION,
userPoolId: process.env.COGNITO_USER_POOL_ID,
clientId: process.env.COGNITO_CLIENT_ID // Same as your web app!
});
await authProvider.init();
Auth0
const authProvider = new AuthProvider('auth0', {
domain: process.env.AUTH0_DOMAIN,
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
audience: process.env.AUTH0_AUDIENCE
});
await authProvider.init();
Method-Level Protection
import { Tool } from "@leanmcp/core";
import { Authenticated } from "@leanmcp/auth";
export class UserService {
// Protected - requires authentication
@Tool({ description: "Get user's private data" })
@Authenticated(authProvider)
async getPrivateData(input: { dataId: string }) {
// authUser is automatically injected
console.log('User ID:', authUser.sub);
console.log('Email:', authUser.email);
return await db.getData({
userId: authUser.sub,
dataId: input.dataId
});
}
// Public - no authentication
@Tool({ description: "Get public info" })
async getPublicInfo() {
return { status: "online" };
}
}
Class-Level Protection
Protect all methods in a service:
@Authenticated(authProvider)
export class SecureService {
@Tool({ description: "Tool 1" })
async tool1(input: { data: string }) {
// authUser available
return { userId: authUser.sub };
}
@Tool({ description: "Tool 2" })
async tool2(input: { data: string }) {
// authUser available here too
return { email: authUser.email };
}
}
The authUser Object
When using @Authenticated, a global authUser variable is injected containing the decoded JWT:
{
sub: 'user_2abc123xyz',
userId: 'user_2abc123xyz',
email: '[email protected]',
firstName: 'John',
lastName: 'Doe',
imageUrl: 'https://img.clerk.com/...'
}
{
sub: 'user-uuid',
email: '[email protected]',
email_verified: true,
'cognito:username': 'username',
'cognito:groups': ['admin', 'users']
}
{
sub: 'auth0|507f1f77bcf86cd799439011',
email: '[email protected]',
email_verified: true,
name: 'John Doe'
}
Client-Side: Passing Tokens
Clients pass tokens via _meta.authorization:
await mcpClient.callTool({
name: "getPrivateData",
arguments: { dataId: "123" },
_meta: {
authorization: {
type: "bearer",
token: "eyJhbGciOiJIUzI1NiIs..." // JWT from your auth provider
}
}
});
Raw MCP request:
{
"method": "tools/call",
"params": {
"name": "getPrivateData",
"arguments": { "dataId": "123" },
"_meta": {
"authorization": {
"type": "bearer",
"token": "your-jwt-token"
}
}
}
}
Adding Payments
The Challenge
Previously, you’d pass Stripe session data to your frontend via API. With MCPs, you need to:
- Create a payment session
- Return the payment URL via MCP
- Let the agent show it to the user
Using Elicitation for Payments
Trigger payment flows with elicitation:
import { Tool } from "@leanmcp/core";
import { Elicitation } from "@leanmcp/elicitation";
import { Authenticated } from "@leanmcp/auth";
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export class PaymentService {
@Tool({ description: "Upgrade to premium" })
@Authenticated(authProvider)
@Elicitation({
title: "Upgrade to Premium",
fields: [
{
name: "plan",
type: "select",
label: "Select Plan",
options: [
{ value: "monthly", label: "Monthly - $9.99/mo" },
{ value: "yearly", label: "Yearly - $99/yr (save 17%)" }
],
required: true
},
{
name: "confirm",
type: "boolean",
label: "I agree to the terms of service",
required: true
}
]
})
async upgradeToPremium(input: { plan: string; confirm: boolean }) {
if (!input.confirm) {
return { error: "Please agree to terms" };
}
const price = input.plan === 'yearly'
? process.env.STRIPE_YEARLY_PRICE_ID
: process.env.STRIPE_MONTHLY_PRICE_ID;
// Create Stripe checkout session
const session = await stripe.checkout.sessions.create({
customer_email: authUser.email,
line_items: [{ price, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/cancelled`,
metadata: {
userId: authUser.sub
}
});
return {
message: "Click the link below to complete payment",
paymentUrl: session.url,
sessionId: session.id
};
}
}
Handling Webhooks
Webhooks remain unchanged. Your existing Stripe webhook handler works the same:
// Your existing webhook - no changes needed
app.post('/webhook/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
await upgradeUser(session.metadata.userId);
break;
// ... other events
}
res.json({ received: true });
});
Checking Subscription Status
@Tool({ description: "Check subscription status" })
@Authenticated(authProvider)
async checkSubscription() {
const user = await db.getUser(authUser.sub);
return {
plan: user.subscription?.plan || 'free',
status: user.subscription?.status || 'none',
expiresAt: user.subscription?.expiresAt,
canUpgrade: !user.subscription || user.subscription.plan === 'free'
};
}
Complete Example
import { Service, Tool, MCPServer, createHTTPServer } from "@leanmcp/core";
import { AuthProvider, Authenticated } from "@leanmcp/auth";
import { Elicitation } from "@leanmcp/elicitation";
import Stripe from 'stripe';
// Initialize auth (use your existing provider!)
const authProvider = new AuthProvider('clerk', {
frontendApi: process.env.CLERK_FRONTEND_API,
secretKey: process.env.CLERK_SECRET_KEY
});
await authProvider.init();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
@Service()
@Authenticated(authProvider)
export class PremiumService {
@Tool({ description: "Get user profile and subscription" })
async getProfile() {
const user = await db.getUser(authUser.sub);
return {
email: authUser.email,
name: `${authUser.firstName} ${authUser.lastName}`,
plan: user.subscription?.plan || 'free'
};
}
@Tool({ description: "Access premium feature" })
async premiumFeature(input: { data: string }) {
const user = await db.getUser(authUser.sub);
if (user.subscription?.plan !== 'premium') {
return {
error: "Premium subscription required",
upgradeAvailable: true
};
}
// Premium feature logic
return { result: "Premium data processed" };
}
@Tool({ description: "Upgrade to premium" })
@Elicitation({
title: "Upgrade to Premium",
fields: [
{ name: "plan", type: "select", label: "Plan",
options: [
{ value: "monthly", label: "$9.99/month" },
{ value: "yearly", label: "$99/year" }
]
}
]
})
async upgrade(input: { plan: string }) {
const session = await stripe.checkout.sessions.create({
customer_email: authUser.email,
line_items: [{
price: input.plan === 'yearly'
? process.env.STRIPE_YEARLY_PRICE
: process.env.STRIPE_MONTHLY_PRICE,
quantity: 1
}],
mode: 'subscription',
success_url: `${process.env.APP_URL}/success`,
cancel_url: `${process.env.APP_URL}/cancel`,
metadata: { userId: authUser.sub }
});
return {
message: "Complete payment to upgrade",
paymentUrl: session.url
};
}
}
// Start server
const serverFactory = () => {
const server = new MCPServer({ name: "premium-service", version: "1.0.0" });
server.registerService(new PremiumService());
return server.getServer();
};
await createHTTPServer(serverFactory, { port: 3000 });
Error Handling
import { AuthenticationError } from "@leanmcp/auth";
// Error codes
| Code | When | Action |
|------|------|--------|
| `MISSING_TOKEN` | No token in request | Prompt user to authenticate |
| `INVALID_TOKEN` | Token expired/invalid | Refresh token or re-authenticate |
| `VERIFICATION_FAILED` | Token verification error | Check provider configuration |
Summary
| Aspect | Recommendation |
|---|
| Auth Provider | Use your existing OAuth (Clerk, Cognito, Auth0) |
| Client ID | Same as your web app |
| Scopes | Same as your web app |
| Payments | Use elicitation → Stripe checkout URL |
| Webhooks | No changes needed |
| Token Passing | _meta.authorization.token |