XSUAA OAuth for MCP-Native Clients¶
This guide sets up BTP XSUAA authentication so MCP-native clients (Claude Desktop, Cursor, VS Code, MCP Inspector) can authenticate via OAuth when connecting to ARC-1.
Updating an existing installation? Start with the upgrade table to keep existing client settings and UI URLs working.
Overview¶
MCP-native clients use RFC 8414 OAuth discovery to find authorization endpoints at the MCP server's URL. ARC-1 proxies the OAuth flow to XSUAA using the MCP SDK's ProxyOAuthServerProvider.
Auth flow:
- Client discovers OAuth via
/.well-known/oauth-authorization-server - Client redirects user to ARC-1's
/authorizeendpoint - ARC-1 proxies to XSUAA's login page
- After login, XSUAA returns authorization code
- Client exchanges code for token via ARC-1's
/tokenendpoint - Client sends Bearer token with MCP requests
Coexistence: XSUAA OAuth coexists with API key and generic OIDC auth (for example Entra ID, Okta, or Keycloak). All configured methods work on the same /mcp endpoint via a chained token verifier.
Prerequisites¶
- SAP BTP Cloud Foundry account with XSUAA entitlement
- CF CLI installed and logged in
- ARC-1 deployed on BTP CF (see BTP Cloud Foundry deployment)
Step 1: Identify the XSUAA lifecycle owner¶
Deployed with the repository MTA? It already creates/binds XSUAA and enables
SAP_XSUAA_AUTH. Do not create a second service or bind it again. Check cf target, then
cf services and cf app <app-name> in the intended space. Record the bound XSUAA instance and
its application identifier; inspect credentials locally only when needed and never paste them
into chat or a ticket. For ordinary functional roles, continue to Step 3.
For per-target restrictions, return to the target-access procedure
after this check; it requires verified enforcement before restricted-user assignment.
The MTA registers ARC-1's /oauth/callback and /oauth/logged-out on its deployed route
automatically. The optional UI extension also registers the AppRouter's /login/callback.
No per-client XSUAA entries are needed. For an external gateway or path
prefix configured with ARC1_PUBLIC_URL, use the custom public URL example.
Using a manually managed app or customer-owned service? Agree on the owner first. The create command below is only for a new, manually managed XSUAA instance. For an existing instance, inspect it and agree on descriptor updates instead of recreating it. MTA and manual lifecycle changes must not compete for ownership. See configuration ownership.
Manual path: create only when a new instance is intended¶
The xs-security.json template defines scopes, roles, and local development callbacks.
Copy it and add the two exact URLs on which ARC-1 is publicly reachable:
In the copied file, set oauth2-configuration.redirect-uris to your actual public URLs:
{
"oauth2-configuration": {
"redirect-uris": [
"https://arc1.example.com/oauth/callback",
"https://arc1.example.com/oauth/logged-out"
]
}
}
Merge this property into the copied descriptor; this fragment is not a complete XSUAA descriptor.
Keep the template's localhost entries only when needed for local use;
add the optional AppRouter's exact /login/callback if used. Never trust an entire shared domain
such as *.hana.ondemand.com or *.applicationstudio.cloud.sap.
After saving the edited file, create the new service:
For an existing customer-owned service, preserve its other settings and required callbacks and follow Updating xs-security.json instead.
oauth2-configuration.redirect-uristakes HTTP(S) URLs only. XSUAA rejects IDE schemes such ascursor://andvscode://there, and the rejection fails the whole create or update — see Troubleshooting. IDE callbacks do not belong in this list: ARC-1 sends XSUAA its own/oauth/callbackand validates the client's redirect URI itself.
The included xs-security.json defines 7 scopes:
| Scope | Description | Gates |
|---|---|---|
read |
Read SAP objects, search, navigate, lint, diagnose | SAPRead, SAPSearch, SAPNavigate, SAPContext, SAPLint, SAPDiagnose, SAPManage/SAPTransport/SAPGit read actions |
write |
Create / update / delete / activate ABAP objects | SAPWrite, SAPActivate, SAPManage package + FLP mutations |
data |
Preview named table contents | SAPRead(type=TABLE_CONTENTS) |
sql |
Execute freestyle SQL queries | SAPQuery |
transports |
Create / release / delete CTS transports | SAPTransport.create/release/delete |
git |
Authorize gated abapGit mutation/egress actions; gCTS mutations remain quarantined | SAPGit.external_info/clone/pull/push/branch/unlink actions after server gates |
admin |
Implies ALL other scopes at runtime | Everything |
The MTA defines the seven existing functional role collections below (assignable in BTP Cockpit).
With target authorization support, the MTA defines eight collections in total, adding a
separate, unassigned All Targets collection. It grants read and literal *, including future
targets; leave it unassigned unless explicitly approved. It does not change the seven functional
assignments or enable enforcement automatically. The manual
create-service command above reads only xs-security.landscape.json; it does not apply the collections in
mta.yaml. A manual owner must create the required collections and add the current application
roles before assigning users.
| Role Collection | Scopes | Use Case |
|---|---|---|
| ARC-1 Viewer | read |
Read-only SAP access |
| ARC-1 Developer | read, write, transports, git |
Full developer (write + CTS + Git) |
| ARC-1 Data Viewer | read, data |
Read-only + table preview |
| ARC-1 Viewer + SQL | read, data, sql |
Read-only + table preview + freestyle SQL |
| ARC-1 Developer + Data | read, write, data, transports, git |
Developer + data preview |
| ARC-1 Developer + SQL | read, write, data, sql, transports, git |
Developer + data + freestyle SQL |
| ARC-1 Admin | all 7 | Administrative access |
Multi-space deployments.
mta.yamlderives the XSUAAxsappnameand these role-collection names from the deploy-time${space}placeholder, so the same mtar can be deployed into several spaces of one subaccount side by side. The collections therefore appear in the cockpit with the space appended — e.g.ARC-1 Viewer (dev)in spacedev. Assign users to the collection for your space (Step 3). The route uses CF's generated default host unless overridden; read the actual route fromcf app <app-name>instead of guessing it from the space.Migrating an existing instance: compare its current
xsappname, collections and route with the reviewed deployment. Re-assign users if the collection/application identifier changes; update MCP client URLs only if the actual route changes. Preserve the stable DCR signing key across redeployments.For a manually managed service, applying its landscape file with
cf update-service arc1-xsuaa -c xs-security.landscape.jsonupdates the scopes and role templates only. It does not create role collections declared inmta.yaml. Agree on lifecycle ownership before adopting the MTA, then verify in Security → Role Collections that all seven functional collections exist and contain the expected roles. Also verify the eighth All Targets collection and leave it unassigned by default. This matters especially for older deployments: seeingMCPViewer,MCPDataViewer, orMCPSqlUserunder Roles does not mean the corresponding assignable role collections already exist.
Want a restricted developer (can write code but cannot transport or push to Git)? Define your own role template in xs-security.json with just [read, write] scopes, redeploy, and assign it — or use SAP_DENY_ACTIONS on the server.
Role collections are only the user-permission gate. Server flags still have to allow the capability: for example, a user in ARC-1 Developer still cannot create transports unless the ARC-1 instance also has SAP_ALLOW_WRITES=true and SAP_ALLOW_TRANSPORT_WRITES=true.
For optional per-user target filtering—most useful when a shared gateway serves different teams—follow the
static-cohort setup, not a second XSUAA
service-creation sequence. That guide covers version availability, setup checks and the
MCPTargetReadAccess template (required arc1_targets, no default role) and
MCPAllTargetReadAccess (explicit * default). A manually managed service still needs an owner to
create the corresponding collection; MTA owns its additional ARC-1 All Targets (<space>) collection.
Do not put the all-target role into an already assigned functional collection during an update.
For target attributes, keep the existing OAuth scope request set; see the distinction below.
IAS-fed target values remain optional and require their own live token/union/refresh validation.
Static target roles need no IAS change. Keeping standard access (standard) is supported when all
readers may see/try every target; neither mode automatically discovers the user's SAP accounts.
Assign the least-privilege collection
ARC-1 Developer bundles transports + git — assigning it authorizes CTS mutations and the
gated abapGit mutation/egress family when the matching server flags are on. It does not make gCTS
mutations available: those remain quarantined before HTTP. Some accepted abapGit mutations return
incomplete when no authoritative postcondition exists. For reviewers, assign ARC-1 Viewer (read
only); to grant code-write without transports/Git, use the [read, write]-only template above.
See authorization.md for the full three-layer authorization model.
Target attributes and OAuth scopes¶
arc1_targets is an XSUAA role attribute, not an OAuth scope. The unqualified user_attributes
does exist as a separate Cloud Foundry UAA scope,
but ARC-1 does not require it to obtain these XSUAA role attributes. Do not add either name to
ARC-1's requested scopes or invent $XSAPPNAME.user_attributes; that application-qualified scope
is not declared by ARC-1. Keep the multi-target endpoints' advertised mutation-free scope set unchanged.
Step 2: Manual path — bind service and configure¶
Skip these changes for a completed repository MTA deployment. For a manually managed app, replace
the example app/service names with the reviewed names and confirm cf target before changing it.
# Bind XSUAA to your app
cf bind-service arc1-mcp-server arc1-xsuaa
# Enable XSUAA auth
cf set-env arc1-mcp-server SAP_XSUAA_AUTH true
# Restage to pick up changes
cf restage arc1-mcp-server
Verify XSUAA is active in the logs:
cf logs arc1-mcp-server --recent | grep XSUAA
# Should show:
# INFO: XSUAA credentials loaded {"xsappname":"arc1-mcp-<space>!t..."}
# INFO: XSUAA OAuth proxy enabled {"xsappname":"arc1-mcp-<space>!t..."}
# INFO: ARC-1 HTTP server started {"auth":"XSUAA OAuth proxy"}
Step 3: Assign Role Collections¶
For filtered multi-target access, follow the target-role procedure instead: verify enforcement before assigning restricted users; Viewer alone has no target grants. The Viewer example below is for single-target or standard multi-target access.
- Open BTP Cockpit → Security → Role Collections
- Find the shipped collection for your space — the names carry the space suffix, e.g. "ARC-1 Viewer (
)", "ARC-1 Developer ( )", … "ARC-1 Admin ( )" (see the multi-space note above) - Click the role collection → Edit → Users tab
- Add your BTP user (email address)
- Save
Assign before you hand out the MCP URL. The assignment creates the shadow user, so it works for users who have never logged in — use the Users tab above, or:
btp assign security/role-collection "ARC-1 Viewer (<space>)" \
--subaccount <subaccount-id> --to-user <email> --of-idp <origin-key>
Choose the least-privilege collection for the task (normally Viewer for source-read acceptance), not Admin simply to make login work. Use the application identity-provider origin; a platform CLI/cockpit login is not proof of the user's application assignment.
Assigning before first sign-in avoids one possible stale-session case. If a user signed in before
the grant, they may need Role assigned? Refresh access and a new MCP sign-in. Cookie deletion
is not a required setup step. First use the invalid_scope decision table
to distinguish an invalid requested scope from missing authorization or stale client state.
Step 4: Verify OAuth Discovery¶
Read the route from cf app <app-name>; do not derive it from the space or copy another region's
hostname. If using a custom public URL, use that reviewed URL. Replace <arc1-route> in the client
examples below with this same host.
Expected response:
{
"issuer": "https://<arc1-route>/",
"authorization_endpoint": "https://<arc1-route>/authorize",
"token_endpoint": "https://<arc1-route>/token",
"scopes_supported": ["read", "write", "data", "sql", "transports", "git", "admin"],
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"]
}
The example above is the single-target /mcp authorization surface. Select the endpoint before
configuring a client:
| Endpoint | Protected-resource scopes ARC-1 advertises | Notes |
|---|---|---|
/mcp |
read, write, data, sql, transports, git, admin |
Actual grants and application ceilings still prune tools/actions |
/<SYSTEM>/<CLIENT>/mcp |
read, data, sql, admin |
Mutation-free pinned multi-target route |
/multi/mcp |
read, data, sql, admin |
Mutation-free aggregate route; SAP calls require target |
XSUAA returns only scopes assigned to the user. Advertising the full mutation-free set does not
grant Admin, data, or SQL. It lets OAuth clients request a usable token while role collections remain
the authorization source. Multi-target routes never advertise mutation scopes and still require
read before revealing whether a pinned target exists.
Step 5: Configure MCP Clients¶
Use /mcp for a single target, a pinned /<SYSTEM>/<CLIENT>/mcp URL for a target-bound
conversation, or /multi/mcp when the model must select among several targets. Do not replace one
with another merely to recover an OAuth error: endpoint selection is part of the security and tool
contract. Multi-target client examples are also available in
Multi-System Setup.
Claude Desktop¶
Add to claude_desktop_config.json:
Claude Desktop will automatically discover OAuth via /.well-known/oauth-authorization-server and prompt for login.
Cursor¶
In Cursor settings → MCP Servers, add:
MCP Inspector¶
Connect to:
The inspector will perform OAuth discovery and redirect to XSUAA login.
Note: Inspector's DCR registration binds its exact loopback callback, including
127.0.0.1 or [::1]. XSUAA redirects to ARC-1's callback proxy, so the Inspector callback
does not need an entry in XSUAA.
Copilot Studio (Manual OAuth — recommended)¶
Use Manual OAuth mode for the most predictable Copilot Studio interoperability. Ordinary ARC-1 restarts do not lose stateless DCR registrations; Manual mode is preferred because it avoids an extra dynamic-registration round trip that some Copilot Studio configurations do not retry cleanly.
- In Copilot Studio, add an MCP server connection
- Select Manual OAuth type
- Fill in:
- Client ID: XSUAA
clientidfromcf env <app-name>(e.g.sb-arc1-mcp-<space>!t627062) - Client secret: XSUAA
clientsecretfromcf env <app-name> - Authorization URL:
https://<app-route>/authorize - Token URL template:
https://<app-route>/token - Refresh URL:
https://<app-route>/token - Scopes: for single-target development, request the approved scopes (for example
read write); for multi-target, requestread data sql adminand let XSUAA return only the scopes assigned to the signed-in user. ARC-1 auto-qualifies names with the XSUAA xsappname. - Save — Copilot Studio generates a redirect URL
- ARC-1 automatically accepts the redirect URL (dynamic redirect URI registration for the XSUAA client)
Why Manual mode: Manual mode pins the connection to the permanent XSUAA service-binding clientid, which sidesteps DCR entirely. Dynamic Discovery (DCR) also works — client_ids are now stateless and survive cf restart/cf push/cell evacuation (see Stateless DCR below) — but Copilot Studio adds a /register round-trip on first connect that some configurations don't retry cleanly. Manual mode is the more predictable path.
Redirect URI: Copilot Studio uses https://global.consent.azure-apim.net/redirect/*.
ARC-1's built-in manual-client policy accepts this fixed-host callback family automatically.
It does not belong in XSUAA's callback list.
Which callback list should I change?¶
| Situation | Action |
|---|---|
| Standard MTA deployment or optional browser UI | No new callback setting: deployment registers ARC-1/AppRouter callbacks. Existing installations: check the upgrade table first. |
| New MCP client using Dynamic Client Registration (DCR), including a BAS workspace | Connect with the server URL. The client registers its exact callback automatically. |
| Supported manual client, such as Copilot Studio | Follow the client setup above; its callback is accepted by ARC-1. |
| A different client configured with the shared XSUAA client ID | Prefer its DCR mode. Editing XSUAA cannot add it to ARC-1's built-in manual-client policy. |
External gateway/custom ARC1_PUBLIC_URL or manually managed deployment |
Register ARC-1's exact public callback and logged-out URLs in the deployment-owned XSUAA configuration. |
XSUAA validates the first redirect to ARC-1. ARC-1 separately validates the second redirect to the MCP client: known manual callbacks use a built-in policy; DCR callbacks must match their registration exactly. A shared SAP domain wildcard is unnecessary at either layer.
Stateless DCR¶
ARC-1 implements RFC 7591 Dynamic Client Registration (DCR) with a stateless design: each issued client_id is an HMAC-signed token that carries its own registration payload (redirect URIs, grant types, etc.). The signing key is derived from the XSUAA clientsecret, so any process with the same service binding can validate any client_id ever issued — no shared store or persistent state is needed.
This means:
- DCR registrations survive
cf restart,cf push,cf restage, cell evacuations, OOM auto-recovery, and multi-instance scale-out — none of these invalidate cachedclient_ids. - The default lifetime is
0— never expire. There is no per-client revocation at any TTL (aclient_idis a stateless HMAC token, not a store row), so a finite TTL only produces periodicinvalid_clientre-auth outages — and some MCP clients (Eclipse Copilot, Copilot CLI) don't self-heal from it. Configurable via--oauth-dcr-ttl-seconds/ARC1_OAUTH_DCR_TTL_SECONDS; set a positive value to opt into expiry (clamped to[60s, 90d]). Revocation is global, via signing-key rotation (below). - Per-client revocation is intentionally not supported. Forced revocation goes through full key rotation (see below) — rotate the DCR signing key (
ARC1_DCR_SIGNING_SECRET) or rebind the XSUAA service. (A deeperKDF_LABELbump,arc1-dcr/v1→v2, also revokes everything, but it lives in the@arc-mcp/xsuaa-authpackage now, not this repo.) /register,/authorize,/token,/revokeare per-IP rate-limited by default (ARC1_AUTH_RATE_LIMIT=20/min/IP). Closes CodeQL alertjs/missing-rate-limiting. Tune via the env var or disable with=0if an upstream proxy already provides this. See the Rate Limiting Guide.
Stable DCR signing key (recommended)¶
By default, the DCR signing key derives from the XSUAA clientsecret. This is convenient (no secret to manage) but has a subtle side effect: cf deploy of an MTA recreates the XSUAA service binding, which rotates the clientsecret and therefore invalidates every cached client_id. Users see invalid_client after every redeploy and must re-register their MCP client.
To decouple the two and survive redeploys, set a dedicated signing secret:
SECRET=$(openssl rand -base64 48)
cf set-env arc1-mcp-server ARC1_DCR_SIGNING_SECRET "$SECRET"
cf restage arc1-mcp-server
ARC-1 emits a [warn] to stderr if ARC1_DCR_SIGNING_SECRET is set without SAP_XSUAA_AUTH=true — the secret is only consumed by the XSUAA OAuth proxy path, so this surfaces a misconfiguration where the secret would otherwise be unused.
Properties:
- cf set-env env vars survive cf deploy (CF doesn't reset them, and MTA only touches env vars declared in mta.yaml properties)
- Re-setting the value (cf set-env with a new secret + cf restage) is the explicit revocation knob — invalidates every client_id issued under the old secret
- Falls back to the XSUAA clientsecret when unset, preserving the legacy behavior
- Empty or whitespace-only values are treated as unset (with a [warn]), so a misconfigured env var won't crash startup
- A signing secret shorter than 16 bytes (128 bits) triggers a soft warning at startup; use openssl rand -base64 48 for the recommended ≥32 bytes
ARC-1 logs the active signing source as dcrSigningSource: 'override' | 'xsuaa' in the startup INFO line for observability — 'override' means the dedicated ARC1_DCR_SIGNING_SECRET is in use, 'xsuaa' means the legacy clientsecret fallback.
Why this is best practice. A client_id issued via RFC 7591 Dynamic Client Registration is only as durable as the key that signs it — in ARC-1's stateless design the signing key is the registration store. Tying that key to a credential that rotates on deploy (the XSUAA clientsecret) turns every redeploy into an unintended key rotation — the same failure mode a web framework hits when its session-signing key (Django SECRET_KEY, Rails secret_key_base) is regenerated per release: all previously-signed artifacts silently become invalid. The fix is the standard one for any signing key — externalize it from the deploy artifact and keep it stable across releases (12-Factor Config), rotating only when you intend to (OWASP Secrets Management Cheat Sheet).
Service-binding rotation¶
The XSUAA clientsecret is always an upstream OAuth credential. It is also the DCR signing source
only while ARC1_DCR_SIGNING_SECRET is unset. In that fallback configuration, rebinding XSUAA
invalidates all DCR registrations. With the recommended dedicated signing secret, rebind/rotation
does not revoke DCR clients; rotate ARC1_DCR_SIGNING_SECRET deliberately for global DCR
revocation.
To rotate the XSUAA binding:
cf unbind-service arc1-mcp-server arc1-xsuaa
cf bind-service arc1-mcp-server arc1-xsuaa
cf restage arc1-mcp-server
After this sequence, the DCR effect depends on the active signing source:
- With
dcrSigningSource: 'xsuaa', every previously issued DCRclient_idbecomes invalid because the effective signing key changed. Clients must register again; cached access/refresh behavior is client- and token-lifetime-dependent. - With
dcrSigningSource: 'override', local DCR registrations remain valid because the dedicated signing key did not change. Upstream XSUAA credential rotation can still require a fresh OAuth login, but it is not a DCR revocation event.
DCR state is globally invalidated whenever the effective signing key changes: rotate
ARC1_DCR_SIGNING_SECRET in dedicated-key deployments, or rotate/rebind the XSUAA credentials while
using the fallback. Routine restart, push without rebind, restage, and cell movement preserve DCR
state while that key remains stable.
Recovering a stuck client¶
After a client has been connected for a while it can fail with one of two errors. They look alike but invalidate different things, so they have different fixes:
| Error | What's stale | Cached token still works? | Fix |
|---|---|---|---|
invalid_token / "not a valid XSUAA, OIDC, or API key token" |
the access token | no — it expired | restart the client — a cold start re-runs auth |
invalid_client / Invalid client_id |
the DCR registration | usually yes — so tool calls keep working | clear the cached registration — a restart alone won't, because the client keeps using its still-valid token and never re-registers |
Prevent both: for invalid_client, set a stable ARC1_DCR_SIGNING_SECRET + ARC1_OAUTH_DCR_TTL_SECONDS=0 so a redeploy can't rotate the signing key. For invalid_token, the default refresh-token-validity in xs-security.json is 30 days, so idle sessions survive far longer.
Client behaviour varies. Claude Desktop and MCP Inspector usually re-register on their own and rarely surface either error. VS Code, Cursor, and Eclipse GitHub Copilot cache the DCR registration and can stay stuck on invalid_client until you clear it — steps per client below. (Eclipse additionally has no per-server "restart MCP" / re-auth action yet — copilot-for-eclipse#237.)
Eclipse GitHub Copilot¶
For invalid_token (an expired or long-idle session — you're effectively logged out): quit and reopen Eclipse. On the cold start it re-runs the sign-in — its "… wants to authenticate" dialog appears — and you're back.
For invalid_client (the server's signing key rotated, e.g. an MTA cf deploy): a restart usually won't help — Copilot keeps using its still-valid access token and never re-registers, so the stale client_id only resurfaces on the next forced sign-in. You have to clear its cached registration so it calls /register again. Quit Eclipse, then delete its one cache file:
# macOS / Linux — clears cached MCP logins; you re-authorize each server once
rm ~/.config/github-copilot/copilot-eclipse.db
Citrix / VDI / roaming profiles:
%LOCALAPPDATA%is often not the literalC:\Users\<you>\AppData\Local— the profile is redirected into a container, so the file is there under a different path. Resolve the variable in-session instead of guessing (Eclipse closed):
Reopen Eclipse → use the server → it registers fresh and prompts you to sign in. Deleting the file is low-impact:
- ✅ The only cost: re-authorize your MCP server(s) once (a browser sign-in each).
- ❌ It does not sign you out of GitHub Copilot itself — that's a separate
auth.db. - ❌ It does not touch your code, workspaces, Eclipse preferences, or your MCP server list — only cached MCP auth.
Want to keep your other MCP servers signed in? With the
sqlite3CLI, delete only this server's rows (the cache is keyed by server URL):sqlite3 ~/.config/github-copilot/copilot-eclipse.db \ "DELETE FROM state WHERE key LIKE 'dynamicAuthProvider:%your-app.cfapps%';"Sanity check: a healthy ARC-1
client_idlooks likearc1-eyJ2Ijox…(~280+ chars). A shortarc1-<8 hex>id predates the stateless store and is always rejected — clear it the same way.
Cursor¶
Cursor also caches its registration and may not re-register on invalid_client. Reset it by removing the MCP server entry, restarting Cursor, then re-adding it. With the stable signing key set (above), you only ever do this once.
VS Code¶
VS Code caches the DCR registration in its own secret storage, keyed by the OAuth issuer URL — so for invalid_client (a rotated signing key) signing out, Restart Server, and removing or renaming the server in mcp.json do not clear it (a sign-out drops the access token but keeps the registration; the issuer URL never changes). Clear the registration itself:
- Command Palette (
Ctrl/Cmd+Shift+P) → "Authentication: Remove Dynamic Authentication Providers". - Tick the ARC-1 entry — there may be several stale ones; remove them all, then OK.
- Restart Server (the
arc-1-…entry's actions menu) → trigger any tool → VS Code registers a freshclient_idand prompts you to sign in again.
See Manage MCP servers in VS Code for the Accounts-menu auth controls; the stale-credential cleanup is tracked in microsoft/vscode#269379.
Found an easier way to recover an Eclipse, VS Code, or Cursor MCP login? MCP-client behavior is still evolving — please open an issue or PR so these docs can capture the simplest known fix.
Browser-based DCR clients (rare)¶
CORS applies when a browser makes a cross-origin request to ARC-1. Native or server-side MCP transports are not subject to it, but browser tools such as MCP Inspector may use a proxy or direct browser requests depending on their configuration. For direct browser requests, add the exact origin to ARC1_ALLOWED_ORIGINS. See Security headers & CORS for the full configuration.
Audit events¶
DCR lifecycle is captured in the audit stream alongside tool calls. Three event types fire:
oauth_client_registered—info: a newclient_idwas minted; payload includes the issued id, client name, redirect-URI count, and id length (for tracking URL-budget regressions).oauth_client_lookup_failed—warn(orinfoforexpired): aclient_idfailed to resolve;reasonis one ofunknown_prefix/malformed/bad_signature/invalid_payload/expired. Useful for spotting forgery / probing attempts.oauth_redirect_uri_registered—info: a redirect URI matched ARC-1's manual-client policy and was added at/authorizetime to ARC-1's in-memory metadata for that client. This does not update the XSUAA service configuration.
Events flow through the existing audit sinks (stderr / file / BTP Audit Log Service) — same pipeline used for tool-call audit.
Updating xs-security.json¶
For MTA-owned services, change the reviewed source descriptor/extension and follow the normal MTA deployment procedure so its effective application name, roles and configuration stay aligned. Do not replace MTA's merged configuration with the bare base file as a troubleshooting shortcut.
For a manually managed service, its owner can add approved redirect URIs or scopes and apply the matching descriptor:
# Edit the landscape-specific file used at creation, retaining its callback URLs.
# Then update the service:
cf update-service arc1-xsuaa -c xs-security.landscape.json
Existing bindings and service keys inherit oauth2-configuration changes — no rebind needed.
Copies of xs-security.json taken before September 2026 may still list cursor:// and vscode://
redirect URIs. Remove them before cf update-service; XSUAA rejects the whole update otherwise
(see Troubleshooting).
Custom public URL¶
If ARC1_PUBLIC_URL=https://gateway.example.com/arc1, merge this resource override into the
same landscape extension that sets that property. The path prefix is part of both URLs:
resources:
- name: arc1-xsuaa
parameters:
config:
oauth2-configuration:
redirect-uris:
- https://gateway.example.com/arc1/oauth/callback
- https://gateway.example.com/arc1/oauth/logged-out
Merge this block into your existing resources: entry; do not replace the rest of your extension.
Extension maps merge recursively, while redirect-uris replaces the whole list. The grants and
token lifetimes from mta.yaml remain in effect; retain any intentional customer overrides.
The standard UI deploy helper adds the AppRouter callback and preserves this list. If the
AppRouter itself uses an external gateway, also add its exact public /login/callback.
A normal backend host:/domain: override needs no extra callback edits. For a backend using
explicit routes:, set ARC1_PUBLIC_URL to the route users connect to and use the exact callback
list above: MTA's ${default-url} does not follow the explicit routes list.
Upgrading an existing deployment¶
Standard MCP clients keep their server URL and registration. There is no new environment variable, service, rebind, or role assignment. Preserve the stable DCR signing key as on any redeploy. The intentional compatibility change is that a client using the shared XSUAA client ID can no longer redirect to an arbitrary SAP CF/BAS host; use that client's DCR mode instead.
Before deploying, check every row that applies to your installation:
| Existing setup | Upgrade action |
|---|---|
| Repository MTA, ordinary CF backend route, no browser UI | Deploy the updated MTAR with your existing landscape extension. No callback edits. |
| Optional browser UI with no explicit route in your extension | Pin the current UI route before deploying to keep bookmarks working. |
UI with explicit host/domain, hosts/domains, or routes |
Keep those settings and use the UI deploy command below; the helper registers matching UI callbacks. |
Gateway, backend routes, or custom ARC1_PUBLIC_URL |
Keep the public URL and add its exact callbacks using Custom public URL. |
An existing redirect-uris override |
Replace shared-domain wildcards with the exact ARC-1 callback and logged-out URLs. Keep any required AppRouter callback. The override wins over the new defaults. |
| Manually managed XSUAA | Its owner updates the landscape JSON as described above, retaining its app name, scopes, grants and lifetimes, then deploys the updated ARC-1 app. |
| No XSUAA authentication | No callback migration is required. |
For an MTA-owned installation, follow the normal update procedure
with the updated descriptors and your existing extension. Update both XSUAA and the app: an
app-only push or restage leaves the old XSUAA policy in place. For the optional UI, build with
npm run btp:build-ui-ext, inspect the artifact as in the deployment runbook, then run
npm run btp:deploy-ui-ext. That command generates mta-ui-deploy.mtaext from your
mta-overrides.mtaext; edit the source extension, since the generated file is overwritten.
After deployment, verify a fresh MCP login, a safe read and logout. If using the UI, verify a fresh login at its existing URL too. If XSUAA reports an invalid redirect, compare the rejected URL with the exact registered list and correct that URL; do not restore a shared-domain wildcard.
Blue-green deployments: ${default-url} can refer to the temporary idle route during testing.
Keep the stable public ARC1_PUBLIC_URL and register its exact callbacks explicitly. If you also
test OAuth at an idle URL, register those exact temporary callbacks during the test and remove
them afterward. Check both the test route and the production route before completing the switch.
Keep an existing UI URL¶
Read the current route before upgrading:
Merge it into the existing arc1-ui-router module in mta-overrides.mtaext. For example, if the
route shown is old-ui.cfapps.eu10.hana.ondemand.com:
Use the actual route from your space; do not copy the example hostname. If you already use
routes: or plural hosts:/domains:, retain those instead of adding a competing singular host.
The UI helper preserves these settings and registers matching /login/callback URLs. Explicit
routes must use actual hostnames, not module-local ${default-url}/${default-uri} placeholders.
Without a route override, the optional UI uses arc1-ui-${space-guid} on the CF domain after this
upgrade. That changes an older default UI URL; pinning the current route avoids the change. The
MCP backend URL is unaffected.
Calling ARC-1 from another BTP application¶
The setup above covers a human at an MCP client (Claude, Cursor, Eclipse) logging in through the browser. A different case is another BTP application — an AI assistant backend, a CAP service, a Fiori app — that already has its users logged in via XSUAA and wants to call ARC-1 as them, without sending them through a second OAuth login.
That is a jwt-bearer token exchange: the caller trades its user's JWT for one audienced to ARC-1.
xs-security.json enables it:
{
"oauth2-configuration": {
"grant-types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"]
}
}
This is the relevant fragment of the descriptor, not a replacement for its other settings.
The exchange runs against ARC-1's own OAuth client, so the grant belongs in ARC-1's descriptor — not the caller's. Create a service key on ARC-1's XSUAA instance and hand its credentials to the consumer:
cf create-service-key arc1-xsuaa consumer-key
cf service-key arc1-xsuaa consumer-key # → clientid, clientsecret, url
Issue a separate key per consumer. With the default binding-secret credential type each key
carries its own secret, distinct from the running app's binding — so a consumer's key can be rotated
or revoked without disturbing ARC-1 itself, and it never exposes the app's own credentials (which,
unless you set ARC1_DCR_SIGNING_SECRET, also sign the DCR client_ids — see
Stable DCR signing key).
Then either let the Destination service do it (no code — recommended):
| Destination property | Value |
|---|---|
Authentication |
OAuth2JWTBearer |
URL |
https://<arc1-host>/mcp |
tokenServiceURL |
<url from the service key>/oauth/token |
clientId / clientSecret |
from the service key |
…or POST the exchange yourself:
POST <xsuaa-url>/oauth/token
Authorization: Basic <arc1-clientid>:<arc1-clientsecret>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<the user's JWT>
The returned token is audienced to arc1-mcp!t… and carries only the scopes that user's ARC-1
role collections grant (Step 3) — the exchange propagates identity, it never widens authorization.
Present it as Authorization: Bearer … on /mcp.
x509 works too: create the key with -c '{"credential-type":"x509"}' and exchange against the
certurl host over mTLS. No credential-types declaration is needed in xs-security.json.
SAP's own walkthrough of this flow — same shape, with a generic "Business Logic Application" where
ARC-1 sits — is How grant-types keep your application secure, Exercise 3.
Note that grant-types itself is absent from SAP's documented oauth2-configuration property table;
it is real and broker-honored, just undocumented.
What this does not enable
- No headless technical user.
client_credentialsstays off the allowlist deliberately — there is no way to mint an ARC-1 token with no human behind it. - Same subaccount only. A caller in another subaccount fails with
Unable to map issuer(#434) — the issuer is not trusted there. - Users still need role collections. An exchanged token for a user with no ARC-1 collection authenticates but authorizes nothing.
Configuration Reference¶
| Variable | Description | Default |
|---|---|---|
SAP_XSUAA_AUTH |
Enable XSUAA OAuth proxy | false |
XSUAA credentials are automatically loaded from VCAP_SERVICES when the service is bound. No manual credential configuration is needed.
How Auth Coexistence Works¶
When XSUAA auth is enabled, the chained token verifier tries three methods in order:
- XSUAA JWT — validated by
@sap/xssecagainst XSUAA JWKS (offline, cached) - Generic OIDC JWT — validated by
joseagainst OIDC issuer JWKS (ifSAP_OIDC_ISSUERis set) - API Key — simple string match against
ARC1_API_KEYSentries
The first successful validation wins. This means: - MCP-native clients (Claude Desktop, Cursor, MCP Inspector) use XSUAA OAuth via auto-discovery - Copilot Studio uses XSUAA OAuth via Manual mode (or generic OIDC, such as Entra ID, if configured separately) - API key auth continues to work for testing and Joule Studio
Troubleshooting¶
"AADSTS50011: Redirect URI mismatch"¶
Identify the issuer reporting the mismatch. AADSTS50011 is a Microsoft Entra error: the IAM
owner must compare the redirect URI and application ID in the error with the intended Entra
registration (Microsoft troubleshooting).
Changing ARC-1's XSUAA allowlist does not repair that upstream registration.
For a mismatch reported by XSUAA instead, compare ARC-1's public /oauth/callback with the
effective service configuration and follow
Updating xs-security.json for the MTA or manual lifecycle. Do not add
another region's wildcard or apply the bare base file to an MTA-owned instance as a shortcut.
If ARC-1 rejects redirect_uri before the XSUAA login page, check whether the client is using
its DCR-issued ID or the shared manual ID. A DCR client must send the exact registered callback;
an unrecognized manual callback should use DCR. An XSUAA configuration edit cannot fix this check.
"Malformed redirect URIs detected"¶
cf create-service or cf update-service for the XSUAA instance fails with
Error parsing xs-security.json data: Malformed redirect URIs detected. The following URIs are invalid: [cursor://…, vscode://…],
and the instance is not created or updated. XSUAA refuses IDE deep-link schemes in
oauth2-configuration.redirect-uris; descriptors that still carry them are rejected as a whole.
For an MTA-managed service, rebuild and deploy with the corrected descriptor. For a manually managed
service, remove those entries from its complete landscape descriptor and retry the create/update.
Preserve its existing app name, scopes and other OAuth settings; do not replace it with an unmodified
repository template. ARC-1 routes
the OAuth return through its own /oauth/callback, so XSUAA only ever sees that URL. The client's real
redirect URI, including Cursor and VS Code deep links, is validated by ARC-1's runtime policy
through @arc-mcp/xsuaa-auth, not by XSUAA.
"Token has no expiration time"¶
API key tokens now include a synthetic expiration (1 year). If you see this error, ensure you're running the latest version of ARC-1.
"XSUAA credentials not found"¶
Confirm cf target and inspect cf services for the intended binding. For MTA-owned resources,
review the deployment operation and descriptor with its owner. Use manual binding
only for a manually managed app; do not attach a similarly named XSUAA instance just to clear the error.
"Insufficient scope" / "invalid_scope"¶
invalid_scope is not a diagnosis by itself. OAuth permits it for unknown or malformed scopes as
well as scopes exceeding the grant (RFC 6749).
Read the exact error description, selected endpoint and intended application identity before
changing roles or clearing state. Do not share full callback URLs, tokens or binding credentials.
| Evidence | Owner and next check |
|---|---|
Scope name reported as invalid/unknown, e.g. an application-qualified user_attributes |
Application/deployment owner: compare the request, endpoint metadata and effective XSUAA descriptor. Fix the unsupported scope/name qualification. Do not invent an application scope or grant Admin to suppress the error. |
| User is not allowed any requested scopes | IAM owner: check the required collection is assigned to the correct application identity and contains roles for the bound application identifier. An assignment alone does not prove those roles exist. |
| Collection exists but has no roles or references an old application identifier | IAM and deployment owners: inspect current role templates and collection contents; use the owner-safe repair below. |
| Email is assigned but login uses another IdP origin | IAM owner: use the IdP-origin check and --of-idp example to match the application login identity. Platform CLI/cockpit access does not establish application access. |
| Correct current roles/origin are verified, but the browser session predates the change | User: if the failure page offers Refresh access, use it and start MCP sign-in again; otherwise follow the recovery procedure below. |
| Sign-in succeeds but old permissions/tools remain | User: use sign-out/new sign-in and the recovery procedure below, then verify the resulting access and reload tools. Reconnect or token refresh can retain old grants. |
A fast callback or an absent login form is not proof of stale permissions: SSO can also return a current grant failure without an interactive form. Preserve a sanitized error code and request correlation for the owner instead of drawing conclusions from timing.
Refresh access after a verified assignment¶
After an administrator assigns a role collection, the browser can still hold an older XSUAA SSO
session. On an eligible invalid_scope failure page for a loopback MCP callback, ARC-1 includes
Role assigned? Refresh access. Use it after checking the assignment, wait for Access refreshed,
then return to the MCP client and retry sign-in. Hosted callbacks or a successful login with stale
target grants may show no action. A new IdP login may be required; this cannot repair an unknown scope name.
The action calls XSUAA's documented /logout.do endpoint with ARC-1's bound client_id and a
fixed, allowlisted ARC-1 return URL. Callback query parameters never select the logout host or
redirect. MTA registers /oauth/logged-out on ARC-1's route. For a custom domain or path prefix,
include that public URL as shown in Custom public URL.
The action ends the XSUAA browser SSO session; it does not revoke already issued access tokens or
necessarily sign out the upstream IdP. Use the MCP client's re-authentication flow if it retains an
old token, then refresh its tool catalog. When no action is available, use a fresh private browser
session as a comparison or ask the deployment owner for the logout link built from the bound
XSUAA origin/client ID and exact allowlisted ARC-1 /oauth/logged-out return URL. That return page
alone is not a logout endpoint. Only if necessary, clear site data for the verified XSUAA
domain—not all browser cookies. Read that domain from the intended binding locally, without
copying its credentials into a support request.
Do not use a bare <xsuaa-url>/logout or /logout.do URL. XSUAA requires the application client and an allowlisted return URL for a reliable application logout.
Verify the resulting permissions; a newly issued token can still carry stale grants. For filtered target access, use the role-change and revocation checks, including the shipped access/refresh lifetimes and the customer's incident procedure.
Repair missing or stale collection roles with the owner¶
In Security → Role Collections → the intended collection → Roles, compare the role template
and application identifier with the currently bound XSUAA application. For example, a Viewer
collection needs MCPViewer from that application, not a similarly named old instance. Roles and
collections are different objects; templates existing under Roles is insufficient evidence.
Service replacement can leave stale references. Before repair, record the collection's roles, user/group assignments, IdP mappings and lifecycle owner. Have that owner reconcile the current roles through the approved MTA/IAM process and verify a fresh user grant. Do not delete/recreate collections or XSUAA as a generic login fix: that can disrupt other users and lose assignments or mappings. A redeploy alone is not proof that existing collections were repaired. For the exceptional, owner-approved replacement of a verified orphaned collection, see Role and user administration.
"Invalid client_id" (Copilot Studio)¶
DCR registrations are stateless and survive ordinary restart, push, restage, and scale-out while the
signing key stays stable. Check the startup dcrSigningSource, restore the intended
ARC1_DCR_SIGNING_SECRET, or re-register the client after an intentional key/binding rotation.
Manual OAuth remains the more predictable Copilot Studio path because it avoids the dynamic
registration round trip, not because ARC-1 stores registrations in memory.
"Token validation failed: not a valid XSUAA, OIDC, or API key token" (Copilot Studio)¶
Copilot Studio caches the access token from the initial sign-in. XSUAA tokens expire after 1 hour and Copilot Studio does not always refresh them automatically — the connector keeps sending the expired token, which ARC-1 correctly rejects.
Fix: re-authenticate the connection. In your bot, open Test → Connections → ⋮ next to the ARC-1 connection → Authenticate, or delete and re-add the connection from the connector page.
OAuth flow hangs or returns 400¶
Check that the XSUAA client ID matches. Run cf env <app-name> and look for the clientid in the XSUAA binding credentials.
"Authorization Request Error" / XSUAA login fails¶
Check the issuer of the error and the actual redirect_uri sent to XSUAA. It should be ARC-1's
public /oauth/callback, not the MCP client's loopback URL. Follow the callback-list guidance above.
Architecture¶
MCP Client (Claude Desktop, Cursor, MCP Inspector)
│
├── GET /.well-known/oauth-authorization-server ──→ OAuth metadata
├── GET /authorize?client_id=...&redirect_uri=... ──→ Proxied to XSUAA login
├── POST /token (authorization_code exchange) ──→ Proxied to XSUAA token endpoint
│
└── POST /mcp (Bearer token)
│
├── requireBearerAuth middleware
│ └── Chained verifier: XSUAA → OIDC → API key
│
└── MCP Server (per-request)
└── ADT Client → SAP System