Skip to main content
Glama
tung2744
by tung2744
README.md
# test-mcp

A minimal MCP resource server for manually testing Authgear's Dynamic Client
Registration (DCR), Client ID Metadata Documents (CIMD), and
resource-indicator support (`docs/specs/dcr.md`, `docs/specs/cimd.md`,
`docs/specs/access-token-audience-binding.md` in the `authgear-server` repo).

It does nothing interesting on its own — its only job is to sit behind
Authgear-as-authorization-server and let a real MCP client exercise the
whole flow: discovery → DCR self-registration → PKCE authorize+consent →
token exchange bound to this server's `resource` → an authenticated MCP tool
call.

## How the pieces fit together

```
MCP client  --1. GET /mcp (no token)-->  test-mcp
            <--2. 401 + WWW-Authenticate: Bearer resource_metadata="..."--

MCP client  --3. GET /.well-known/oauth-protected-resource-->  test-mcp
            <--4. { resource, authorization_servers: [Authgear] }--

MCP client  --5. GET /.well-known/oauth-authorization-server-->  Authgear
            <--6. { registration_endpoint, authorization_endpoint, ... }--

MCP client  --7. POST /oauth2/register-->  Authgear   (DCR)
MCP client  --8. /oauth2/authorize + consent, resource=<RESOURCE_URI>--> Authgear
MCP client  --9. POST /oauth2/token, resource=<RESOURCE_URI>-->  Authgear
            <--10. JWT access token, aud=[RESOURCE_URI]--

MCP client  --11. POST /mcp, Authorization: Bearer <token>-->  test-mcp
            <--12. tool result (or 401 if scope/audience don't match)--
```

Steps 1-2 and 11-12 happen against **this** server. Everything in between is
Authgear, discovered automatically by any spec-compliant MCP client — you
don't configure the client with Authgear's URL directly.

## Prerequisites

- A running Authgear instance with DCR enabled, e.g. in `authgear.yaml`:

  ```yaml
  oauth:
    dynamic_client_registration:
      enabled: true
      initial_access_token_required: false # open registration, for easy testing
  ```

