Skip to main content
Glama
igagansingh

understanding-mcp

by igagansingh
README.md
# 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

```bash
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>`.

## 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` | `off` → `registration_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

```bash
# 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:

```bash
# 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

```text
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