Skip to main content
Glama
igagansingh

understanding-mcp

by igagansingh

understanding-mcp

A zero-dependency Node.js playground for debugging how MCP clients authenticate to an MCP server over HTTP. It runs two servers in one process:

  1. MCP server (resource server) on http://localhost:3001 — a minimal streamable-HTTP MCP server (initialize, tools/list, tools/call, ping).

  2. OAuth authorization server on http://localhost:3002 — discovery metadata, Dynamic Client Registration (DCR), authorization + token endpoints.

Every request and every response — headers included — is printed to the console and kept in a ring buffer you can fetch from GET /__log on either server. Point a real MCP client at http://localhost:3001/mcp (or run node client.js) and watch the whole OAuth handshake happen hop by hop.

No npm install, no dependencies. Requires Node 18+.

Quick start

node server.js        # terminal 1: watch every request/response
node client.js        # terminal 2: drive the full flow end-to-end

client.js models how a real MCP client connects:

  1. POST an MCP request without a token.

  2. Read the WWW-Authenticate challenge. If it carries resource_metadata, use that URL; otherwise construct it from the MCP endpoint path: <mcp_base_url>/.well-known/oauth-protected-resource/<url_remaining_path>.

  3. Fetch the protected resource metadata, read authorization_servers.

  4. Discover the authorization server metadata by building <auth_server_base_url>/.well-known/oauth-authorization-server/<authz_server_remaining_url>.

  5. If registration_endpoint is advertised → DCR (POST /register). If it is absent → skip straight to the preconfigured client_id.

  6. Run the authorization-code flow (PKCE S256 + resource parameter + iss validation), exchange the code for an access token.

  7. Retry the MCP request with Authorization: Bearer <token>.

Related MCP server: Vulnerable MCP Server

Endpoints

Server

Endpoint

Purpose

MCP (3001)

POST /mcp

Streamable-HTTP MCP endpoint. No/expired token → 401 with WWW-Authenticate. Missing scope → 403 insufficient_scope.

MCP (3001)

GET /.well-known/oauth-protected-resource/mcp

RFC 9728 protected resource metadata. Only the path derived from the MCP endpoint serves it; any other /.well-known/oauth-protected-resource* (including the root, when the MCP server lives at /mcp) returns 404. Non-GET methods return 405.

MCP (3001)

GET /__log

Recent request/response log entries as JSON.

AUTH (3002)

GET /.well-known/oauth-authorization-server

RFC 8414 authorization server metadata.

AUTH (3002)

POST /register

RFC 7591 Dynamic Client Registration.

AUTH (3002)

GET /authorize

Authorization endpoint (auto-approves, redirects with code, state, iss).

AUTH (3002)

POST /token

Token endpoint (authorization_code with PKCE, and client_credentials).

AUTH (3002)

GET /__log

Recent request/response log entries as JSON.

Configuration

All settings are environment variables with sensible defaults.

Variable

Default

Meaning

MCP_URL

http://localhost:3001/mcp

Canonical URI of the MCP server. Everything else (listen port, endpoint path, resource audience, well-known metadata path) is derived from it.

MCP_PORT

(from MCP_URL)

Override for the MCP server's listen port.

MCP_BASE_URL

(origin of MCP_URL)

Override for the origin used to build well-known URLs.

MCP_ENDPOINT

(path of MCP_URL)

Override for the MCP endpoint path.

AUTH_PORT

3002

Port of the authorization server.

AUTH_BASE_URL

http://localhost:3002

Issuer of the authorization server.

WWW_AUTH_RESOURCE_METADATA

on

off → 401 challenges omit resource_metadata, forcing the client to construct the well-known URI itself.

AUTH_DCR

on

offregistration_endpoint is not advertised and /register is disabled (forces the preconfigured-client path).

DCR_ALLOWED_REDIRECT_URIS

(empty = allow any)

Comma-separated whitelist. When set, DCR rejects any redirect_uri not on the list.

PRECONFIGURED_CLIENT_ID

preconfigured-client

Static client_id available without registration (public client, no secret).

PRECONFIGURED_REDIRECT_URIS

http://localhost:8899/callback

redirect_uris allowed for the preconfigured client.

TOKEN_TTL_SECONDS

3600

Access token lifetime.

Reproduce each scenario

# 1. DCR succeeds -> token issued to a dynamically registered client
node server.js
node client.js

# 2. DCR advertised, but registration rejected (redirect URI not whitelisted)
#    -> client falls back to the preconfigured client_id
DCR_ALLOWED_REDIRECT_URIS='http://only-whitelisted.example/callback' node server.js
node client.js

# 3. No DCR at all -> client uses the preconfigured client_id directly
AUTH_DCR=off node server.js
node client.js

# 4. 401 challenge without resource_metadata -> client derives the well-known URI
WWW_AUTH_RESOURCE_METADATA=off node server.js
node client.js

