test-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@test-mcptest the dynamic client registration flow with a tool call"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 testingA Resource registered in that project matching
RESOURCE_URIbelow, withaccess_policy.allow_dynamic_third_party_client_access: trueon the Resource itself and on every Scope the test tools need — otherwise a DCR client'sresource=request getsinvalid_target/invalid_scope. Create it via the Admin API GraphQL playground (oradmin_api_graphqlin an e2e test, if you're doing this from within theauthgear-serverrepo):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:8090must matchRESOURCE_URIbelow 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
resourcefield 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 likeProtected resource ... does not match expected ... (or origin)if you pointRESOURCE_URIat 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 changePORT, update the Resource's URI (andRESOURCE_URIbelow) to match.
Setup
npm install
npm run setup # generates a self-signed TLS cert for localhost (see below)Running
npm startEnvironment variables (all optional):
Var | Default | Meaning |
|
| Port this server listens on. |
|
| Base URL of your Authgear instance. Use |
|
| The RFC 8707 resource identifier — must match the Resource created above, and must be this server's real origin (see above). |
| unset | Set to |
| unset (derived from the request's | Overrides the scheme+host used for the MCP endpoint URL, the resource-metadata URL, and the |
|
| Port for the CIMD test-client endpoints — see Testing CIMD below. Always plain HTTP, regardless of |
|
| Comma-separated extra |
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 8090Genuine 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 publicThe 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 publicskyrocket'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 publicThen, 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
MCP Inspector (recommended first step)
npx @modelcontextprotocol/inspectorOpen 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/mcpand 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 |
| The CIMD document itself. |
| What |
| Generates a PKCE pair + |
| Where |
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.Clientthat 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: importcerts/localhost.crt, then set it to "Always Trust" for SSL) before Authgear's fetch of the document succeeds. Acertificate signed by unknown authorityline inmake start's log forcimd: failed to resolve client metadata documentmeans this step isn't done yet (or the cert's shape is wrong — see the comments inscripts/gen-cert.shfor the specific requirements Apple enforces).insecure_fetch_address_allowed: trueis still needed invar/authgear.features.yaml(see step 2 below) —localhostis 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
Enable CIMD on your local Authgear project (
var/authgear.yamlinauthgear-server):oauth: client_id_metadata_document: enabled: trueAllow fetching a loopback address, local/test project only (
var/authgear.features.yaml). Addinsecure_http_allowed: truetoo if you're using the/oauth/startfallback 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) fallbackRegister a Resource + Scopes matching
RESOURCE_URIas in Prerequisites above — this part is unchanged from DCR testing.npm start, then either point Inspector athttps://localhost:8090/oauth/client-metadata.json(see above — needs the cert trusted), or openhttp://localhost:18091/oauth/startin a browser yourself (adjust the port if you setCIMD_PORT).Log in and approve consent. The consent screen should show the document's host (
localhost:8090orlocalhost:18091) as a distinct, verified-identity line perdocs/specs/cimd.md's Phishing Mitigation section — that's what confirms Authgear actually resolved the document, rather than something else.If you used
/oauth/start,/oauth/callbackreports whether the token exchange succeeded, whether the access token came back as an audience-bound JWT (audshould includeRESOURCE_URI), and the result of calling/oauth2/userinfowith it. If you used Inspector directly, its own Auth panel shows the equivalent.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 sendresource): 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 withaud: [RESOURCE_URI].whoamishould now succeed regardless of granted scopes;list_widgets/run_widgetsucceed 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_URIis set to something other than this server's real origin. FixRESOURCE_URI(and the matching Resource in Authgear) to behttps://localhost:<PORT>, not an arbitrary placeholder — see "Prerequisites" above.invalid_targetat/oauth2/authorizeor/oauth2/token— the Resource (and/or the specific Scope) doesn't haveaccess_policy.allow_dynamic_third_party_client_access: true, or theresource=value the client sent doesn't exactly match what's registered.401 from this server with
error_description: "fetch failed"— this server couldn't reachAUTHGEAR_ENDPOINTto 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
audthanRESOURCE_URI.
This server cannot be deployed
Maintenance
Related MCP Connectors
Minimal streamable HTTP MCP server used for owned-account registry connectivity tests.
Experimental MCP server for current empirical verification of explicit public HTTPS endpoint claims.
- StytchOAuthdev.stytch.mcp
The Stytch MCP server is a reference implementation that demonstrates remote MCP server authentication and authorization using Stytch Connected Apps. It provides OAuth 2.1-compliant authorization (including PKCE), Dynamic Client Registration, and validates Stytch-issued access tokens to enable AI agents to securely interact with external services through permissioned access, supporting scopes like openid, email, profile, and manage:project_data.
MCP server for verifying EUDI/Talao wallet data via OIDC4VP (pull) for AI agents.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA proof-of-concept MCP server implementing OAuth 2.1 authorization with CIMD client registration and PKCE, demonstrating protected resource access and step-up authentication.-
- FlicenseNot gradedqualityDmaintenanceA simple MCP server with OAuth 2.0 authentication for testing OAuth support in mcp-cli.-
- -licenseNot gradedqualityFmaintenanceA 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.-
- AlicenseNot gradedqualityCmaintenanceA demo MCP server protected by OAuth (DCR), enabling hands-on exploration of OAuth flow for local MCP servers.MIT