MCP OAuth2 Authentication¶
Overview¶
The MCP (Model Context Protocol) server supports OAuth 2.1 authentication per the MCP specification (2025-03-26). MCP clients like Claude Code and Claude Desktop authenticate via the central auth server (auth.bdgr.co.uk) using Authorization Code + PKCE flow, rather than static API keys.
Architecture¶
MCP Client (Claude Code / Claude Desktop)
│
├─1─► /.well-known/oauth-authorization-server (on web app — RFC 8414)
│ → Returns auth server metadata (endpoints, scopes, grant types)
│
├─2─► auth.bdgr.co.uk /oauth2/register (RFC 7591 Dynamic Client Registration)
│ → Client auto-registers as public PKCE client
│
├─3─► auth.bdgr.co.uk /oauth2/authorize (Authorization Code + PKCE)
│ → User logs in, sees consent screen, approves MCP access
│
├─4─► auth.bdgr.co.uk /oauth2/token
│ → Client exchanges code for JWT access token
│
└─5─► shop.example.com/mcp (Bearer token)
→ MCP server validates JWT, checks ROLE_MCP + scopes, executes tools
Authorization Server Metadata (RFC 8414)¶
The MCP spec requires clients discover the authorization server via GET /.well-known/oauth-authorization-server on the MCP server's base URL (path stripped). Since the auth server runs on a separate origin, the web app serves a metadata document that points to the auth server's actual endpoints.
GET https://shop.example.com/.well-known/oauth-authorization-server returns:
{
"issuer": "https://auth.bdgr.co.uk",
"authorization_endpoint": "https://auth.bdgr.co.uk/oauth2/authorize",
"token_endpoint": "https://auth.bdgr.co.uk/oauth2/token",
"registration_endpoint": "https://auth.bdgr.co.uk/connect/register",
"scopes_supported": ["openid", "mcp:read", "mcp:write", "mcp:admin"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256"]
}
Implementation: McpAuthorizationServerMetadataController in the ai module.
Protected Resource Metadata (RFC 9728)¶
Also available at GET /.well-known/oauth-protected-resource for non-MCP OAuth2 clients:
{
"resource": "https://shop.example.com/mcp",
"authorization_servers": ["https://auth.bdgr.co.uk"],
"scopes_supported": ["openid", "mcp:read", "mcp:write", "mcp:admin"],
"bearer_methods_supported": ["header"]
}
The resource field is dynamic based on the request domain (multi-tenant).
Transport and Sessions¶
The MCP server speaks Streamable HTTP on a single endpoint, POST /mcp, in Spring AI's
STATELESS mode:
Every request is self-contained. The server never mints an Mcp-Session-Id, never holds
per-client state in heap, and never needs the next request to come back to the same JVM.
Why stateless and not STREAMABLE. STREAMABLE assigns each client a session and keeps it
in memory. badger-web runs 2 replicas behind badger-web-service, a plain ClusterIP service
with no sessionAffinity, so kube-proxy spreads requests across both pods. A client that
initialises against pod A and then calls a tool on pod B gets Session not found. Rather than
introduce sticky sessions at the edge or replicate session state between pods, we drop the
session: McpAuthFilter authenticates the Bearer token and resolves the tenant on every
request anyway, so there is nothing left for a session to carry.
This is only safe because none of our tools need a session. Every @McpTool method is plain
request/response — no sampling, no elicitation, no roots, no progress notifications, no resource
subscriptions. Adding a tool that needs any of those would mean reintroducing session state, so
don't.
Don't just delete the protocol key
Spring AI defaults to STREAMABLE — the one mode that breaks across our replicas. (Before
2.0.1 it defaulted to SSE, the deprecated 2024-11-05 transport, so removing the property has
never been safe.) The valid values are SSE, STREAMABLE, and STATELESS.
Protocol version¶
We build on Spring AI 2.0.1 and io.modelcontextprotocol.sdk 2.0.1, which advertises
protocol versions 2024-11-05, 2025-03-26, 2025-06-18, and 2025-11-25.
The current MCP specification, 2026-07-28,
removes protocol-level sessions outright — no Mcp-Session-Id, no GET stream, no Last-Event-ID
resumability — along with the initialize handshake, in favour of per-request protocol metadata
and a server/discover RPC. That matches how we already run the server, but Java is a Tier 2 SDK
and does not implement 2026-07-28 yet. When the SDK ships support, adopting it should be a
version bump plus a re-test rather than an architectural change, because we are not carrying any
session state to unwind.
Authentication¶
OAuth2 JWT Bearer tokens issued by the auth server are the only accepted credential. There is no second way in.
The mcp_* API key path was retired
MCP originally also accepted a per-tenant static API key held in the mcp-api-key site
config. That path is gone: McpApiKeyService was deleted, the config key was removed, and a
system update purges any stored values. A mcp_* token now fails JWT decoding and gets the
same 401 as any other invalid bearer token — clients still presenting one must re-authorise
through the OAuth2 flow above.
Retiring it also let McpToolAuthorizationAspect fail closed. It previously waved through any
non-JWT authentication on the grounds that API key clients held every scope; it now denies.
OAuth2 Scopes¶
| Scope | Description | Required Role |
|---|---|---|
mcp:read |
Read store data (products, collections, orders, statistics) | ROLE_MCP |
mcp:write |
Create, update, and delete store data | ROLE_MCP + any admin role |
mcp:admin |
Access admin settings, pipelines, site configuration | ROLE_MCP + any admin role |
Scope filtering happens at the auth server during token issuance. Users without ROLE_MCP have all MCP scopes stripped. Users with ROLE_MCP but no admin roles only receive mcp:read.
Scopes nest: mcp:admin implies mcp:write, which implies mcp:read (McpScopes). A token
that only asked for mcp:admin can still call read and write tools.
Customer details need mcp:write. Because every ROLE_MCP user gets mcp:read, customer
names, emails, phone numbers and addresses (orders, subscriptions, gift cards) are only returned
to tokens holding mcp:write — in practice, store admins. Other tokens get the rest of the record
with customerDetailsHidden: true. The customers tool is read-only but needs mcp:write
for the same reason, as does filtering orders list by customerEmail (which reveals whether
someone has shopped here). A dedicated mcp:customers scope would be cleaner, but needs
the auth server, consent screen and every registered client updated, so it hasn't been done.
ROLE_MCP¶
The MCP role gates who can use MCP. Assign this role to users via the admin UI to grant MCP access. Without this role, JWT authentication to the MCP server will be rejected with 403.
For dynamically registered clients (RFC 7591), the JWT may not contain tenant-specific roles. In this case, McpAuthFilter falls back to a database lookup — it resolves the user's roles on the tenant identified by the request domain.
Dynamic Client Registration¶
MCP clients register automatically via RFC 7591 (POST /oauth2/register). The registration endpoint is discovered from the authorization server metadata. Clients register as public clients with PKCE:
{
"client_name": "Claude Code",
"redirect_uris": ["http://127.0.0.1:{port}/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
scope is optional. claude.ai connectors send "scope": "openid mcp:read mcp:write mcp:admin",
so the registration request may name any of openid profile email offline_access mcp:read
mcp:write mcp:admin. Any other scope is rejected with 400 invalid_scope (RFC 7591 §3.2.2).
Whatever the client asks for, the registered client is granted openid profile email mcp:read
mcp:write mcp:admin, and scopes are requested again on the authorize call.
Spring Authorization Server 7's default validator rejects every scope at registration, which made
claude.ai fail with "Couldn't register with ... sign-in service". McpDynamicClientRegistration
(in the auth module) replaces only the scope check with the allow-list above; the default
redirect_uris checks (no fragment, no javascript:/data: schemes) and the https-only
jwks_uri check still apply. Dynamically registered clients always require consent and get 1h
access tokens and 24h refresh tokens.
Pre-registered clients (e.g., via admin UI or MongoOidcClientRegistrationService) can include badger.site-group-id and badger.site-id in client settings for tenant-specific token claims.
Consent Screen¶
When an MCP client initiates OAuth2 authorization, users see a consent screen listing the requested scopes with human-readable descriptions. Consent decisions are persisted to MongoDB and remembered for future authorization requests.
Configuration¶
MCP has no per-tenant configuration keys. Access is granted entirely through ROLE_MCP on the
user plus the OAuth2 scopes the auth server issues.
The retired mcp-api-key and mcp-oauth2-enabled keys were removed; system updates 81.json
and 82.json delete any values left in the siteConfiguration collection.
Tool Conventions¶
These are enforced by McpToolConventionsTest in the ai module.
Read and write tools are separate. Each domain has a read tool named after the thing
(products, collections, orders...) and, where writes exist, a manage* tool
(manageProducts, manageCollections...). That split exists so that:
- a read-only token (
mcp:read) can list and inspect everything, and a write needsmcp:write; - the MCP tool annotations are truthful. Read tools set
readOnlyHint = true, and every tool setsopenWorldHint = falseexceptmanageMedia, whoseimportfetches a URL the caller supplies (OPEN_WORLD_TOOLSin the test lists the exceptions). Clients use these to auto-approve reads and warn before destructive calls. Before the split, every tool advertised the defaults (readOnlyHint = false,destructiveHint = true,openWorldHint = true), reads included.
Each tool method carries @McpScope(McpScopes.READ | WRITE | ADMIN). pipelines,
managePipelines, siteConfig and manageSiteConfig need mcp:admin.
Failures are MCP error results. Tools throw McpToolException (via McpArgs helpers) for
anything the caller can fix: unknown action, missing argument, not found, missing scope.
McpToolInvocationAspect returns it as a CallToolResult with isError = true and the message
as its only text, so the model can correct itself. Unexpected exceptions are logged at ERROR and
the model gets a generic message, without internal details. No tool returns null or an
{"error": ...} map. Every @McpTool method is declared to return Object, because the aspect
may substitute the error result.
Pages are capped. size defaults to 20 and is capped at 50 (McpArgs.MAX_PAGE_SIZE). Every
row a tool returns stays in the model's context for the rest of the conversation.
Server instructions. spring.ai.mcp.server.instructions in web/application.yml holds the
domain primer (how products, collections, extensions, stereotypes and taxonomy fit together,
minor-unit money, identifiers, paging, error handling). Every client receives it once at
initialize, so it doesn't depend on the Claude Code badger-skills plugin. Keep it short; long
reference material belongs in that plugin or in extensions getPromptSection.
Fulfilment and refunds. manageFulfilment (mcp:write) packs and dispatches orders
(dispatch takes an optional carrier, trackingNumber and trackingUrl, recorded on the order
and shown in the dispatch email; see Dispatch, Cancellations & Returns), and
manageRefunds (mcp:admin) refunds them. Both run the site's own packOrder,
dispatchOrder and refundOrder pipelines, so per-site pipeline changes apply. Unlike the REST
admin API, which uses a made-up "system" user, they run as the admin behind the token
(McpActingUser), so the order history names who acted. An action is refused if that user has no
account on the tenant. Refunds have extra guards, because retries and model mistakes move real
money:
- an
idempotencyKeyis required. A retry with the same key replays the first result instead of refunding again. This uses the Redis-backedPosIdempotencyServiceunder themcp-refundscope. If the provider refunded but a later pipeline step failed, the key is kept, so a retry can't refund twice. - the amount can't exceed what's left to refund: the captured amount (or the authorised amount
before capture), minus what has already been refunded.
orders getshowsrefundableAmount. - the
mcp-refund-limitsite config key (payment-config, minor units, default 0 = no limit) caps any single MCP refund. Staff refunds in the admin aren't affected. dryRunpreviews a refund without calling the payment provider.- the refund and its
reasonare written to the order's activity log asREFUNDED_BY_AI_AGENT.
manageCancellations (mcp:admin) cancels a SUBMITTED or PACKING order through
OrderCancellationService and the site's cancelOrder pipeline: it refunds or releases the
payment, restores stock and emails the customer. It reuses the refund guards: a required
idempotencyKey (scope mcp-cancel, keyed to the order), mcp-refund-limit on the refund part,
and dryRun. The key is only freed if the order didn't end up cancelled.
returns (mcp:read) lists and reads returns (RMAs); the customer's name and email on get
need mcp:write. manageReturns (mcp:write) creates, approves (a customer's
PENDING_APPROVAL request), receives and rejects them through
ReturnService. Its refund action also checks for mcp:admin inside the tool, and has the same
guards as manageRefunds: a required idempotencyKey (scope mcp-return-refund),
mcp-refund-limit, dryRun, and the key is kept if money moved.
Like the POS API, idempotency fails open. If Redis is down, the refund goes ahead without a recorded key, and only the balance check and the site limit protect against a duplicate.
Customer service. customers searches accounts by email or name (the query is quoted before
it reaches the Mongo $regex) and returns a profile with the latest orders. Guest checkouts have
no account, so orders list customerEmail matches the email captured on each order
(OrderService.findOrdersByCustomerEmail, exact and case-insensitive). manageOrderEmails
resends the order confirmation or the dispatch email (with its tracking) to the customer, or the
order to the site's fulfilment partners, through OrderEmailResendService. It refuses to send the
same email for an order again within 15 minutes, based on the order's activity log, so a looping
agent can't flood a customer. For the dispatch email, the send made by the dispatch itself counts
too.
Ops inbox and low stock. opsInbox (mcp:read, counts only) returns, in one call:
- orders to pack (SUBMITTED) and to dispatch (PACKING), each with the oldest order's age in hours;
- PAYMENT_FAILED orders created in the last 7 days;
- subscriptions that are
past_dueorunpaid; - returns to approve (PENDING_APPROVAL, asked for by customers), to receive (REQUESTED) and to refund (RECEIVED);
- low-stock SKUs.
Each item names the tool call that shows the detail. The sections are read independently, so a failing source shows as unavailable instead of failing the whole call. The platform's admin notification events (new orders, media uploads, basket adds) are an activity feed, not work to do, so they aren't included.
inventory lowStock lists tracked SKUs at or below a threshold, lowest first. The default
threshold comes from the low-stock-threshold site config key (admin-site-config, default 5).
It uses the new InventoryService.findLowStock. Untracked SKUs (negative stock, meaning in stock
by default) and SKUs with no stock record aren't reported. Inventory backends other than Mongo
inherit a default that reports low stock as unsupported.
What shoppers see. Collection, page and product results carry url, the shopper-facing
address (/collection/…, /p/…, /product/… on the site's domain), so an agent quotes real
links rather than guessing them.
menusmarks the storefront's main navigationmain(thedefault-menu-idsite config key).getreturns the editableitemsandrendered, the links as shoppers see them.manageMenus setItemsreplaces a menu's whole tree, the way the admin menu editor saves it, and refuses a target that doesn't exist, since the storefront silently drops those links.setMainchanges which menu is the main navigation, and deleting the main menu is refused.- Sites created before the current menu model have a legacy menu (
legacy: true).setItemson one writes a current-model menu under the same menuId, which the storefront then renders instead. - New sites now point
default-menu-idat themain-menuthey are created with. Before this, the key defaulted tomainMenu, which no new site has, so new shops had no navigation at all.
manageExtensionConfig refuses a slot the item doesn't render (a block there would never show;
mainSection, for example, doesn't exist on a collection). It stores jsonComponent,
heroConfig and sceneConfig specs as the JSON strings those extensions read, even when they
arrive as objects, and validates jsonComponent with JsonComponentValidator. update can also
move a block (pageLocation) and show or hide it (enabled). extensionConfig lists the blocks
an item inherits from its stereotype, and whether its own blocks replace them.
manageExtensionConfig add and update return a compact result: id, index, extensionName,
pageLocation, enabled, displayPriority and propertySizes (each property's length in
characters). jsonComponent and heroConfig specs run to 5-30KB, and echoing them on every call
made bulk page builds very token-heavy. Pass echoValues: true to get the stored
configurationProperties back; extensionConfig (the read tool) always returns the full values.
extensions getSchema returns real config fields for the extensions that predate the v2
editable-field model too (blogMastheadExtension, faqExtension, the static and scrolling
carousels), through BadgerExtension.getDeclaredConfigFields(). Other legacy extensions still
return a null configSchema until they declare theirs.
Branding. getDesignBrief also returns shop, which holds the shop's name, URL and logo, and
customCss, a summary of the hand-written stylesheet (chars, version), or null if the shop has none.
Pass includeCss: true to get the stylesheet's text in customCss.css too. It is left out by default
because it can be up to 100KB. shop also carries the current theme and the tenant's
productImagePath, read-only: it is the base every media URL (blog cards, mastheads, product
images) is resolved against, so if images 400, check it points at a working image host. Only a
superadmin can change it (System > Tenants, see multi-tenancy), not a site admin, so the MCP does not
either; ask the platform operator.
manageBranding (mcp:write) has these actions:
setDesignBriefdeep-merges the sections it is given into the current brief (or the defaults), checks that palette colours are hex, and saves throughDesignBriefService.saveBrief, which recompiles the tenant's theme CSS.setIdentitychanges the shop name and logo throughSiteManagementService.updateSite. The domain and live/demo mode are deliberately not exposed; they stay in the admin UI.listThemesreturns the storefront themes (name, family, description, bestFor) and the current one.setTheme(themeName) switches the storefront theme. It goes through the same site update the admin tenant editor uses, after checking the name against the theme catalogue, and needs the same authority as the admin (mcp:write, which only admin-level roles are granted). It returns the previous and new theme. The admin's own selector page also keeps nav settings per theme; those are not touched.setCustomCssreplaces the whole of the tenant's hand-written stylesheet ($cssOverride$/global.css). It is the same file the admin CSS editor (Shop Settings > CSS Editor) edits, and it goes through the sameCustomCssService.GlobalCSSThemeExtensionstill links it after the Design Brief'stheme.css, so authored rules win. An emptycssremoves the stylesheet. Every save bumpscss-override-version, which busts the five-day browser cache on/dynamic/css/global.css?v=N. Shoppers see the change once the extension's five-minute per-site cache expires. The tool refuses a stylesheet over 100,000 characters. It also refuses `