Remote MCP Server Build Playbook

A standalone recipe for building a remote MCP server that Claude.ai connects to over OAuth: architecture, Auth0 setup, the security isolation pattern, tool design, deployment, and testing. Hostnames, ports, and credentials are placeholders - swap in your own.

By Jordi Buskermolen14 min read
ai-systemsmcpplaybook
Remote MCP Server Build Playbook

The plug that lets AI assistants use your product, over OAuth: architecture, Auth0, the isolation pattern, tool design, deployment, and testing

A standalone recipe. Hostnames, ports, and credentials below are placeholders - swap in your own. For the non-technical version written for the person who signs the budget, see MCP for Business Owners.

Stack: Node + Express + @modelcontextprotocol/sdk, Auth0 for OAuth, SQLite behind it, a bare process behind nginx with TLS.


0. What this builds

A remote MCP server at https://mcp.yourapp.com (or https://yourapp.com/mcp) so Claude.ai can call your app's data in a conversation. The server:

  • Speaks the Model Context Protocol over Streamable HTTP transport
  • Authenticates users via Auth0 OAuth 2.1, validating tokens through Auth0 /userinfo (see section 2 for why that, not JWKS)
  • Derives the authenticated user's identity solely from the validated token - never from tool arguments
  • Runs as a separate process (pm2) on the same host as the main app

Two deployment shapes: a dedicated subdomain (mcp.yourapp.com, POST /) or a path on an existing host (yourapp.com/mcp, POST /mcp) - see section 5.7 for the path variant.


1. Architecture

Shape

Claude.ai
    |
    |  HTTPS  POST /  (Streamable HTTP)
    v
nginx  --->  http://127.0.0.1:PORT  (MCP Express process)
                |
                +-- Try JWKS verify  --->  Auth0 /.well-known/jwks.json
                |   (throws on JWE tokens - see section 2)
                |
                +-- /userinfo fallback  --->  Auth0 /userinfo
                |   (the actual auth mechanism on this tenant)
                |
                +-- Email -> user id lookup  --->  your DB
                |
                +-- your tools  --->  your DB (read-only where possible)

The server is a plain Express app. No MCP framework beyond the official @modelcontextprotocol/sdk. It runs on an internal port and is exposed only through nginx with TLS.

Why Streamable HTTP (not SSE or stdio)?

  • stdio is for local tools only. Remote servers need a network transport.
  • SSE transport (the original) is deprecated; Claude no longer connects to SSE-only servers.
  • Streamable HTTP is the current standard. SDK class: StreamableHTTPServerTransport.

Why stateless (new McpServer per request)?

Creating a fresh McpServer per HTTP request avoids session management. sessionIdGenerator: undefined tells the transport not to keep server-side state. Correct for a read-heavy server.

SDK version note: sessionIdGenerator: undefined is correct in @modelcontextprotocol/sdk ^1.12.x. The transport API changed between 0.x and 1.x - check the changelog if TypeScript complains on this option.

Opening the database - read-only is the common case

For a read-only MCP server (search, list - no writes), open read-only with a busy_timeout. Do not set WAL mode from the MCP side: PRAGMA journal_mode=WAL on a read-only connection throws SQLITE_READONLY. The main app enables WAL; the read-only connection inherits it.

const db = new Database('/path/to/app.db', {
  readonly: true,
  timeout: 5000,  // busy_timeout in ms
});

Open read-write only if the MCP genuinely writes (e.g. a request counter). That's the exception.

Key package versions

"@modelcontextprotocol/sdk": "^1.12.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"zod": "^3.24.0",
"express": "^4.18.2",
"better-sqlite3": "^11.7.0"

2. Auth0 setup

2.1 The tenant

Create a tenant at auth0.com. Note the domain - dev-xxxxxxxxxx.us.auth0.com. That's your AUTH0_DOMAIN. One tenant can be shared across multiple MCP servers (section 2.9).

2.2 The API (resource server)