- A **Resource** registered in that project matching `RESOURCE_URI` below,
  with `access_policy.allow_dynamic_third_party_client_access: true` on the
  Resource itself and on every Scope the test tools need — otherwise a
  DCR client's `resource=` request gets `invalid_target`/`invalid_scope`.
  Create it via the Admin API GraphQL playground (or `admin_api_graphql` in
  an e2e test, if you're doing this from within the `authgear-server` repo):

  ```graphql
  mutation {
    createResource(input: {
      resourceURI: "https://localhost:8090"
      name: "test-mcp"
      accessPolicy: { allowDynamicThirdPartyClientAccess: true }
    }) {
      resource { id }
    }
  }

  mutation {
    createScope(input: {
      resourceURI: "https://localhost:8090"
      scope: "read:tools"
      accessPolicy: { allowDynamicThirdPartyClientAccess: true }
    }) {
      scope { id }
    }
  }

  mutation {
    createScope(input: {
      resourceURI: "https://localhost:8090"
      scope: "execute:tools"
      accessPolicy: { allowDynamicThirdPartyClientAccess: true }
    }) {
      scope { id }
    }
  }
  ```

  `https://localhost:8090` must match `RESOURCE_URI` below byte-for-byte, **and**
  must be this server's own real origin (scheme + host + port), not an
  arbitrary placeholder. Two independent constraints pin it down:

  - Authgear requires every Resource URI to be `https://` (`pkg/lib/resourcescope/formats.go`).
  - RFC 9728 protected resource metadata's `resource` field is expected to
    match the URL (or origin) the client actually connected to, and strict
    clients enforce this — MCP Inspector will refuse to connect with an
    error like `Protected resource ... does not match expected ... (or origin)`
    if you point `RESOURCE_URI` at an unrelated identifier instead of the
    server's real address.

  That combination is exactly why this server defaults to serving HTTPS
  (self-signed) rather than plain HTTP: `https://localhost:<PORT>` is simultaneously
  a valid Authgear Resource URI *and* this server's genuine origin. If you
  change `PORT`, update the Resource's URI (and `RESOURCE_URI` below) to match.

## Setup

```sh
npm install
npm run setup   # generates a self-signed TLS cert for localhost (see below)
```

## Running

```sh
npm start
```

Environment variables (all optional):

| Var | Default | Meaning |
|---|---|---|
| `PORT` | `8090` | Port this server listens on. |
| `AUTHGEAR_ENDPOINT` | `http://localhost:4000` | Base URL of your Authgear instance. Use `http://localhost:3000` if you're hitting the `make start` process directly, or `http://localhost:3100` if you're going through the conventional local-dev `nginx` proxy (`docker compose up -d proxy`) — either way this must be wherever `/.well-known/openid-configuration` actually resolves. |
| `RESOURCE_URI` | `https://localhost:<PORT>` | The RFC 8707 resource identifier — must match the Resource created above, and must be this server's real origin (see above). |
| `USE_HTTP` | unset | Set to `1` to serve plain HTTP instead of HTTPS. Not recommended: with `USE_HTTP=1`, `RESOURCE_URI` can no longer equal this server's real origin (it'd have to be `http://...`, which Authgear rejects as a Resource URI), so a strict MCP client's resource-match check will fail. Only use this against a client you know doesn't enforce that check. |
| `PUBLIC_ORIGIN` | unset (derived from the request's `Host` header) | Overrides the scheme+host used for the MCP endpoint URL, the resource-metadata URL, and the `WWW-Authenticate` challenge. Only needed behind a reverse proxy that rewrites the `Host` header before it reaches this server — e.g. `skyrocket`'s Cloudflare Tunnel daisychain (see [Deploying publicly](#deploying-publicly-skyrocket)) — where the derived value would otherwise be an internal host the client never actually used. Set to the real public origin, no trailing slash. |
| `CIMD_PORT` | `18091` | Port for the CIMD test-client endpoints — see [Testing CIMD](#testing-cimd) below. Always plain HTTP, regardless of `USE_HTTP`. |
| `CIMD_EXTRA_REDIRECT_URIS` | `http://127.0.0.1:6274/oauth/callback` (MCP Inspector's default) | Comma-separated extra `redirect_uris` to publish in the CIMD document, alongside this server's own `/oauth/callback`. Needed whenever a real MCP client drives the CIMD flow itself (using its own callback listener) instead of going through `/oauth/start` — otherwise Authgear rejects it with `redirect URI is not allowed`. Override if Inspector is running on a non-default port, or set to `""` to clear it. |

## Deploying publicly (skyrocket)

For testing against a real MCP client that can't reach `localhost` (e.g. a
hosted Authgear instance, or anyone who isn't you), this server can be
deployed to a public HTTPS endpoint with [`skyrocket`](https://docs-skyrocket.oursky.dev).

The repo already has a `Dockerfile`. It forces `USE_HTTP=1`: `skyrocket`'s
Ingress terminates TLS itself and forwards plain HTTP to the container, so
the app's own self-signed-HTTPS listener (and the `certs/` dir, which isn't
in the image) would just break things. `RESOURCE_URI` is independent of
`USE_HTTP` in the code, so the server still advertises `https://…` — that's
what actually matters for RFC 9728 resource matching, since it's what the
client connects to, not what the container speaks internally.

```sh
skyrocket auth login                      # once
skyrocket init --name test-mcp --port 8090
```

Genuine public-internet reachability (as opposed to `skyrocket`'s default,
internal-network-only ingress host) requires deploying to the reserved
`public` env, and secrets are scoped per `--env`:

```sh
skyrocket secrets set AUTHGEAR_ENDPOINT https://<your-authgear-host> --env public
skyrocket deploy --env public
```

The deploy output includes the public URL(s) — one origin (direct) host plus
one public (Cloudflare-fronted) host per configured public base domain, the
first of which is primary. `RESOURCE_URI` must exactly match whichever one
you actually point a client at, so set it to that (with a trailing slash,
matching the `https://localhost:<PORT>/` shape used locally) and it takes
effect immediately, no redeploy needed:

```sh
skyrocket secrets set RESOURCE_URI <the public URL you're using>/ --env public
```

`skyrocket`'s Cloudflare Tunnel daisychain also rewrites the `Host` header
on every request to its own internal origin host before it reaches this
server, regardless of which public URL the client actually used — set
`PUBLIC_ORIGIN` (no trailing slash) too, or the MCP endpoint URL, resource
metadata URL, and `WWW-Authenticate` challenge will all advertise that
internal, non-publicly-reachable host instead:

```sh
skyrocket secrets set PUBLIC_ORIGIN <the public URL you're using> --env public
```

Then, same as [Prerequisites](#prerequisites) above but against your real
Authgear project, create a Resource whose `resourceURI` is that same
`RESOURCE_URI` (byte-for-byte, no trailing slash on the Resource itself —
see the GraphQL example) with matching Scopes, and point your MCP client at
`<that public URL>/mcp`.

### Currently deployed

This project (`authgear-test-mcp` in `skyrocket.yaml`) is deployed to the
`public` env against `AUTHGEAR_ENDPOINT=https://mcp-tester.authgear-staging.com`,
with `RESOURCE_URI` and `PUBLIC_ORIGIN` both set to its primary public URL.
`skyrocket deploy --env public` (re-run after code changes) or `skyrocket
secrets set … --env public` (for config changes, applied immediately) update
it in place.

Note this only exposes the main resource-server port (`PORT`/8090) — the
separate `CIMD_PORT` (18091) listener isn't reachable publicly with this
single-service `skyrocket.yaml`, since it only maps one container port.
Testing CIMD against a public deployment would need the multi-service form
of `skyrocket.yaml`.

## Testing with a real MCP client

### MCP Inspector (recommended first step)

```sh
npx @modelcontextprotocol/inspector
```

Open the printed local URL, set the server URL to `https://localhost:8090/mcp`,
and connect — Inspector's "Auth" panel walks through discovery, DCR, and the
authorize/token exchange step by step, so you can see exactly what each
response contains.

Since the cert is self-signed, you may need to tell Node to trust it for the
Inspector's own outgoing requests:

```sh
NODE_EXTRA_CA_CERTS=$(pwd)/certs/localhost.crt npx @modelcontextprotocol/inspector
```

(Only do this for local testing — never disable certificate validation for
anything that talks to a real server.)

### mcp-remote (for testing against Claude Desktop)

```sh
npx mcp-remote https://localhost:8090/mcp
```

and point Claude Desktop's config at the resulting local stdio bridge per
`mcp-remote`'s own docs.

Whether either of these actually exercises CIMD (rather than falling back to
DCR) depends on that client's own support for the CIMD client-identification
priority order — check its Auth debug output for a fetch of a
`client-metadata.json`-shaped URL as `client_id`, versus a `POST` to
`/oauth2/register`. If it's the latter, use [Testing CIMD](#testing-cimd)
below instead, which doesn't depend on the client at all.

## Testing CIMD

Unlike DCR, which the *client* performs against Authgear, CIMD only requires
a `client_id` that resolves to a metadata document — there's no registration
call for a client to make. So this server can play the CIMD client role
itself, without a separate CIMD-aware MCP client or a second file host:

| Path | Purpose |
|---|---|
| `GET /oauth/client-metadata.json` | The CIMD document itself. `client_id`, `redirect_uris`, and `logo_uri` are all derived from the request's own origin, plus `CIMD_EXTRA_REDIRECT_URIS` (default: MCP Inspector's own callback — see below). |
| `GET /oauth/logo.png` | What `logo_uri` above points at — the real Authgear favicon (`assets/authgear-logo.png`, copied from `authgear-server`'s `resources/authgear/static/en/favicon.png`), for exercising `docs/specs/cimd.md`'s logo-fetching path (`/_internals/client_logo` on the Authgear side: server-side fetch, caching, content-type sniffing). Never rendered directly by a browser; Authgear proxies it. |
| `GET /oauth/start` | Generates a PKCE pair + `state`, then redirects your browser to `AUTHGEAR_ENDPOINT/oauth2/authorize` using the document above as `client_id` and `RESOURCE_URI` as `resource`. |
| `GET /oauth/callback` | Where `/oauth/start` sends you back. Exchanges the code for a token, decodes it, and calls `/oauth2/userinfo` with it — all server-side — then prints the result as plain text. |

### With MCP Inspector directly (recommended if its Auth panel supports CIMD)

Point Inspector at `https://localhost:8090/oauth/client-metadata.json` as
the `client_id` — no `/oauth/start` needed, Inspector drives
authorize/consent/token itself. This works out of the box against
Inspector's default callback (`http://127.0.0.1:6274/oauth/callback`,
included in the document's `redirect_uris` by default — override
`CIMD_EXTRA_REDIRECT_URIS` if Inspector runs on a different port).

Two things this depends on that `/oauth/start` below doesn't:

- **Authgear itself must trust the cert on port 8090.** Authgear's CIMD
  fetcher is a Go `http.Client` that validates certificates like any other
  TLS client, so the browser "click through the warning" trick doesn't
  cover it — a self-signed cert has to be explicitly trusted (e.g. via
  Keychain Access on macOS: import `certs/localhost.crt`, then set it to
  "Always Trust" for SSL) before Authgear's fetch of the document succeeds.
  A `certificate signed by unknown authority` line in `make start`'s log
  for `cimd: failed to resolve client metadata document` means this step
  isn't done yet (or the cert's shape is wrong — see the comments in
  `scripts/gen-cert.sh` for the specific requirements Apple enforces).
- **`insecure_fetch_address_allowed: true`** is still needed in
  `var/authgear.features.yaml` (see step 2 below) — `localhost` is a
  loopback address, which is a separate check from TLS trust.

### With `/oauth/start` (no trusted cert needed)

Always plain HTTP, on `CIMD_PORT` (default `18091`), independent of
`USE_HTTP` — this is the fallback that works without doing anything to the
cert at all, since it never goes through TLS. That means your project needs
`oauth.client_id_metadata_document.insecure_http_allowed: true` too (see
below), which the HTTPS path above doesn't.

### Setup

1. Enable CIMD on your local Authgear project (`var/authgear.yaml` in
   `authgear-server`):
   ```yaml
   oauth:
     client_id_metadata_document:
       enabled: true
   ```
2. Allow fetching a loopback address, local/test project only
   (`var/authgear.features.yaml`). Add `insecure_http_allowed: true` too if
   you're using the `/oauth/start` fallback instead of the HTTPS path above:
   ```yaml
   oauth:
     client_id_metadata_document:
       insecure_fetch_address_allowed: true
       # insecure_http_allowed: true  # only for the /oauth/start (CIMD_PORT) fallback
   ```
3. Register a Resource + Scopes matching `RESOURCE_URI` as in
   [Prerequisites](#prerequisites) above — this part is unchanged from DCR
   testing.
4. `npm start`, then either point Inspector at
   `https://localhost:8090/oauth/client-metadata.json` (see above — needs
   the cert trusted), or open `http://localhost:18091/oauth/start` in a
   browser yourself (adjust the port if you set `CIMD_PORT`).
5. Log in and approve consent. The consent screen should show the
   document's host (`localhost:8090` or `localhost:18091`) as a distinct,
   verified-identity line per `docs/specs/cimd.md`'s Phishing Mitigation
   section — that's what confirms Authgear actually resolved the document,
   rather than something else.
6. If you used `/oauth/start`, `/oauth/callback` reports whether the token
   exchange succeeded, whether the access token came back as an
   audience-bound JWT (`aud` should include `RESOURCE_URI`), and the result
   of calling `/oauth2/userinfo` with it. If you used Inspector directly,
   its own Auth panel shows the equivalent.
7. Either way, this server's own MCP tools are exercised the same as in
   [Testing with a real MCP client](#testing-with-a-real-mcp-client) above.

Confirm the client was actually persisted with `source: CIMD` via the Admin
API's `dynamicClients` query (see `docs/specs/dcr.md`'s "new query"), e.g.:

```graphql
query {
  dynamicClients {
    edges { node { clientID source clientName lastFetchedAt } }
  }
}
```

## What to look for

- **No `resource=` requested** (a plain OIDC client, or an MCP client that
  doesn't send `resource`): Authgear issues an **opaque** token to a
  third-party/DCR client by default. This server can't verify an opaque
  token at all (it isn't a JWT), so every tool call fails with 401 — this is
  the intended behavior (`docs/specs/dcr.md`, `access-token-audience-binding.md`):
  an unbound third-party token is only usable at Authgear's own
  `/oauth2/userinfo`, nowhere else.
- **`resource=<RESOURCE_URI>` requested**: Authgear issues a JWT
  with `aud: [RESOURCE_URI]`. `whoami` should now succeed
  regardless of granted scopes; `list_widgets`/`run_widget` succeed only if
  the corresponding scope (`read:tools`/`execute:tools`) was granted at
  consent time.
- **A resource-bound token from a *different* resource, or one whose
  Resource/Scope lacks `allow_dynamic_third_party_client_access`**: rejected
  at Authgear itself (`invalid_target`/`invalid_scope`) before it ever
  reaches this server.

## Troubleshooting

- `Failed to connect ... Protected resource <X> does not match expected <Y> (or origin)`
  (MCP Inspector, or another RFC-9728-strict client) — `RESOURCE_URI` is set
  to something other than this server's real origin. Fix `RESOURCE_URI` (and
  the matching Resource in Authgear) to be `https://localhost:<PORT>`, not an
  arbitrary placeholder — see "Prerequisites" above.
- `invalid_target` at `/oauth2/authorize` or `/oauth2/token` — the Resource
  (and/or the specific Scope) doesn't have
  `access_policy.allow_dynamic_third_party_client_access: true`, or the
  `resource=` value the client sent doesn't exactly match what's registered.
- 401 from this server with `error_description: "fetch failed"` — this
  server couldn't reach `AUTHGEAR_ENDPOINT` to fetch discovery metadata;
  check Authgear is actually running there.
- 401 with a JWT-verification error — the token is real but either expired,
  signed by a different issuer, or bound to a different `aud` than
  `RESOURCE_URI`.