plugins/mcp-auth/skills/express-mcp-server/SKILL.md
# Express.js MCP OAuth Authentication with Scalekit ## Overview This skill documents the pattern for building production-ready MCP (Model Context Protocol) servers using Express.js, TypeScript, and OAuth 2.1 Bearer token authentication via Scalekit. This approach provides fine-grained control over HTTP request handling, middleware chains, and server behavior for Node.js-based MCP implementations. ## When to Use This Pattern Use this Express.js MCP integration when you need: - **Node.js ecos
npx skillsauth add scalekit-inc/claude-code-authstack plugins/mcp-auth/skills/express-mcp-serverInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill documents the pattern for building production-ready MCP (Model Context Protocol) servers using Express.js, TypeScript, and OAuth 2.1 Bearer token authentication via Scalekit. This approach provides fine-grained control over HTTP request handling, middleware chains, and server behavior for Node.js-based MCP implementations.
Use this Express.js MCP integration when you need:
Don't use this pattern if you prefer Python's ecosystem or if a simpler MCP server setup (without Express) meets your requirements.
MCP Client → Express Server (401 + WWW-Authenticate)
MCP Client → Scalekit (Exchange code for token)
Scalekit → MCP Client (Bearer token)
MCP Client → Express Server (POST /mcp + Bearer token)
Express Middleware → Scalekit SDK (Validate token)
McpServer → Tool Handler → Response
/.well-known/oauth-protected-resource) for client discoveryRequired variables:
SK_ENV_URL: Scalekit environment URL (issuer)SK_CLIENT_ID + SK_CLIENT_SECRET: SDK authentication credentialsEXPECTED_AUDIENCE: The resource identifier that tokens must targetPROTECTED_RESOURCE_METADATA: Complete OAuth discovery metadata JSONPORT: Server listening port (must match registered server URL)Security:
.env files to version control.env to .gitignore immediatelySK_CLIENT_SECRET regularlyEXPECTED_AUDIENCE matches your server's public URL exactly (including trailing slash)import { Scalekit } from '@scalekit-sdk/node';
const scalekit = new Scalekit(
SK_ENV_URL,
SK_CLIENT_ID,
SK_CLIENT_SECRET
);
Best practices:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({
name: 'Greeting MCP',
version: '1.0.0'
});
server.tool(
'greet_user',
'Greets the user with a personalized message.',
{
name: z.string().min(1, 'Name is required'),
},
async ({ name }: { name: string }) => ({
content: [
{
type: 'text',
text: `Hi ${name}, welcome to Scalekit!`
}
]
})
);
Tool registration:
Zod validation benefits:
app.use(async (req: Request, res: Response, next: NextFunction) => {
// Exempt public endpoints
if (req.path === '/.well-known/oauth-protected-resource' || req.path === '/health') {
next();
return;
}
// Extract Bearer token
const header = req.headers.authorization;
const token = header?.startsWith('Bearer ')
? header.slice('Bearer '.length).trim()
: undefined;
if (!token) {
res.status(401)
.set('WWW-Authenticate', WWW_HEADER_VALUE)
.json({ error: 'Missing Bearer token' });
return;
}
try {
// Validate with Scalekit SDK
await scalekit.validateToken(token, {
audience: [EXPECTED_AUDIENCE]
});
next();
} catch (error) {
res.status(401)
.set('WWW-Authenticate', WWW_HEADER_VALUE)
.json({ error: 'Token validation failed' });
}
});
Key principles:
app.use() for middleware that runs on every requestreturn after sending 401 responses (prevents "headers already sent" errors)WWW-Authenticate header on 401 responsesnext() to pass control to subsequent middleware/routesCommon mistake: Forgetting to return after sending a response leads to "Cannot set headers after they are sent" errors.
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
app.post('/', async (req: Request, res: Response) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
try {
await transport.handleRequest(req, res, req.body);
} catch (error) {
res.status(500).json({ error: 'MCP transport error' });
}
});
Transport responsibilities:
sessionIdGenerator: undefined)Stateless design: Setting sessionIdGenerator: undefined ensures each request is independent—suitable for serverless deployments.
app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!PROTECTED_RESOURCE_METADATA) {
res.status(500).json({ error: 'PROTECTED_RESOURCE_METADATA config missing' });
return;
}
const metadata = JSON.parse(PROTECTED_RESOURCE_METADATA);
res.type('application/json').send(JSON.stringify(metadata, null, 2));
});
Purpose:
WWW-Authenticate headerError handling: Return 500 if metadata is missing to signal misconfiguration (deployment should fail fast).
import cors from 'cors';
app.use(cors({
origin: true, // Allow all origins
credentials: false // No credentials needed for Bearer tokens
}));
Configuration options:
origin: true: Reflects request origin (development convenience)origin: ['https://app.example.com']: Whitelist specific origins (production)credentials: false: Bearer tokens don't require cookies/credentialsmethods: ['GET', 'POST', 'OPTIONS']: Limit allowed HTTP methodsProduction recommendation: Use explicit origin whitelist instead of origin: true.
exp claimjwt.verify()express-rate-limitnpx @modelcontextprotocol/inspector@latest
Testing workflow:
npm run devhttp://localhost:3002/# Get token from Scalekit (via OAuth flow or test endpoint)
export TOKEN="<your-access-token>"
# Test authenticated MCP request
curl -X POST http://localhost:3002/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "greet_user",
"arguments": {
"name": "Saif"
}
}
}'
# Test missing token (should return 401)
curl -v -X POST http://localhost:3002/
# Test invalid token
curl -X POST http://localhost:3002/ \
-H "Authorization: Bearer invalid-token" \
-H "Content-Type: application/json"
import request from 'supertest';
import { app } from './server';
describe('MCP Authentication', () => {
test('returns 401 without token', async () => {
const response = await request(app).post('/');
expect(response.status).toBe(401);
expect(response.headers['www-authenticate']).toContain('Bearer');
});
test('returns 401 with invalid token', async () => {
const response = await request(app)
.post('/')
.set('Authorization', 'Bearer invalid-token');
expect(response.status).toBe(401);
});
test('health check is public', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
test('metadata endpoint is public', async () => {
const response = await request(app).get('/.well-known/oauth-protected-resource');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/json');
});
});
Test dependencies:
{
"devDependencies": {
"@types/jest": "^29.5.12",
"jest": "^29.7.0",
"supertest": "^6.3.4",
"ts-jest": "^29.1.2"
}
}
# Install autocannon for HTTP load testing
npm install -g autocannon
# Test authenticated endpoint throughput
autocannon -c 10 -d 30 \
-m POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-b '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
http://localhost:3002/
Symptom: Tokens fail validation with "invalid audience" error
Cause: EXPECTED_AUDIENCE doesn't match the Server URL registered in Scalekit
Fix: Ensure both values are identical including protocol, host, port, and trailing slash
Example:
// ❌ Wrong - missing trailing slash
EXPECTED_AUDIENCE=http://localhost:3002
// ✅ Correct - matches Scalekit registration
EXPECTED_AUDIENCE=http://localhost:3002/
Symptom: Error: Cannot set headers after they are sent to the client
Cause: Forgetting to return after sending a response in middleware
Fix: Always return immediately after res.json() or res.send()
Example:
// ❌ Wrong - continues to next middleware
if (!token) {
res.status(401).json({ error: 'Missing token' });
}
next(); // This runs even after sending 401
// ✅ Correct - returns after response
if (!token) {
res.status(401).json({ error: 'Missing token' });
return; // Prevents calling next()
}
Symptom: CORS errors, authentication bypassed, or parsing failures Cause: Middleware execution order matters in Express Fix: Correct order is: CORS → body parsing → authentication → routes
Example:
// ✅ Correct order
app.use(cors());
app.use(express.json());
app.use(authMiddleware);
app.get('/public', publicRoute);
app.post('/', protectedRoute);
Symptom: Clients can't discover how to authenticate
Cause: PROTECTED_RESOURCE_METADATA not set or malformed JSON
Fix: Copy exact JSON from Scalekit dashboard, verify with JSON.parse()
Debugging:
# Test metadata endpoint
curl http://localhost:3002/.well-known/oauth-protected-resource
# Should return valid JSON with authorization_endpoint
Symptom: Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'
Cause: Missing .js extension in ES module imports
Fix: Always include .js extension when importing from MCP SDK
Example:
// ❌ Wrong - missing .js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
// ✅ Correct - includes .js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
Symptom: Tests pass initially then fail after 1 hour Cause: Access tokens expire (default 3600 seconds) Fix: Implement token refresh before each test run or use shorter test cycles
import jwt from 'jsonwebtoken';
// Extend Express Request type
declare global {
namespace Express {
interface Request {
tokenPayload?: {
sub: string;
scope: string[];
};
}
}
}
app.use(async (req: Request, res: Response, next: NextFunction) => {
// ... existing token validation ...
// Decode token to access claims (after validation)
const decoded = jwt.decode(token) as any;
req.tokenPayload = {
sub: decoded.sub,
scope: decoded.scope?.split(' ') || []
};
next();
});
// Tool with scope requirement
server.tool(
'admin_action',
'Performs an admin action',
{ action: z.string() },
async ({ action }, { req }) => {
if (!req.tokenPayload?.scope.includes('admin')) {
throw new Error('Requires admin scope');
}
// ... admin logic ...
}
);
app.use(async (req: Request, res: Response, next: NextFunction) => {
// ... validate token ...
const decoded = jwt.decode(token) as any;
req.orgId = decoded.org_id;
next();
});
server.tool(
'get_org_data',
'Retrieves organization-specific data',
{},
async (_params, { req }) => {
const orgId = req.orgId;
const data = await fetchDataForOrg(orgId);
return {
content: [{ type: 'text', text: JSON.stringify(data) }]
};
}
);
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per window
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
// Rate limit by token subject (user ID)
keyGenerator: (req: Request) => {
const token = req.headers.authorization?.slice('Bearer '.length);
if (!token) return req.ip;
const decoded = jwt.decode(token) as any;
return decoded.sub || req.ip;
}
});
// Apply to MCP endpoint
app.post('/', limiter, async (req: Request, res: Response) => {
// ... MCP transport handling ...
});
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: ['req.headers.authorization'], // Never log tokens
});
app.use((req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
path: req.path,
status: res.statusCode,
duration: Date.now() - start,
});
});
next();
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error({ err, path: req.path }, 'Unhandled error');
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message
});
});
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.0",
"@scalekit-sdk/node": "^2.0.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^5.1.0",
"zod": "^3.25.57"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/node": "^20.11.19",
"tsx": "^4.7.0",
"typescript": "^5.4.5"
}
}
Dependency purposes:
@modelcontextprotocol/sdk: Official MCP protocol implementation@scalekit-sdk/node: Scalekit authentication SDK for token validationcors: Cross-Origin Resource Sharing middlewaredotenv: Environment variable loading from .env filesexpress: Fast, unopinionated web frameworkzod: TypeScript-first schema validationtsx: TypeScript execution for development (faster than ts-node)typescript: TypeScript compiler{
"dependencies": {
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"pino": "^8.17.2",
"pino-http": "^9.0.0"
}
}
Production enhancements:
express-rate-limit: Rate limiting middlewarehelmet: Security headers middlewarepino: High-performance JSON loggerpino-http: HTTP request logging middlewarepackage.json (use "express": "5.1.0" not "^5.1.0")^ for development flexibilitynpm audit regularly for security vulnerabilitiesnpm ci in production for reproducible buildsFROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source
COPY . .
# Build TypeScript
RUN npm run build
# Expose port
EXPOSE 3002
# Start server
CMD ["npm", "start"]
{
"apps": [{
"name": "mcp-server",
"script": "dist/server.js",
"instances": 4,
"exec_mode": "cluster",
"env": {
"NODE_ENV": "production"
}
}]
}
Start with PM2:
npm run build
pm2 start ecosystem.config.json
import serverless from 'serverless-http';
// ... existing Express app setup ...
export const handler = serverless(app);
Note: Ensure stateless transport configuration for serverless environments.
const config = {
development: {
port: 3002,
corsOrigin: true,
logLevel: 'debug'
},
production: {
port: parseInt(process.env.PORT || '3002'),
corsOrigin: process.env.ALLOWED_ORIGINS?.split(',') || [],
logLevel: 'info'
}
};
const env = process.env.NODE_ENV || 'development';
const appConfig = config[env];
A full production-ready Express.js MCP server is available in the Scalekit MCP Auth Demos repository:
GitHub Repository: scalekit-inc/mcp-auth-demos/tree/main/greeting-mcp-node
This example includes:
src/main.ts - Main server entry pointsrc/lib/auth.ts - OAuth discovery endpoint handlersrc/lib/middleware.ts - Token validation middlewaresrc/lib/transport.ts - MCP transport layer setupsrc/tools/ - Tool implementationscd greeting-mcp-node
npm install
npm run build
npm start
See README.md for complete setup instructions.
development
Walks through a structured production readiness checklist for Scalekit SSO implementations. Use when the user says they are going live, launching to production, doing a pre-launch review, hardening their SSO setup, or wants to verify their Scalekit implementation is production-ready.
data-ai
Implements complete SSO and authentication flows using Scalekit. Handles modular SSO, IdP-initiated login, user session management, and enterprise customer onboarding. Use when adding authentication, SSO, SAML, OIDC, or user login to applications.
testing
Implements Scalekit's admin portal for customer self-serve SSO and SCIM configuration. Generates portal links server-side and embeds the portal as an iframe in the app's settings UI. Use when the user asks to add an admin portal, customer self-serve SSO setup, iframe embed for SSO config, shareable setup link, or let customers configure their own SSO or SCIM connection.
development
Walks through a structured production readiness checklist for Scalekit SCIM provisioning implementations. Use when the user says they are going live, launching to production, doing a pre-launch review, or wants to verify their SCIM directory sync implementation is production-ready.