Auth0 -> Applications -> APIs -> Create API:

| Field | Value | |-------|-------| | Name | Your App MCP | | Identifier (audience) | https://mcp.yourapp.com - the exact HTTPS URL of your MCP server | | Signing algorithm | RS256 |

Gotcha - audience must match exactly. The aud claim must equal the MCP_AUDIENCE constant in your server. Use the server URL, not a made-up string like my-api.

Then: enable Allow Offline Access (enables offline_access / refresh tokens; without it Claude re-authenticates every hour).

2.3 The user-facing application

Auth0 -> Applications -> Applications -> Create Application:

| Field | Value | |-------|-------| | Name | Your App MCP Client | | Type | Regular Web Application |

Use Regular Web Application, not SPA. A Regular Web App is a confidential client with a client secret. Claude's connector requires both Client ID and Client Secret. An SPA has no secret and the registration fails.

Allowed Callback URLs - add BOTH:

https://claude.ai/api/mcp/auth_callback,
https://claude.com/api/mcp/auth_callback

Gotcha - the .com variant. Some flows use claude.com. Omit it and those users get an Auth0 "callback URL mismatch" that is very hard to diagnose (Auth0 doesn't say which URL failed). Add both from the start.

Allowed Web Origins and Allowed Logout URLs: https://claude.ai, https://claude.com

Advanced -> Grant Types: ensure Authorization Code is enabled. Note the Client ID and Client Secret.

2.4 Email resolution - you probably don't need a Post Login Action

Read this before creating any Auth0 Action.

Many Auth0 tenants issue JWE tokens (JSON Web Encryption) for Claude's OAuth flow - 5-segment encrypted tokens (header.key.iv.ciphertext.tag), not the standard 3-segment signed JWTs. jsonwebtoken cannot verify JWE and throws jwt malformed. In logs:

JWT failed: jwt malformed | segments=5 | preview=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwi...

That header decodes to {"alg":"dir","enc":"A256GCM"} - direct AES-256-GCM. The token is opaque to your server.

The real email path is the /userinfo fallback. When JWKS verification fails, call GET https://{AUTH0_DOMAIN}/userinfo with the token as the Bearer; Auth0 validates it and returns { email }. No Post Login Action, no custom claim, no namespace.

Check your tenant's format first:

  1. Server logs show jwt malformed | segments=5 - JWE - /userinfo handles it, no Action.
  2. Base64-decode a token's first segment. alg: dir or RSA-OAEP - JWE. alg: RS256 with a kid - signed JWT that JWKS verifies.

Cost of /userinfo: one outbound HTTPS call to Auth0 per authenticated request (~50-200ms). Works regardless of token format.

Only if your tenant issues signed JWS with no email claim, add a Post Login Action:

exports.onExecutePostLogin = async (event, api) => {
  const namespace = 'https://mcp.yourapp.com'; // must be a URL
  if (event.user.email) {
    api.accessToken.setCustomClaim(`${namespace}/email`, event.user.email);
  }
};

The Auth0 free plan caps Actions at 5 tenant-wide - don't spend one unless confirmed needed.

2.5 M2M test application (optional)

Lets you get a token without a browser. But M2M issues signed JWS even on JWE tenants, so it never exercises the /userinfo path, and M2M tokens carry no email - expect 401 "Token is missing email claim". Useful only to confirm the server boots and JWKS/audience checks pass. The real handshake (section 6) is the actual gate.

2.6 Refresh tokens

On the application: Refresh Token Rotation enabled, Absolute Expiration (~30 days), Inactivity Expiration (~7 days). Plus Allow Offline Access on the API (2.2). Without this, Claude re-prompts the user every hour.

2.7 The no-DCR problem - custom Client ID and Secret

Claude's connector asks for a client_id and client_secret. The standard OAuth way is Dynamic Client Registration (DCR, RFC 7591) - Auth0 does not support DCR. So create the Regular Web Application manually (2.3) and hand Claude its Client ID + Secret:

| Field | Value | |-------|-------| | Server URL | https://mcp.yourapp.com | | Client ID | your Auth0 application's Client ID | | Client Secret | your Auth0 application's Client Secret |

2.8 Auth0 gotcha summary

| # | Do | Avoid | |---|-----|-------| | 1 | Check token format first (logs / decode header) before adding Actions | Assuming a Post Login Action is needed - on JWE tenants /userinfo handles email | | 2 | Create a Regular Web Application (confidential, has a secret) | An SPA - no secret, connector registration fails | | 3 | Supply both Client ID and Client Secret | Client ID only - connector won't complete auth | | 4 | Add BOTH claude.ai and claude.com callbacks | claude.ai only - .com causes silent failures | | 5 | Enable Allow Offline Access on the API | Off - tokens expire hourly, users re-prompted | | 6 | Use the full https://... URL as the audience | A short string like my-api - harder to debug | | 7 | Test with a real OAuth handshake before shipping | Shipping after M2M only - it's a different token path | | 8 | Include the /userinfo fallback in your auth middleware | Omitting it - on JWE tenants real users never reach the JWKS path |

2.9 Sharing one tenant across MCP servers

One tenant, many apps. Each app still needs its own API (distinct audience URL - audience binding is what scopes tokens) and its own Regular Web Application (own Client ID + Secret). Shared Login Actions apply to all apps; if you add one, check the token's audience before mutating claims.


3. The isolation pattern (non-negotiable)

Every query must be scoped to the authenticated user. Not optional.

requireMcpAuth middleware:
    1. Extract Bearer token
    2. Try JWKS verify (RS256) - works on signed-JWS tenants
       Fails "jwt malformed" on JWE - step 3
    3. /userinfo fallback: GET Auth0/userinfo -> { email }
    4. Look up user: SELECT id FROM users WHERE email = ?
    5. req.mcpUser = { ownerId, email }
    6. next()

Tool handler:
    - receives only its own args from Claude - NO user id in args
    - reads ownerId from the closure (req.mcpUser)
    - every DB query: WHERE owner_id = ?  bound to ownerId

The tool input schema must never contain a user id, owner id, or any field that could influence whose data is returned. Identity is established once by the middleware and captured by closure. A crafted user_id: "someone-else" must have no effect - and it can't, because the field doesn't exist in the schema and is never read.

server.tool('search_items', 'Search the user's items...', {
  query: z.string().min(1).max(500),
  limit: z.number().int().min(1).max(20).optional().default(10),
  // NO user_id field. Ever.
}, async ({ query, limit }) => {
  const rows = db.prepare('SELECT * FROM items WHERE owner_id = ? ...').all(ownerId); // ownerId from closure
  ...
});

