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 : 'user@example.com' ,
firstName : 'John' ,
lastName : 'Doe' ,
imageUrl : 'https://img.clerk.com/...'
}
{
sub : 'user-uuid' ,
email : 'user@example.com' ,
email_verified : true ,
'cognito:username' : 'username' ,
'cognito:groups' : [ 'admin' , 'users' ]
}
{
sub : 'auth0|507f1f77bcf86cd799439011' ,
email : 'user@example.com' ,
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
Auth Examples See working auth examples
Elicitation Guide Learn about elicitation
OAuth Client Browser-based OAuth flows with PKCE
OAuth Server Build authorization servers with provider proxy