diff --git a/approuter/lib/mcp-consent.js b/approuter/lib/mcp-consent.js
index 67e772640..622d6602e 100644
--- a/approuter/lib/mcp-consent.js
+++ b/approuter/lib/mcp-consent.js
@@ -57,6 +57,7 @@ const AUTHORIZE_PATH = `${BASE}/authorize`
const CONSENT_PATH = `${BASE}/consent`
const CALLBACK_PATH = `${BASE}/callback`
const TOKEN_PATH = `${BASE}/token`
+const REGISTER_PATH = `${BASE}/register`
// The service name shown in the consent copy. Substituted into the SAME verbatim
// text the other SAP MCP tools use (Tom, 2026-10-06).
@@ -72,15 +73,47 @@ function isEnabled() {
}
// IAS issuer endpoint. Reuse the approuter's already-configured XSUAA_MCP_URL
-// (set in mtaext, read by well-known-oauth.js). The client_id is NOT server
-// config — it comes from the MCP client's authorize request and is forwarded through
-// the sealed state to the IAS leg. This preserves the env-specific + client-supplied
-// model (DEV 0b1e8b56…, PROD sb-tutorials-prod!t676072, etc.).
+// (set in mtaext, read by well-known-oauth.js). For the mcp-remote bridge path the
+// client_id comes from the client's authorize request (forwarded through sealed
+// state); DEV and PROD have DISTINCT tutorials-identity clients (DEV 0b1e8b56…,
+// PROD 5a7cb5fd…), so it is env-specific. The native-HTTP (DCR) path instead reads
+// that env's clientid server-side via resolveIasClientId() below.
function iasIssuer() {
const v = process.env.XSUAA_MCP_URL
return v ? v.replace(/\/+$/, '') : null
}
+// Resolve the IAS public PKCE client_id that tutorials-srv-mcp validates token
+// `aud` against. This MUST be srv-mcp's clientid, not the approuter's own.
+//
+// The approuter and the MCP server bind DIFFERENT tutorials-identity instances
+// (verified DEV 2026-10-11: approuter instance `tutorials-approuter-identity`
+// clientid cf2ea5eb…, srv-mcp instance `tutorials-identity` clientid 0b1e8b56…;
+// same tenant app_tid but distinct clients). @sap/xssec `kind:ias` checks
+// `aud === credentials.clientid` exactly, so handing a DCR client the approuter's
+// own clientid produces WrongAudience 401 after a successful login — the exact
+// bug DCR was meant to eliminate. The approuter's VCAP identity binding therefore
+// CANNOT be trusted as the source.
+//
+// Source of truth, in order:
+// 1. MCP_RESOURCE_CLIENT_ID — explicit, set per-env in the mtaext to srv-mcp's
+// clientid (DEV 0b1e8b56…, PROD 5a7cb5fd…). Deterministic; no shared-binding
+// assumption.
+// 2. VCAP_SERVICES.identity[].credentials.clientid — fallback only (correct ONLY
+// if the approuter happens to bind the same instance as srv-mcp). Mirrors the
+// raw-VCAP parsing in credstore-secret.js / well-known-oauth.js (the approuter
+// deliberately avoids @sap/xsenv).
+// Returns null when neither is available.
+function resolveIasClientId() {
+ const explicit = (process.env.MCP_RESOURCE_CLIENT_ID || '').trim()
+ if (explicit) return explicit
+ let services
+ try { services = JSON.parse(process.env.VCAP_SERVICES || '{}') } catch { return null }
+ const arr = Array.isArray(services.identity) ? services.identity : []
+ const binding = arr.find(b => b && b.credentials && b.credentials.clientid) || arr[0]
+ return (binding && binding.credentials && binding.credentials.clientid) || null
+}
+
// Externally-visible base URL of this approuter, from the inbound request —
// honors CF's x-forwarded-proto/host. Same derivation as well-known-oauth.js.
function resolveBaseUrl(req) {
@@ -416,6 +449,38 @@ async function handleCallback(req, res, baseUrl) {
return redirect(res, withQuery(ctx.client_redirect_uri, { code, state: ctx.client_state }))
}
+// RFC 7591 Dynamic Client Registration — facade. A native-HTTP OAuth MCP client with
+// no pre-configured client_id POSTs here to bootstrap. We do NOT create a new IAS
+// client; we return THIS environment's existing tutorials-identity public PKCE
+// client_id (resolveIasClientId). That id is public (no secret) and is exactly the
+// value tutorials-srv-mcp validates token `aud` against, so the client ends up with
+// the correct per-env id automatically — eliminating the wrong-env client_id class
+// of failure. The request's redirect_uris are echoed back for the client↔proxy leg
+// only (validated at /authorize); the proxy↔IAS leg always uses our fixed CALLBACK_PATH,
+// so registration input cannot influence it.
+async function handleRegister(req, res) {
+ const rawBody = await readBody(req)
+ const params = parseForm(req, rawBody)
+
+ const clientId = resolveIasClientId()
+ if (!clientId) {
+ console.error('[mcp-consent] /register: no identity binding in VCAP_SERVICES — cannot issue client_id')
+ return sendJson(res, 500, { error: 'server_error', error_description: 'identity binding unavailable' })
+ }
+
+ const redirectUris = Array.isArray(params.redirect_uris) ? params.redirect_uris : []
+ // Public PKCE client: no client_secret, no registration_access_token minted.
+ return sendJson(res, 201, {
+ client_id: clientId,
+ client_id_issued_at: Math.floor(Date.now() / 1000),
+ token_endpoint_auth_method: 'none',
+ grant_types: ['authorization_code', 'refresh_token'],
+ response_types: ['code'],
+ redirect_uris: redirectUris,
+ ...(params.client_name ? { client_name: String(params.client_name) } : {}),
+ })
+}
+
async function handleToken(req, res) {
const rawBody = await readBody(req)
const params = parseForm(req, rawBody)
@@ -509,6 +574,7 @@ function mcpConsentHandler(req, res, next) {
if (req.method === 'POST') {
if (pathOnly === CONSENT_PATH) return run(handleConsent(req, res, baseUrl))
if (pathOnly === TOKEN_PATH) return run(handleToken(req, res))
+ if (pathOnly === REGISTER_PATH) return run(handleRegister(req, res))
}
// A /mcp-oauth/* path we don't serve — do not leak to the SPA proxy.
@@ -521,6 +587,8 @@ module.exports = {
idpExchange,
idpRefresh,
resolveBaseUrl,
+ resolveIasClientId,
+ handleRegister,
verifyPkce,
sealState,
openState,
@@ -530,5 +598,6 @@ module.exports = {
CONSENT_PATH,
CALLBACK_PATH,
TOKEN_PATH,
+ REGISTER_PATH,
SERVICE_NAME,
}
diff --git a/approuter/lib/well-known-oauth.js b/approuter/lib/well-known-oauth.js
index aa3e113de..024f8592f 100644
--- a/approuter/lib/well-known-oauth.js
+++ b/approuter/lib/well-known-oauth.js
@@ -167,6 +167,10 @@ function authorizationServerMetadata(issuer, endpointBase, scope) {
issuer,
authorization_endpoint: `${issuer}/mcp-oauth/authorize`,
token_endpoint: `${issuer}/mcp-oauth/token`,
+ // RFC 7591 DCR: native-HTTP OAuth clients register here to obtain this env's
+ // client_id with zero config (facade — returns the bound tutorials-identity
+ // clientid). Only advertised under the consent-proxy flip.
+ registration_endpoint: `${issuer}/mcp-oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
diff --git a/deploy/dev.mtaext b/deploy/dev.mtaext
index 83c76d810..3db0eb1ed 100644
--- a/deploy/dev.mtaext
+++ b/deploy/dev.mtaext
@@ -94,6 +94,13 @@ modules:
# state to the IAS leg (preserves env-specific DEV/PROD model). Requires the
# MCP_CONSENT_STATE_SECRET to be seeded in the Credential Store.
MCP_CONSENT_ENABLED: "true"
+ # Dynamic Client Registration (RFC 7591) must hand native-HTTP OAuth clients
+ # the clientid that tutorials-srv-mcp validates token `aud` against. The
+ # approuter binds its OWN identity instance (tutorials-approuter-identity,
+ # a DIFFERENT clientid than srv-mcp's tutorials-identity), so its VCAP binding
+ # is the wrong source — using it yields WrongAudience 401 after login. Pin
+ # srv-mcp's DEV clientid explicitly; resolveIasClientId() prefers this env.
+ MCP_RESOURCE_CLIENT_ID: "0b1e8b56-5f5d-4ebf-a9e9-28aae2964236"
# deploy.yml's $REBUILD_API_KEY. Removed as the third and final rollout
# step of PR #871 (issue #903): the approuter now reads REBUILD_API_KEY
# exclusively from the BTP Credential Store via
diff --git a/deploy/prod.mtaext b/deploy/prod.mtaext
index 7e51d0d80..d7028c79f 100644
--- a/deploy/prod.mtaext
+++ b/deploy/prod.mtaext
@@ -89,6 +89,10 @@ modules:
# #2696/#2697). Read via process.env at call time; MUST be `properties:` not
# `env:` (MTA drops module-level env).
MCP_CONSENT_ENABLED: "true"
+ # DCR (RFC 7591) must hand native-HTTP OAuth clients srv-mcp's clientid, not
+ # the approuter's own (distinct identity instances — see dev.mtaext). Pin
+ # PROD srv-mcp's clientid; resolveIasClientId() prefers this env over VCAP.
+ MCP_RESOURCE_CLIENT_ID: "5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34"
parameters:
app-name: tutorials-prod-approuter
instances: 2
diff --git a/docs/end-users/mcp-quickstart.md b/docs/end-users/mcp-quickstart.md
index a846427b2..33961fd41 100644
--- a/docs/end-users/mcp-quickstart.md
+++ b/docs/end-users/mcp-quickstart.md
@@ -105,13 +105,12 @@ Joule Work Desktop connects to an MCP server by registering the server's URL. Fo
The curated tools then appear to Joule.
-> **Authenticated tools (`/mcp-auth/*`) are not reachable from Joule Work Desktop's native flow.**
+> **Authenticated tools (`/mcp-auth/*`) work from Joule Work Desktop's native flow.**
> Pasting an authenticated URL triggers Joule Work Desktop's OAuth 2.0 handshake, which uses
-> **Dynamic Client Registration (RFC 7591)** — it `POST`s to a `/register` endpoint to
-> self-register a client. XSUAA does **not** support DCR; OAuth clients must be **pre-registered**
-> (the same limitation described for native Claude clients above). So the personalized tools
-> (`get_my_tutorials`, `complete_step`, …) can't be used through Joule Work Desktop today —
-> stick to the anonymous `/mcp/*` URLs.
+> **Dynamic Client Registration (RFC 7591)** — it `POST`s to the `registration_endpoint` to
+> self-register. The server implements DCR (facade over the environment's IAS public client),
+> so Joule Work Desktop self-registers, shows the Application Access Request consent, and the
+> personalized tools (`get_my_tutorials`, `complete_step`, …) become available after sign-in.
## Joule Studio (classic edition)
@@ -170,14 +169,16 @@ The server speaks Streamable HTTP. Clients may send `Accept: application/json` o
Phase 2 adds authenticated tools under `/mcp-auth/*` — your tutorial progress, events, and personalized recommendations. To use them, sign in with your SAP universal ID via OAuth.
-> **If your Claude Desktop build uses native OAuth, it may fail with
-> `does not support dynamic client registration`** — the same XSUAA/DCR limitation described
-> in the Claude Code section below. XSUAA requires a **pre-registered** client, so clients
-> that insist on RFC 7591 self-registration cannot connect directly. If you hit this, bridge
-> through **`mcp-remote`** with the pre-registered `client_id` (see the Claude Code section),
-> or use a **[PAT](#headless--ci-with-a-personal-access-token)**.
+> **Native-HTTP OAuth clients (incl. Claude Desktop) connect with NO client_id.**
+> The server supports **RFC 7591 Dynamic Client Registration**: a client that does its own
+> OAuth discovers the `registration_endpoint` from
+> `/.well-known/oauth-authorization-server`, self-registers, and is handed the correct
+> client_id for that environment automatically — just point it at `/mcp-auth/api` and
+> approve the browser sign-in. No `--static-oauth-client-info`, no per-environment id to
+> look up. Use the `mcp-remote` bridge below **only** for clients that cannot do native HTTP
+> OAuth; it needs the explicit, environment-specific `client_id` from the table.
-For builds that accept a pre-registered client, bridge through `mcp-remote`:
+For clients that need the `mcp-remote` bridge (no native OAuth), supply the environment's `client_id`:
```json
{
@@ -186,7 +187,7 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`:
"command": "npx",
"args": [
"-y", "mcp-remote", "/mcp-auth/api",
- "--static-oauth-client-info", "{\"client_id\":\"0b1e8b56-5f5d-4ebf-a9e9-28aae2964236\"}",
+ "--static-oauth-client-info", "{\"client_id\":\"5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34\"}",
"--host", "localhost"
]
}
@@ -194,23 +195,28 @@ For builds that accept a pre-registered client, bridge through `mcp-remote`:
}
```
-> **The `client_id` is the same on all environments — the IAS `tutorials-identity` client:**
+> **The `client_id` is ENVIRONMENT-SPECIFIC — match it to your ``:**
>
> | Environment | `` | `client_id` |
> | --- | --- | --- |
-> | **Production** | `https://developers.sap.com` | `0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` |
+> | **Production** | `https://developers.sap.com` | `5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34` |
> | **Dev** | your dev route | `0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` |
>
-> Both environments now authenticate against **IAS** (a public OIDC client —
-> authorization_code + PKCE S256, no secret). DEV and PROD share **one** IAS tenant
-> (`atxgsg7zi`) and **one** `tutorials-identity` app, with both approuter hosts
-> registered as redirect URIs — so there is a **single** client UUID for both. The old
-> XSUAA ids (`sb-tutorials-prod!…`, `sb-tutorials!…`) are **retired**: since
-> `MCP_ISSUER_KIND: ias` landed in both mtaexts (#2593), discovery advertises IAS, and
-> IAS rejects an XSUAA `sb-…` id at the authorize endpoint ("client_id … must match the
-> configuration") before PKCE is even checked. If the `tutorials-identity` instance is
-> recreated, this UUID changes — read it from the IAS admin console (tenant `atxgsg7zi`,
-> app `tutorials-identity` → OpenID Connect Configuration) and update both rows.
+> Both environments authenticate against **IAS** (a public OIDC client —
+> authorization_code + PKCE S256, no secret) in the shared tenant `atxgsg7zi`. But DEV
+> and PROD each have their **own** `tutorials-identity` service instance, and each
+> instance has a **distinct clientid** — so the ids differ per environment. The
+> interactive id_token's `aud` equals the client_id you supply, and the resource
+> (`/mcp-auth`) validates it against the clientid bound to that env's `tutorials-srv-mcp`
+> — using the wrong env's id fails with a `WrongAudience`/`invalid_token` 401 **after** a
+> successful browser login ("server rejected a token it just issued"). The old XSUAA ids
+> (`sb-tutorials-prod!…`, `sb-tutorials!…`) are **retired**: since `MCP_ISSUER_KIND: ias`
+> landed in both mtaexts (#2593), discovery advertises IAS and an XSUAA `sb-…` id is
+> rejected at the authorize endpoint. If a `tutorials-identity` instance is recreated its
+> clientid changes — read the current value from the IAS admin console (tenant
+> `atxgsg7zi` → app `tutorials-identity` for that space → Client Authentication → Client
+> ID), or from `VCAP_SERVICES.identity[].credentials.clientid` on that env's
+> `tutorials-srv-mcp`, and update the matching row.
On first connection `mcp-remote` opens a browser tab for consent (PKCE, no client secret required). The endpoints are discovered automatically from `/.well-known/oauth-authorization-server`; you supply only the `client_id`. After approval, the token is cached and refreshed silently.
@@ -232,17 +238,17 @@ On first connection `mcp-remote` opens a browser tab for consent (PKCE, no clien
## Sign in with Claude Code (OAuth via mcp-remote)
-> **Claude Code's *native* `type: http` OAuth does NOT work against this server.** Its
-> built-in OAuth client requires **Dynamic Client Registration** (RFC 7591) — it tries to
-> `POST` to a `registration_endpoint` to self-register. XSUAA does not support DCR; OAuth
-> clients must be **pre-registered**. Pointing Claude Code's native HTTP client at
-> `/mcp-auth/api` fails with `SDK auth failed: Incompatible auth server: does not support
-> dynamic client registration`. Use **`mcp-remote`** (below), which accepts a pre-registered
-> `client_id`, or use a **[PAT](#headless--ci-with-a-personal-access-token)** (simplest for
-> Claude Code — no browser flow).
+> **Claude Code's *native* `type: http` OAuth now works against this server.** Its built-in
+> OAuth client uses **Dynamic Client Registration** (RFC 7591) — it `POST`s to the
+> `registration_endpoint` to self-register. The server implements DCR, so native HTTP OAuth
+> succeeds with **no** `client_id`: `"sap-developers-auth": { "type": "http", "url":
+> "/mcp-auth/api" }`, then approve the browser sign-in. The `mcp-remote` bridge below
+> remains available for older clients or headless use; a **[PAT](#headless--ci-with-a-personal-access-token)**
+> is still simplest for CI (no browser flow).
-Bridge through `mcp-remote`, which performs the OAuth 2.1 authorization-code + PKCE handshake
-against the pre-registered public client and forwards the bearer to `/mcp-auth/api`:
+For older clients without native OAuth, bridge through `mcp-remote`, which performs the OAuth
+2.1 authorization-code + PKCE handshake and forwards the bearer to `/mcp-auth/api` (supply the
+environment's `client_id` from the table above):
```bash
npm install -g mcp-remote
@@ -255,7 +261,7 @@ npm install -g mcp-remote
"command": "npx",
"args": [
"-y", "mcp-remote", "/mcp-auth/api",
- "--static-oauth-client-info", "{\"client_id\":\"0b1e8b56-5f5d-4ebf-a9e9-28aae2964236\"}",
+ "--static-oauth-client-info", "{\"client_id\":\"5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34\"}",
"--host", "localhost"
]
}
@@ -263,13 +269,16 @@ npm install -g mcp-remote
}
```
-`0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` is the **IAS public OIDC client** (`tutorials-identity`
-app in the shared IAS tenant `atxgsg7zi`) — a public PKCE client with no secret. The SAME id
-serves both DEV and PROD: they share one subaccount, one IAS tenant, and one `tutorials-identity`
-app, with each approuter host registered as a redirect URI. The retired XSUAA ids
-(`sb-tutorials-prod!…`, `sb-tutorials!…`) no longer work — IAS rejects them at the authorize
-endpoint. To confirm the current id, read it from the IAS admin console (tenant `atxgsg7zi`, app
-`tutorials-identity` → OpenID Connect Configuration name).
+`5a7cb5fd-a0f0-4678-8f51-f8c471e0ab34` (PROD) / `0b1e8b56-5f5d-4ebf-a9e9-28aae2964236` (DEV)
+is the **IAS public OIDC client** (`tutorials-identity` app) for each environment — a public
+PKCE client with no secret, in the shared IAS tenant `atxgsg7zi`. DEV and PROD each have their
+**own** `tutorials-identity` service instance with a **distinct clientid**, so you must use the id
+matching your `` (see the table above) — the resource validates the token's `aud` against
+its own env's bound clientid. The retired XSUAA ids (`sb-tutorials-prod!…`, `sb-tutorials!…`) no
+longer work — IAS rejects them at the authorize endpoint. To confirm the current id for an env,
+read it from the IAS admin console (tenant `atxgsg7zi`, the `tutorials-identity` app for that space
+→ Client Authentication → Client ID) or from `VCAP_SERVICES.identity[].credentials.clientid` on
+that env's `tutorials-srv-mcp`.
The flow uses PKCE with no client secret. On first run, `mcp-remote` opens your browser for the
SAP universal-ID consent flow; after approval the token is cached in `~/.mcp-auth/` and refreshed
silently. The server advertises its endpoints at `/.well-known/oauth-authorization-server`,
diff --git a/hugo/content/api-docs/_index.md b/hugo/content/api-docs/_index.md
index c641732e1..51944ff76 100644
--- a/hugo/content/api-docs/_index.md
+++ b/hugo/content/api-docs/_index.md
@@ -107,7 +107,7 @@ Each CDS service is mounted separately under `/mcp/*` over the [Model Context Pr
**Signed-in tools** (your progress, recommendations, marking steps done) live behind two authenticated tiers:
-- **`/mcp-auth/*`** — OAuth 2.1 authorization-code + PKCE against SAP Identity Authentication Service (IAS), as a **public client** (no secret). Point an MCP client with native HTTP OAuth straight at the mount — the authorization server, scopes, and PKCE method are advertised via [`/.well-known/oauth-protected-resource`](https://developers.sap.com/.well-known/oauth-protected-resource) and [`/.well-known/oauth-authorization-server`](https://developers.sap.com/.well-known/oauth-authorization-server), so no `mcp-remote` bridge is needed. In Claude Code: `"sap-developers-auth": { "type": "http", "url": "https://developers.sap.com/mcp-auth/api" }` — approve, and the browser sign-in completes the flow.
+- **`/mcp-auth/*`** — OAuth 2.1 authorization-code + PKCE against SAP Identity Authentication Service (IAS), as a **public client** (no secret). Point an MCP client with native HTTP OAuth straight at the mount — the authorization server, scopes, PKCE method, and **RFC 7591 Dynamic Client Registration** endpoint are advertised via [`/.well-known/oauth-protected-resource`](https://developers.sap.com/.well-known/oauth-protected-resource) and [`/.well-known/oauth-authorization-server`](https://developers.sap.com/.well-known/oauth-authorization-server), so the client self-registers and no `mcp-remote` bridge or pre-configured `client_id` is needed. In Claude Code: `"sap-developers-auth": { "type": "http", "url": "https://developers.sap.com/mcp-auth/api" }` — approve, and the browser sign-in completes the flow.
- **`/mcp-pat/*`** — a **Personal Access Token** for headless / CI clients that can't do a browser flow. Mint one at [`/me/tokens/`](/me/tokens/) (self-service; requires the **Tutorials MCP Users** role collection), then send `Authorization: Bearer pat_...`. Scopes: `read` (read tools) or `write` (also allows `complete_step` / `reset_tutorial_progress`). The plaintext token is shown once.
Full connection walkthrough (Claude Desktop, Claude Code, native HTTP OAuth, PATs, troubleshooting): [MCP Quickstart](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/end-users/mcp-quickstart.md). Tool + parameter reference: [mcp-server.md](https://github.com/sap-tutorials/tutorials-ims/blob/main/docs/developers/reference/mcp-server.md).
diff --git a/test/unit/mcp-consent.test.js b/test/unit/mcp-consent.test.js
index d1e2ba199..5e8d96814 100644
--- a/test/unit/mcp-consent.test.js
+++ b/test/unit/mcp-consent.test.js
@@ -59,7 +59,7 @@ function saveEnv(...names) { for (const n of names) SAVED[n] = process.env[n] }
function restoreEnv() { for (const [n, v] of Object.entries(SAVED)) { if (v === undefined) delete process.env[n]; else process.env[n] = v } }
beforeEach(() => {
- saveEnv('MCP_CONSENT_ENABLED', 'MCP_CONSENT_STATE_SECRET', 'XSUAA_MCP_URL')
+ saveEnv('MCP_CONSENT_ENABLED', 'MCP_CONSENT_STATE_SECRET', 'XSUAA_MCP_URL', 'VCAP_SERVICES', 'MCP_RESOURCE_CLIENT_ID')
credstore._resetForTests()
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -488,3 +488,92 @@ describe('unknown /mcp-oauth path', () => {
await vi.waitFor(() => expect(res.statusCode).toBe(404))
})
})
+
+describe('dynamic client registration (RFC 7591) — /register facade', () => {
+ const IDENTITY_VCAP = JSON.stringify({
+ identity: [{ name: 'tutorials-identity', credentials: { clientid: '5a7cb5fd-prod-clientid', url: 'https://tenant.accounts.ondemand.com' } }],
+ })
+
+ // The DCR facade must return srv-mcp's clientid. Default these tests to the
+ // VCAP-only path (no explicit env) unless a test opts into MCP_RESOURCE_CLIENT_ID.
+ beforeEach(() => { delete process.env.MCP_RESOURCE_CLIENT_ID })
+
+ it('returns 201 with this env\'s bound tutorials-identity client_id (no secret)', async () => {
+ process.env.VCAP_SERVICES = IDENTITY_VCAP
+ const body = JSON.stringify({ client_name: 'probe', redirect_uris: ['http://127.0.0.1:0/callback'], token_endpoint_auth_method: 'none' })
+ const res = mockRes()
+ consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn())
+ await vi.waitFor(() => expect(res.statusCode).toBe(201))
+ const reg = res.json()
+ expect(reg.client_id).toBe('5a7cb5fd-prod-clientid')
+ expect(reg.token_endpoint_auth_method).toBe('none')
+ expect(reg.grant_types).toEqual(['authorization_code', 'refresh_token'])
+ expect(reg.redirect_uris).toEqual(['http://127.0.0.1:0/callback'])
+ expect(reg.client_name).toBe('probe')
+ })
+
+ it('never returns a client_secret or registration_access_token (public client)', async () => {
+ process.env.VCAP_SERVICES = IDENTITY_VCAP
+ const body = JSON.stringify({ redirect_uris: ['http://localhost:0/callback'] })
+ const res = mockRes()
+ consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn())
+ await vi.waitFor(() => expect(res.statusCode).toBe(201))
+ const reg = res.json()
+ expect(reg.client_secret).toBeUndefined()
+ expect(reg.registration_access_token).toBeUndefined()
+ })
+
+ it('500s server_error when no identity binding is present in VCAP', async () => {
+ delete process.env.VCAP_SERVICES
+ const body = JSON.stringify({ redirect_uris: ['http://127.0.0.1:0/callback'] })
+ const res = mockRes()
+ consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn())
+ await vi.waitFor(() => expect(res.statusCode).toBe(500))
+ expect(res.json().error).toBe('server_error')
+ })
+
+ it('resolveIasClientId reads VCAP_SERVICES.identity[].credentials.clientid', () => {
+ process.env.VCAP_SERVICES = IDENTITY_VCAP
+ expect(consent.resolveIasClientId()).toBe('5a7cb5fd-prod-clientid')
+ delete process.env.VCAP_SERVICES
+ expect(consent.resolveIasClientId()).toBeNull()
+ })
+
+ it('MCP_RESOURCE_CLIENT_ID takes precedence over the approuter VCAP binding', () => {
+ // Reproduces the DEV 2026-10-11 bug: approuter binds a DIFFERENT identity
+ // instance than srv-mcp. The explicit env must win so /register hands out
+ // srv-mcp's clientid, not the approuter's own.
+ process.env.VCAP_SERVICES = JSON.stringify({
+ identity: [{ name: 'tutorials-approuter-identity', credentials: { clientid: 'cf2ea5eb-approuter-own' } }],
+ })
+ process.env.MCP_RESOURCE_CLIENT_ID = '0b1e8b56-srv-mcp'
+ expect(consent.resolveIasClientId()).toBe('0b1e8b56-srv-mcp')
+ })
+
+ it('/register returns MCP_RESOURCE_CLIENT_ID even when VCAP has a different clientid', async () => {
+ process.env.VCAP_SERVICES = JSON.stringify({
+ identity: [{ name: 'tutorials-approuter-identity', credentials: { clientid: 'cf2ea5eb-approuter-own' } }],
+ })
+ process.env.MCP_RESOURCE_CLIENT_ID = '0b1e8b56-srv-mcp'
+ const body = JSON.stringify({ redirect_uris: ['http://127.0.0.1:0/callback'] })
+ const res = mockRes()
+ consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, body, 'application/json'), res, vi.fn())
+ await vi.waitFor(() => expect(res.statusCode).toBe(201))
+ expect(res.json().client_id).toBe('0b1e8b56-srv-mcp')
+ })
+
+ it('falls back to VCAP when MCP_RESOURCE_CLIENT_ID is empty/whitespace', () => {
+ process.env.VCAP_SERVICES = IDENTITY_VCAP
+ process.env.MCP_RESOURCE_CLIENT_ID = ' '
+ expect(consent.resolveIasClientId()).toBe('5a7cb5fd-prod-clientid')
+ })
+
+ it('is a no-op (next) at /register when the feature flag is off', () => {
+ process.env.MCP_CONSENT_ENABLED = 'false'
+ const res = mockRes()
+ const next = vi.fn()
+ consent.mcpConsentHandler(postReq(consent.REGISTER_PATH, '{}', 'application/json'), res, next)
+ expect(next).toHaveBeenCalledOnce()
+ expect(res.statusCode).toBeNull()
+ })
+})
diff --git a/test/unit/well-known-oauth.test.js b/test/unit/well-known-oauth.test.js
index 80c852c39..47f6c3331 100644
--- a/test/unit/well-known-oauth.test.js
+++ b/test/unit/well-known-oauth.test.js
@@ -307,6 +307,7 @@ describe('well-known-oauth: consent-proxy flip (MCP_CONSENT_ENABLED)', () => {
expect(m.issuer).toBe(SELF);
expect(m.authorization_endpoint).toBe(`${SELF}/mcp-oauth/authorize`);
expect(m.token_endpoint).toBe(`${SELF}/mcp-oauth/token`);
+ expect(m.registration_endpoint).toBe(`${SELF}/mcp-oauth/register`);
expect(m.scopes_supported).toEqual(['openid']);
expect(m.code_challenge_methods_supported).toContain('S256');
expect(m.token_endpoint_auth_methods_supported).toContain('none');
@@ -316,6 +317,7 @@ describe('well-known-oauth: consent-proxy flip (MCP_CONSENT_ENABLED)', () => {
delete process.env.MCP_CONSENT_ENABLED;
const m = authorizationServerMetadata('https://developers.sap.com',
'https://t.authentication.eu10-005.hana.ondemand.com', 'tutorials-mcp.Everyone');
+ expect(m.registration_endpoint).toBeUndefined();
expect(m.authorization_endpoint).not.toContain('/mcp-oauth/');
});