4. Tool design

  • Descriptions are Claude-readable. The description string is how Claude decides whether to call a tool. Write plain English from the user's perspective.
  • Cap results server-side. Never trust the limit arg alone: const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 20);
  • Return readable text, not JSON. Claude receives tool output as a string. Numbered lists with dates/URLs and a short summary read better than a JSON blob.
  • isError: true on failure so Claude can recover: return { content: [{ type: 'text', text: 'Search failed.' }], isError: true };
  • Zod for every input - validates before your handler runs, surfaces structured errors.

5. Deployment

5.1 DNS

A record for mcp.yourapp.com -> your host IP. (Skip if deploying path-based on an existing host.)

5.2 nginx server block

server {
    server_name mcp.yourapp.com;

    location / {
        proxy_pass              http://127.0.0.1:PORT;   # your MCP port
        proxy_http_version      1.1;
        proxy_set_header        Host                $host;
        proxy_set_header        X-Real-IP           $remote_addr;
        proxy_set_header        X-Forwarded-For     $proxy_add_x_forwarded_for;
        proxy_set_header        X-Forwarded-Proto   $scheme;
        proxy_set_header        Authorization       $http_authorization;
        proxy_buffering         off;
        proxy_read_timeout      310s;   # Claude's tool timeout is ~300s
        proxy_connect_timeout   10s;
        proxy_send_timeout      310s;
    }

    listen 443 ssl;
    ssl_certificate     /etc/letsencrypt/live/mcp.yourapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mcp.yourapp.com/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
}

