Skip to content

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:

spring:
  ai:
    mcp:
      server:
        protocol: STATELESS   # web/src/main/resources/application.yml

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.

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 needs mcp:write;
  • the MCP tool annotations are truthful. Read tools set readOnlyHint = true, and every tool sets openWorldHint = false except manageMedia, whose import fetches a URL the caller supplies (OPEN_WORLD_TOOLS in 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 idempotencyKey is required. A retry with the same key replays the first result instead of refunding again. This uses the Redis-backed PosIdempotencyService under the mcp-refund scope. 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 get shows refundableAmount.
  • the mcp-refund-limit site config key (payment-config, minor units, default 0 = no limit) caps any single MCP refund. Staff refunds in the admin aren't affected.
  • dryRun previews a refund without calling the payment provider.
  • the refund and its reason are written to the order's activity log as REFUNDED_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_due or unpaid;
  • 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.

  • menus marks the storefront's main navigation main (the default-menu-id site config key). get returns the editable items and rendered, the links as shoppers see them.
  • manageMenus setItems replaces 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.
  • setMain changes 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). setItems on one writes a current-model menu under the same menuId, which the storefront then renders instead.
  • New sites now point default-menu-id at the main-menu they are created with. Before this, the key defaulted to mainMenu, 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:

  • setDesignBrief deep-merges the sections it is given into the current brief (or the defaults), checks that palette colours are hex, and saves through DesignBriefService.saveBrief, which recompiles the tenant's theme CSS.
  • setIdentity changes the shop name and logo through SiteManagementService.updateSite. The domain and live/demo mode are deliberately not exposed; they stay in the admin UI.
  • listThemes returns 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.
  • setCustomCss replaces 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 same CustomCssService. GlobalCSSThemeExtension still links it after the Design Brief's theme.css, so authored rules win. An empty css removes the stylesheet. Every save bumps css-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 `