Connect Claude Code to a Stack9 MCP endpoint
Stack9 exposes each MCP config at /api/mcp/<mcpKey>. Every tool call runs as the
signed-in user, with that user's security role and app access enforced server-side.
There is no separate local or stdio MCP server. Development and production hit the same endpoint; the only difference is who holds the token.
Connect
claude mcp add --transport http stack9-local http://localhost:4444/api/mcp/test_mcp
Replace the URL's host and mcpKey for other instances — e.g.
https://<instance>/api/mcp/<mcpKey>. Pass no credential: Claude Code discovers how to
authenticate on its own, then opens a browser so you can sign in through Stack9's normal
login (which federates to Entra on instances configured for it).
/api prefix is requiredStack9 mounts its whole API under /api, so the MCP path is /api/mcp/<mcpKey>.
/mcp/<mcpKey> will not work.
Knowing which environment you are talking to
Local, dev, UAT and prod serve identical MCP configs, so the connection itself must say where it points:
- Name your registrations by environment —
s9dxp-local,s9dxp-dev,s9dxp-uat— and add one per environment rather than editing a single entry's URL. - The server's advertised name carries the instance identity:
Pages MCP [my-app · production · uat-my-app.example.com]— check it in/mcp. - Every config also exposes a built-in
stack9_whoamitool returning the instance name, environment, base URL, MCP key, and the authenticated user. Agents should call it before writing anywhere when the environment is in any doubt.
Tokens are audience-bound per resource, so a token minted for one environment is a 401
on every other — pointing a session at the wrong host fails closed rather than writing to
the wrong instance.
What happens when you connect
-
Claude Code calls the endpoint with no token and gets
401plus aWWW-Authenticate: Bearer resource_metadata="…"challenge. -
It fetches that Protected Resource Metadata document (RFC 9728), which names the canonical resource URI and the authorization server:
GET /.well-known/oauth-protected-resource/api/mcp/test_mcp{
"resource": "http://localhost:4444/api/mcp/test_mcp",
"authorization_servers": ["http://localhost:4444"],
"scopes_supported": ["mcp:tools"],
"bearer_methods_supported": ["header"]
} -
It fetches the authorization server metadata (RFC 8414) from
/.well-known/oauth-authorization-server. -
It runs an OAuth 2.1 authorization-code flow with PKCE (
S256), passing the RFC 8707resourceparameter. You sign in through Stack9's own login and approve the request. -
Stack9 issues a short-lived access token (15 minutes) whose
audis that MCP resource and whose identity is your Stack9 user, with your roles copied verbatim.
Because the well-known documents must sit at the host root, they are served from / — not
from under /api.
Client registration
Stack9 supports Client ID Metadata Documents (CIMD), the mechanism the MCP
specification prefers, and pre-registered clients via app_registration rows.
Dynamic Client Registration is not offered: it is deprecated by the specification, so
there is no registration_endpoint in the metadata. A client that only supports DCR
cannot self-register — pre-register it as an app_registration instead, with its redirect
URI in redirect_urls.
Confidential pre-registered clients
An app_registration with a secret_hash is a confidential client, and three
accommodations exist for one whose OAuth stack cannot do what an MCP client does. Each is
gated on holding a secret, so none of them applies to Claude Code or any other public
client:
| Accommodation | What it means |
|---|---|
default_oauth_resource on the row | Used as the RFC 8707 resource when the client sends none. Must be a canonical /api/mcp/<key> URL on this instance — it is validated exactly as an explicit resource is. Ignored for CIMD clients. |
| PKCE optional | The client may omit code_challenge; its secret authenticates the token exchange instead. If it does send a challenge, the token endpoint still enforces it. |
client_secret_basic | The secret may arrive in an HTTP Basic Authorization header (RFC 6749 §2.3.1, url-encoded then base64) as well as in the request body. Both are advertised in token_endpoint_auth_methods_supported; present one or the other, never both. |
Public and CIMD clients keep mandatory PKCE (S256) and must send resource on every
authorization request.
These exist for confidential integrations such as AWS Bedrock AgentCore's outbound OAuth
provider, whose configuration has no field for a resource and which defaults to
CLIENT_SECRET_BASIC. Leave default_oauth_resource blank for any client that can send
resource itself — an audience the operator picked is weaker evidence than one the client
named.
How permissions are enforced
Authorization is decided server-side, in Stack9, before a tool runs:
- Query tools are checked against the same screen-query permission model the regular screen routes use — the query's required role versus your access level for the owning app.
- Server-action tools are checked the same way as query tools. A screen declares a
server action as
{ name, queryKey }and permissions grant it by the declared name, so the check considers the action key and every name a declaring screen exposes it under. An action declared on no screen is denied outright, and the dispatch itself goes through the same role-guarded screen-query path the UI uses. - Automation tools are checked against the automation's
app_automation_permissionsrow — the same row the webhook route checks — comparing the row's required role against your access level for the app that row names. An automation whose row isPublicis open to any authenticated caller. An automation with no row is denied: unlike the webhook route, which is iterating candidates and treats a missing row as "not this one", MCP names exactly one automation, so a missing row can only mean nobody granted it. - Administrators short-circuit to full access, consistent with the rest of Stack9.
A forbidden call comes back as a clean MCP tool error, so the agent can explain it:
Authorization error (insufficient_app_role): You have no access to run getalldevelopers
Identity is taken only from the verified token. The decision function receives no tool
arguments at all, so a user_id, roles, or is_administrator value invented by the model
in a tool call cannot influence the outcome.
A query tool is permitted if any screen that exposes it grants it to you. If the same query key appears on screens in more than one app (matching is case-insensitive), access to the most permissive of those apps is enough. Check every screen exposing a query before assuming which app gates it.
Tokens Stack9 will not accept here
Only an audience-bound Bearer token works. In particular:
| Credential | Result |
|---|---|
Bearer token whose aud is this MCP resource, with the mcp:tools scope | Accepted |
Bearer token for a different resource, or with no aud | 401 |
| A Stack9 session JWT presented as a Bearer token | 401 — it carries no aud, so it was never issued for this resource |
| Session cookie | 401 — ambient credentials are never read here |
Api-Key header | Accepted, for development only. Not advertised in metadata and not an OAuth mechanism. |
| Basic auth | 401 |
On a DXP client instance
An instance whose stack9.config.json sets dxp.mode = "client" resolves its connector API
key per user, from the provider that authenticated them. Interactively that key comes from
the login exchange and dies with the browser session — which a headless agent can neither
run nor renew.
So on these instances the connector scope is bound to the OAuth grant instead. It is snapshotted from your session at the moment you approve the consent screen, stored in Redis against the grant, and carried forward through every refresh. Two things follow:
- Its lifetime is the refresh token's, not your browser session's. You can sign out of Stack9, close the browser, or let the session lapse; the connection keeps working. An agent that authorises once and then lives on refresh tokens — AWS Bedrock, a scheduled job — is the case this exists for.
- It is fixed at consent. The grant reaches the tenant and the provider origin your session had when you approved it, and it never silently follows a later sign-in somewhere else. If you need it pointed elsewhere, authorise again.
Losing the grant loses the access: delete the refresh token, or let it expire, and the scope goes with it.
Two failure modes are specific to these instances:
- Authorising while you have no live connector session is refused at the consent step with "Your Stack9 session could not be read". Sign in to Stack9 again first, then restart the connection from the client.
- A grant whose snapshot has expired answers
401witherror="invalid_token"and the description "the DXP session scope bound to this grant is missing or expired; re-authorize". Reconnect — a normal re-authorization repairs it. This is deliberately an OAuth challenge and not thedxp_session_expiredresponse the browser routes give, which clears a session cookie a Bearer request does not have.
Ordinary (non-DXP) instances are unaffected by any of this.
The console's Test tab
The Stack9 console's MCP Test tab does not use this endpoint. It posts to the console
server (webconsole, port 3334 by default), which runs the identical MCP handler at
/api/mcp/<mcpKey> on that server.
It cannot use the endpoint above: that one is an OAuth 2.1 protected resource and, per RFC 8707, accepts only a Bearer token audience-bound to itself. The console holds no such token.
Like every other route on the console server, it is unauthenticated: its neighbouring routes
already create and delete entity schemas, queries, automations and environment variables
without a login. start:core does not launch webconsole, but the base image's default CMD
(yarn run dev:core) does, so the console server is not local-only by construction — the MCP
route therefore sits behind devOnlyMiddleware and refuses outright unless
NODE_ENV=development.
Because there is no session there to identify anyone, the Test tab runs tools as a fixed administrator actor. Two consequences: it is not a way to check per-role behaviour, and it cannot demonstrate a permission denial. For that, connect a non-administrator over OAuth as described above.
The console server is a separate surface from the endpoint above. It is not an OAuth resource, appears in no discovery metadata, and an audience-bound access token is not valid there.
Troubleshooting
Needs authentication in claude mcp list — expected before you complete the flow. Run
/mcp inside Claude Code to start it.
401 with the access token carries no aud claim — you are sending a session token
rather than one minted by the OAuth flow.
401 with the access token is not audience-bound to … — the token was issued for a
different mcpKey or host. Tokens are bound to one resource; get a new one for this endpoint.
403 with error="insufficient_scope" — the token lacks mcp:tools. Re-authorize
requesting that scope.