server {
    listen 80;
    server_name mcp.yourapp.com;
    return 301 https://$host$request_uri;
}

nginx -t && systemctl reload nginx.

5.3 TLS

certbot --nginx -d mcp.yourapp.com, then certbot renew --dry-run.

5.4 pm2

pm2 start server.js --name yourapp-mcp
pm2 save

pm2 logs yourapp-mcp to tail. pm2 startup to survive reboots.

5.5 Environment

.env (gitignored), read via dotenv at startup. Minimum:

AUTH0_DOMAIN=your-tenant.auth0.com   # no https://, no trailing slash
MCP_PORT=PORT                        # internal; must match nginx proxy_pass

Never put secrets in nginx config, the repo, or pm2's stored config.


6. Testing

6.1 Discovery (public):

curl -s https://mcp.yourapp.com/.well-known/oauth-protected-resource | jq .

Expect { resource, authorization_servers, scopes_supported, bearer_methods_supported }. A 502 or HTML error page means nginx or the process is down.

6.2 401 (no token):

curl -s -X POST https://mcp.yourapp.com/ -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .

Expect 401 Missing Bearer token with a WWW-Authenticate header.

6.3 Real OAuth handshake (the only test that matters):

  1. Add the server to Claude (Settings -> connectors), URL + Client ID + Client Secret.
  2. Claude redirects to Auth0 login; after login it shows the server connected with tools listed.
  3. Call a tool in conversation.
  4. Tail logs: pm2 logs yourapp-mcp. On a JWE tenant you'll see jwt malformed | segments=5 followed by a successful call - that's the /userinfo path working end to end.

| Symptom | Cause | Fix | |---------|-------|-----| | Auth0 "callback URL mismatch" | Missing claude.com callback | Add https://claude.com/api/mcp/auth_callback | | Connected but tool returns 401 | /userinfo returned no email, or signed-JWS tenant with no email claim | Check logs; if segments=3 and no email, add the Post Login Action (2.4) | | "Server not responding" | nginx 502 | Check the process is up; internal port matches nginx | | Works once, stops after ~1h | Refresh tokens not configured | Allow Offline Access on the API + Refresh Token Rotation on the app |


5.7 Path-based deployment (alternative to a subdomain)

To colocate on an existing host, route by path instead of subdomain root - e.g. https://yourapp.com/mcp with POST /mcp.

nginx

# discovery - RFC 9728 path-appended form. Put this BEFORE location /mcp.
location = /.well-known/oauth-protected-resource/mcp {
    proxy_pass http://127.0.0.1:PORT/;
    proxy_set_header Host $host;
    proxy_buffering off;
}
location /mcp {
    proxy_pass http://127.0.0.1:PORT/mcp;
    proxy_set_header Authorization $http_authorization;
    proxy_set_header Host $host;
    proxy_buffering off;
    proxy_read_timeout 310s;
}

Express routes

app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => {
  res.json({
    resource: MCP_AUDIENCE,  // e.g. https://yourapp.com/mcp
    authorization_servers: [`https://${AUTH0_DOMAIN}`],
    scopes_supported: ['openid', 'email', 'offline_access'],
    bearer_methods_supported: ['header'],
  });
});
app.post('/mcp', requireMcpAuth, mcpRequestHandler);

WWW-Authenticate - the path-based gotcha

When auth fails, the server sends WWW-Authenticate: Bearer resource_metadata="{discovery URL}". Claude reads that URL to find the authorization server. If it's wrong, discovery fails and the whole OAuth flow breaks silently. For a path deployment it must point at the path-appended form:

