Skip to main content
Glama
tung2744
by tung2744

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.

Related MCP server: MCP Server OAuth Toy

Prerequisites

  • A running Authgear instance with DCR enabled, e.g. in authgear.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):

    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

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

Running

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) — 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 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.

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.

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:

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:

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:

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

Then, same as 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

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:

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)

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

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):

    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:

    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 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 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.:

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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    A proof-of-concept MCP server implementing OAuth 2.1 authorization with CIMD client registration and PKCE, demonstrating protected resource access and step-up authentication.
    -
  • -
    license
    Not graded
    quality
    F
    maintenance
    A minimal remote (Streamable HTTP) MCP server that is an OAuth 2.1 resource server, demonstrating the MCP authorization spec with token validation and audience checks.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    A demo MCP server protected by OAuth (DCR), enabling hands-on exploration of OAuth flow for local MCP servers.
    MIT