Skip to main content

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

Provider Setup

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();

Protecting Tools

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/...'
}

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:
  1. Create a payment session
  2. Return the payment URL via MCP
  3. 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

AspectRecommendation
Auth ProviderUse your existing OAuth (Clerk, Cognito, Auth0)
Client IDSame as your web app
ScopesSame as your web app
PaymentsUse elicitation → Stripe checkout URL
WebhooksNo changes needed
Token Passing_meta.authorization.token