skills/express-mcp-server/SKILL.md
Build a production-ready MCP server using Express.js, TypeScript, and OAuth 2.1 Bearer token authentication via Scalekit. Use when the user wants to build an MCP server with Express.js and needs fine-grained control over HTTP request handling and middleware chains.
npx skillsauth add scalekit-inc/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.
tools
Create or review Scalekit custom providers/connectors for proxy-only usage, including MCP providers. Use this skill when the task is to gather API docs, infer whether a connector is OAuth, Basic, Bearer, or API Key, determine if it is an MCP provider, determine required tracked fields like domain or version, generate provider JSON, check for existing custom providers, show update diffs, run approved create or update curls, and print resolved delete curls.
tools
Use when a developer is new to Scalekit and needs guidance on where to start, doesn't know which auth plugin or skill to choose, wants to connect an AI agent or agentic workflow to third-party services (Gmail, Slack, Notion, Google Calendar), needs OAuth or tool-calling auth for agents, wants to add authentication to a project but hasn't chosen an approach yet, or needs to install the Scalekit plugin for their AI coding tool (Claude Code, Codex, Copilot CLI, Cursor, or other agents).
tools
Use when a user asks to generate, review, validate, or fix any code snippet that uses Scalekit APIs or SDKs. This skill is the single source of truth for Scalekit code correctness — it can generate illustration-quality snippets from scratch (for docs, websites, or integration guides) and review existing code to catch wrong method names, missing parameters, security anti-patterns, and broken auth flows. Covers all four SDKs (Node, Python, Go, Java), raw REST API calls, and both Scalekit product suites — SaaSKit (SSO, login, sessions, RBAC, SCIM) and AgentKit (connections, tool calling, MCP auth). Use when the user says review my Scalekit code, generate a Scalekit example, validate this auth flow, check my SDK usage, fix my Scalekit integration, write a code sample for docs, or anything involving Scalekit code quality.
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.