Express MCP Server with OAuth 2.0
Overview
This template provides a production-ready Model Context Protocol (MCP) server built with TypeScript and Express, using HTTP transport secured by the full OAuth 2.0 Authorization Code flow. It is designed for developers who need to expose authenticated API capabilities to AI assistants like Claude, where the underlying APIs require delegated user authorization rather than static API keys.
The template implements three realistic GitHub API tools β repository listing, issue creation, and repository search β demonstrating how to build tools that operate on behalf of an authenticated user. The OAuth token lifecycle (authorization, token exchange, refresh, and secure in-memory storage) is fully implemented, so you can study or extend it for any OAuth 2.0-compatible provider such as Google, Slack, or a custom authorization server.
This is an intermediate-level template aimed at TypeScript developers familiar with Node.js and REST APIs who want to understand MCP server construction, OAuth delegation patterns, and production deployment practices including Docker, structured logging, and automated testing.
What You'll Learn
- How to construct an MCP server using the
@modelcontextprotocol/sdkwith HTTP/SSE transport on Express - Implementing the full OAuth 2.0 Authorization Code flow including PKCE, token exchange, and refresh token rotation
- Securing MCP tool endpoints with Bearer token middleware that validates OAuth access tokens
- Registering strongly-typed MCP tools with Zod input schemas and structured error responses
- Setting up Pino for structured JSON logging with per-request correlation IDs propagated through async context
- Writing Jest tests for MCP tool handlers, OAuth middleware, and error boundary behaviour
- Building optimised multi-stage Docker images that separate build and runtime dependencies
- Configuring docker-compose for local development with hot-reload and environment variable injection
- Managing OAuth token storage and expiry with in-memory token stores suitable for upgrade to Redis
- Applying the MCP error taxonomy (
InvalidParams,InternalError,MethodNotFound) correctly - Reading and validating all configuration from environment variables with typed defaults
- Deploying the containerised server to Railway or Fly.io with health-check endpoints
Architecture
Claude Desktop / MCP Client
β HTTP POST /mcp (Bearer <access_token>)
βΌ
Express Server
β
Auth Middleware βββΊ Token Introspection / Validation
β β
β Token Store (Memory/Redis)
βΌ
MCP SDK Handler
β
Tool Router
βββ list_repositories βββΊ GitHub REST API
βββ create_issue βββΊ GitHub REST API
βββ search_repositoriesβββΊ GitHub REST API
β
Pino Logger (JSON + correlation ID)
β
stdout / log aggregator
OAuth Flow (separate endpoints):
GET /auth/login β GitHub Authorization URL
GET /auth/callback β Token Exchange β Token Store
POST /auth/refresh β Refresh Token β Token Store
Project Structure
express-mcp-oauth/
βββ src/
β βββ server.ts # Express app + MCP server setup
β βββ config.ts # Typed config from environment
β βββ auth.ts # OAuth 2.0 flow + Bearer middleware
β βββ logger.ts # Pino structured logger + correlation
β βββ __tests__/
β βββ server.test.ts # Jest tests for tools + middleware
βββ Dockerfile # Multi-stage production image
βββ docker-compose.yml # Local development stack
βββ package.json # Dependencies + scripts
βββ tsconfig.json # TypeScript compiler config
βββ jest.config.ts # Jest configuration
βββ .env.example # All environment variables documented
βββ .gitignore
Prerequisites
- Node.js 20 or higher
- npm 10 or higher
- Docker Desktop (for containerised development)
- A GitHub OAuth App (create one here) with callback URL set to
http://localhost:3000/auth/callback - Claude Desktop or another MCP client for end-to-end testing
Quick Start
# 1. Clone or copy this template
git clone https://github.com/your-org/express-mcp-oauth
cd express-mcp-oauth
# 2. Install dependencies
npm install
# 3. Configure environment
cp .env.example .env
# Edit .env with your GitHub OAuth App credentials
# 4. Start in development mode (ts-node with hot reload)
npm run dev
# 5. Initiate OAuth flow in your browser
open http://localhost:3000/auth/login
# Complete GitHub authorization β the token is stored in memory
# 6. Run the test suite
npm test
# 7. Build and run with Docker
docker compose up --build
Features
- Full OAuth 2.0 Authorization Code flow with PKCE, state parameter CSRF protection, token exchange, and refresh token rotation
- Three GitHub API tools β
list_repositories,create_issue,search_repositoriesβ with Zod input validation - Pino structured logging with JSON output, configurable log level, and per-request correlation IDs via
AsyncLocalStorage - Bearer token middleware that validates OAuth access tokens before routing MCP requests
- Multi-stage Dockerfile producing a minimal production image (~180 MB) with non-root user
- docker-compose configuration for local development with volume mounts and health checks
- Jest test suite covering tool execution, OAuth middleware, error handling, and edge cases
- Typed configuration loaded from environment variables with validation and sensible defaults
- Health check endpoint at
GET /healthfor load balancer and orchestrator probes - Graceful shutdown handling SIGTERM and SIGINT with in-flight request draining
Works With
| Client | Transport | Status |
|---|---|---|
| Claude Desktop | HTTP | β Supported |
| Claude Code | HTTP | β Supported |
| Cursor | HTTP | β Supported |
| Windsurf | HTTP | β Supported |
| Continue | HTTP | β Supported |
To add this server to Claude Desktop, append to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"github-oauth": {
"command": "curl",
"args": ["-s", "-X", "POST", "http://localhost:3000/mcp"],
"transport": "http",
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer YOUR_ACCESS_TOKEN"
}
}
}
}
Configuration
| Variable | Description | Required |
|---|---|---|
PORT | HTTP port the server listens on | Optional (default: 3000) |
NODE_ENV | Runtime environment (development/production) | Optional (default: development) |
LOG_LEVEL | Pino log level: trace debug info warn error | Optional (default: info) |
GITHUB_CLIENT_ID | OAuth App Client ID from GitHub Developer Settings | Required |
GITHUB_CLIENT_SECRET | OAuth App Client Secret from GitHub Developer Settings | Required |
GITHUB_REDIRECT_URI | Callback URL registered in your GitHub OAuth App | Required |
OAUTH_STATE_SECRET | Secret for signing the OAuth state parameter (β₯32 chars) | Required |
SESSION_SECRET | Secret for signing session cookies (β₯32 chars) | Required |
TOKEN_STORE_TTL_SECONDS | Seconds before cached tokens are evicted | Optional (default: 3600) |
ALLOWED_ORIGINS | Comma-separated CORS origins | Optional (default: *) |
MCP_SERVER_NAME | Name advertised in MCP server info | Optional (default: github-mcp) |
MCP_SERVER_VERSION | Version advertised in MCP server info | Optional (default: 1.0.0) |
Deployment
Docker (Local)
docker build -t express-mcp-oauth .
docker run -p 3000:3000 --env-file .env express-mcp-oauth
Railway
npm install -g @railway/cli
railway login
railway new
railway link
railway variables set GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=xxx GITHUB_REDIRECT_URI=https://your-app.railway.app/auth/callback OAUTH_STATE_SECRET=xxx SESSION_SECRET=xxx
railway up
Fly.io
npm install -g flyctl
fly auth login
fly launch --name express-mcp-oauth --no-deploy
fly secrets set GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=xxx GITHUB_REDIRECT_URI=https://express-mcp-oauth.fly.dev/auth/callback OAUTH_STATE_SECRET=xxx SESSION_SECRET=xxx
fly deploy
Docker Compose (Production-like)
docker compose -f docker-compose.yml up -d --build
docker compose logs -f mcp-server
Production Checklist
-
GITHUB_CLIENT_SECRET,OAUTH_STATE_SECRET, andSESSION_SECRETare stored in a secrets manager (not in version control) -
NODE_ENV=productionis set in the runtime environment -
LOG_LEVEL=infoorwarnin production (notdebugortrace) -
GITHUB_REDIRECT_URImatches exactly the callback URL registered in GitHub OAuth App settings - HTTPS is enforced at the reverse-proxy or load balancer layer
- CORS
ALLOWED_ORIGINSis restricted to known client origins - Docker image is scanned for CVEs with
docker scoutor Trivy before deployment - Health check endpoint
/healthis configured in your orchestrator (Railway/Fly.io/K8s) - Token store is backed by Redis (not in-memory) for multi-instance deployments
- Graceful shutdown timeout matches your orchestrator's termination grace period
- Rate limiting is applied to
/auth/endpoints to prevent abuse - Dependency versions are pinned and
npm auditreports no high/critical vulnerabilities - Container runs as non-root user (already configured in the Dockerfile)
- Structured logs are forwarded to a log aggregator (Datadog, Logtail, etc.)
- OAuth refresh token rotation is enabled in your GitHub App settings
Testing
Run the test suite
# All tests
npm test
# Watch mode during development
npm run test:watch
# Coverage report
npm run test:coverage
Manual testing with curl
# Health check
curl http://localhost:3000/health
# Start OAuth flow (visit in browser)
open http://localhost:3000/auth/login
# After completing OAuth, copy the access_token from the callback response
export TOKEN=<your_access_token>
# Call the MCP server β list tools
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Execute list_repositories tool
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_repositories","arguments":{"per_page":5}}}'
Testing with Claude Desktop
- Complete the OAuth flow by visiting
http://localhost:3000/auth/login - Note the
access_tokenreturned on the callback page - Add the server to
claude_desktop_config.jsonwith the token in the Authorization header - Restart Claude Desktop and look for the π tools icon
- Ask Claude: "List my GitHub repositories" or "Search for TypeScript MCP servers on GitHub"