// WRONG - root, nothing there
`Bearer resource_metadata="https://yourapp.com/.well-known/oauth-protected-resource"`
// CORRECT - RFC 9728 path-appended
`Bearer resource_metadata="https://yourapp.com/.well-known/oauth-protected-resource/mcp"`

The audience is the endpoint URL: https://yourapp.com/mcp. Use that exact string as the Auth0 API identifier and as MCP_AUDIENCE.


7. Read vs. read-write

Start read-only. Ship list/search/get tools first; prove the server works, isolation is correct, and Claude uses the tools as expected. The isolation guarantee is the same: ownerId from closure, WHERE owner_id = ? on every query.

Write tools need extra safeguards, added incrementally:

  1. Confirm before writing - return a preview, or require an explicit confirm: true.
  2. Idempotency - a retried call must not duplicate. Idempotency keys or check-then-insert.
  3. Narrow scopes - split create_draft from publish so it's a two-step flow.
  4. Audit logging - user, timestamp, exact input, per write.
  5. No bulk writes - cap to a few items per call, or per-item confirmation.
  6. Scope the risk - "create draft" (worst case: a deletable draft) vs "publish now" (much higher). Design accordingly.

Appendix: requireMcpAuth middleware (the JWE /userinfo fallback)

The /userinfo fallback is the primary auth mechanism on JWE tenants. The JWKS try block is kept for signed-JWS tenants.

const MCP_AUDIENCE = 'https://mcp.yourapp.com';
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
const WWW_AUTHENTICATE = `Bearer resource_metadata="${MCP_AUDIENCE}/.well-known/oauth-protected-resource"`;
// path-based: `Bearer resource_metadata="https://yourapp.com/.well-known/oauth-protected-resource/mcp"`

const jwksClient = jwksRsa({
  jwksUri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`,
  cache: true, cacheMaxEntries: 5, cacheMaxAge: 600_000,
  rateLimit: true, jwksRequestsPerMinute: 10,
});

function verifyToken(token) {
  return new Promise((resolve, reject) => {
    jwt.verify(token, (header, cb) => {
      jwksClient.getSigningKey(header.kid, (err, key) =>
        err ? cb(err) : cb(null, key.getPublicKey()));
    }, { audience: MCP_AUDIENCE, issuer: `https://${AUTH0_DOMAIN}/`, algorithms: ['RS256'] },
      (err, decoded) => (err ? reject(err) : resolve(decoded)));
  });
}

async function requireMcpAuth(req, res, next) {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Bearer ')) {
    res.setHeader('WWW-Authenticate', WWW_AUTHENTICATE);
    return res.status(401).json({ error: 'Missing Bearer token' });
  }
  const token = authHeader.slice(7);
  try {
    // Signed-JWS path (also M2M).
    const decoded = await verifyToken(token);
    const email = decoded.email || decoded[`${MCP_AUDIENCE}/email`];
    if (!email) { res.setHeader('WWW-Authenticate', WWW_AUTHENTICATE); return res.status(401).json({ error: 'Token missing email claim' }); }
    const user = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
    if (!user) return res.status(403).json({ error: 'No account for this email.' });
    req.mcpUser = { ownerId: user.id, email };
    return next();
  } catch (err) {
    // JWE fallback: 5-segment encrypted token - JWKS can't verify it. /userinfo can.
    if (token.split('.').length === 5) {
      try {
        const resp = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { headers: { Authorization: `Bearer ${token}` } });
        if (resp.ok) {
          const { email } = await resp.json();
          if (email) {
            const user = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
            if (user) { req.mcpUser = { ownerId: user.id, email }; return next(); }
            return res.status(403).json({ error: 'No account for this email.' });
          }
        }
      } catch { /* fall through */ }
    }
    res.setHeader('WWW-Authenticate', WWW_AUTHENTICATE);
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

Want more of this?

I write regularly on LinkedIn about what I'm building and learning: agency growth, AI development, product judgment, and the messy reality behind making things work.

Follow on LinkedIn