You can also connect a real MCP client to http://localhost:3001/mcp and watch the handshake in the server log.

Findings worth blogging about

These are the non-obvious behaviors the log surfaces. All four are reproducible above.

1. WWW-Authenticate may or may not tell you where the metadata lives

The 401 challenge can carry resource_metadata="<uri>" (RFC 9728 §5.1), or just Bearer scope="...". When it does not, the client has to guess the URL by inserting the MCP endpoint's path: <mcp_base_url>/.well-known/oauth-protected-resource/<url_remaining_path>. Set WWW_AUTH_RESOURCE_METADATA=off and the log shows the client doing exactly this derivation.

2. Metadata gives you no guarantee that DCR will succeed

This is the big one. The presence of registration_endpoint only means the endpoint exists — it says nothing about whether your registration will be accepted. Real servers (e.g. Figma) advertise DCR but enforce a redirect-URI whitelist, so a client registering its own redirect_uri gets a 400 invalid_redirect_uri.

Reproduce it: set DCR_ALLOWED_REDIRECT_URIS to anything that isn't the client's callback. The metadata still advertises registration_endpoint, /register still responds, and registration still fails. A robust client therefore treats DCR as best-effort and falls back to a preconfigured client_id when registration is rejected — client.js prints this fallback explicitly.

3. Preconfigured client_id skips DCR entirely

When the client already has a client_id for this server (many MCP clients let you configure one), it never calls /register. It goes straight to authorization_endpoint + token_endpoint from the metadata. Set AUTH_DCR=off to see the pure preconfigured flow.

4. Scope challenges happen at runtime too

A token with mcp but not tools:execute gets a 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="tools:execute" when calling tools/call — a step-up authorization trigger, not a login failure.

Manual debugging

Without client.js, you can drive the flow by hand:

# discover
curl -i http://localhost:3001/mcp -X POST -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}'
curl http://localhost:3001/.well-known/oauth-protected-resource/mcp
curl http://localhost:3002/.well-known/oauth-authorization-server

# dynamic client registration
curl -i http://localhost:3002/register -X POST -H 'Content-Type: application/json' \
  -d '{"client_name":"manual","application_type":"native","redirect_uris":["http://localhost:8899/callback"]}'

# authorize (auto-approves; paste into a browser and read the Location header)
curl -i "http://localhost:3002/authorize?response_type=code&client_id=<client_id>&redirect_uri=http%3A%2F%2Flocalhost%3A8899%2Fcallback&scope=mcp+tools%3Aexecute&code_challenge_method=S256"

# token
curl -i http://localhost:3002/token -X POST -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code&code=<code>&redirect_uri=http://localhost:8899/callback&client_id=<client_id>&code_verifier=<verifier>'

# authenticated MCP call
curl -i http://localhost:3001/mcp -X POST -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <access_token>' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}'

The default preconfigured client is a public client (preconfigured-client, no secret, PKCE required, token_endpoint_auth_method: none). DCR-registered clients behave the same unless application_type: web is sent, in which case they are issued a client_secret and must authenticate at the token endpoint.

The flow

Client                              MCP Server (3001)                Auth Server (3002)
   |--- POST /mcp (no token) ---------->|
   |<-- 401 + WWW-Authenticate ---------|
   |        (resource_metadata URI,     |
   |         or client derives it)      |
   |--- GET /.well-known/oauth-protected-resource/mcp -->|
   |<-- { authorization_servers: [...] }----------------|
   |--- GET /.well-known/oauth-authorization-server -->| (3002)
   |<-- { registration_endpoint?, authorize, token } --|
   |--- POST /register (DCR) ---------->|
   |<-- { client_id, client_secret } ---|
   |      (or registration rejected -> use preconfigured client_id)
   |--- GET /authorize (PKCE+resource) ->|
   |<-- 302 redirect_uri?code&iss ------|
   |--- POST /token -------------------->|
   |<-- { access_token } ---------------|
   |--- POST /mcp (Bearer token) ------->|
   |<-- 200 MCP JSON-RPC result --------|

What it deliberately does not do

  • No real user login / consent UI — /authorize auto-approves so the flow is scriptable.

  • No refresh tokens, JWT, revocation, or OIDC userinfo. Scopes are mcp (any access) and tools:execute (tools/call).

  • No Client ID Metadata Documents (the newer, preferred registration mechanism) — this repo focuses on the DCR vs preconfigured-client question.

  • Tokens are opaque and stored in memory; restarting the server invalidates them.

Files

  • server.js — MCP resource server + OAuth authorization server + request/response logger.

  • client.js — demo client that runs the discovery → DCR-or-preconfigured → OAuth → MCP flow.

  • README.md — this file.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    A Model Context Protocol (MCP) server designed for learning and experimentation. It provides a foundational setup for developers to build, run, and debug MCP server implementations using Node.js.
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    A minimal local HTTP MCP mock server for development and testing, providing predictable tool responses with OAuth token support and zero dependencies.
    -