From 8f78bef329ca5c723a6b3df25333546d958b05e5 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 4 Nov 2026 15:50:36 +0530 Subject: [PATCH 1/3] feat: add b2b_ecommerce example for auth0-ai-fastmcp --- .../examples/b2b_ecommerce/.env.example | 32 ++ .../examples/b2b_ecommerce/README.md | 516 ++++++++++++++++++ .../b2b_ecommerce/fastmcp_features.py | 48 ++ .../examples/b2b_ecommerce/server.py | 69 +++ .../examples/b2b_ecommerce/token_store.py | 48 ++ .../examples/b2b_ecommerce/tools.py | 122 +++++ 6 files changed, 835 insertions(+) create mode 100644 packages/auth0-ai-fastmcp/examples/b2b_ecommerce/.env.example create mode 100644 packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md create mode 100644 packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py create mode 100644 packages/auth0-ai-fastmcp/examples/b2b_ecommerce/server.py create mode 100644 packages/auth0-ai-fastmcp/examples/b2b_ecommerce/token_store.py create mode 100644 packages/auth0-ai-fastmcp/examples/b2b_ecommerce/tools.py diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/.env.example b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/.env.example new file mode 100644 index 0000000..9ee8e2d --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/.env.example @@ -0,0 +1,32 @@ +# Auth0 tenant domain (no https://, no trailing slash) +AUTH0_DOMAIN=your-tenant.us.auth0.com + +# Optional: switch the server into organization-required mode. +# When set, an incoming MCP token is rejected unless its org_id claim matches this value. +# Leave blank for a non-org deployment. See "Running in B2B org mode" in the README. +AUTH0_ORGANIZATION_ID= + +# Base URL of this server, without /mcp. The MCP API identifier in Auth0 is this value plus /mcp. +# See ../../docs/Configuration.md for why. +MCP_RESOURCE_URL=http://localhost:3002 + +# Downstream service audiences, each a separate API registered on the tenant +ORDERS_AUDIENCE=https://orders.api/ +PAYMENTS_AUDIENCE=https://payments.api/ +INVENTORY_AUDIENCE=https://inventory.api/ +SHIPPING_AUDIENCE=https://shipping.api/ +CUSTOMERS_AUDIENCE=https://customers.api/ +NOTIFICATIONS_AUDIENCE=https://notifications.api/ +ANALYTICS_AUDIENCE=https://analytics.api/ + +# OBO token cache (in-memory in this example) +# At least 32 characters, for example openssl rand -hex 32 +TOKEN_STORE_SECRET= +# Scope-matching mode for the OBO cache: strict or non_strict +SCOPE_MATCHING=strict + +# --- OBO token exchange: a resource-server Custom API client --- +# app_type=resource_server, resource_server_identifier set to MCP_RESOURCE_URL plus /mcp, with the OBO +# exchange profile enabled and user-delegated client grants to the downstream APIs. +OBO_CLIENT_ID= +OBO_CLIENT_SECRET= diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md new file mode 100644 index 0000000..a379809 --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md @@ -0,0 +1,516 @@ +# B2B Ecommerce MCP Server (FastMCP) + +This is an MCP server for a pretend online store whose backend is split into separate services +for orders, payments, inventory, shipping, customers, notifications and analytics. Each service +is its own API in Auth0. When a user calls a tool, the server takes the user's one token and +swaps it for a separate, narrower token for each service it needs to talk to. Real company +backends are built this way, so the example walks through it end to end on a live Auth0 tenant. + +The store itself is fake, so almost every tool just returns a one-line message. The security is real. +Incoming tokens are actually checked, and the per-service tokens actually come from Auth0 +through the on-behalf-of (OBO) token exchange. + +## The services and the tools + +There are seven downstream services. Each one is a separate API with its own audience and +scopes. + +| Service | Audience | Scopes | +|---|---|---| +| Orders | `https://orders.api/` | `cancel:orders`, `read:orders`, `write:orders` | +| Payments | `https://payments.api/` | `refund:payments` | +| Inventory | `https://inventory.api/` | `read:inventory`, `write:inventory` | +| Shipping | `https://shipping.api/` | `read:shipments`, `create:shipments` | +| Customers | `https://customers.api/` | `read:customers`, `write:customers` | +| Notifications | `https://notifications.api/` | `send:notifications` | +| Analytics | `https://analytics.api/` | `read:analytics` (M2M) | + +The server has five tools, and a user needs a matching `tool:*` scope to call each one. Four of +them talk to several services in a single call. The fifth, `sales_analytics_report`, does not +act for the user at all. It gets a machine-to-machine (M2M) token for the server itself. Each +tool asks every service for only the scopes it really needs. A sixth tool, `whoami`, needs no +scope and is covered under [FastMCP features](#fastmcp-features). + +| Tool | Scope needed to call it | Services it calls (scope it asks for) | +|---|---|---| +| `cancel_and_refund_order` | `tool:cancel_and_refund_order` | orders (`cancel:orders`), payments (`refund:payments`) | +| `fulfill_order` | `tool:fulfill_order` | orders (`read:orders`), inventory (`read:inventory write:inventory`), shipping (`read:shipments create:shipments`), notifications (`send:notifications`) | +| `order_status_report` | `tool:order_status_report` | orders (`read:orders`), inventory (`read:inventory`), shipping (`read:shipments`), customers (`read:customers`) | +| `update_customer_and_notify` | `tool:update_customer_and_notify` | customers (`read:customers write:customers`), notifications (`send:notifications`) | +| `sales_analytics_report` | `tool:sales_analytics_report` | analytics (M2M, `read:analytics`) | + +Start with `cancel_and_refund_order`, because it shows the whole idea in one call. If the +user's token lacks `tool:cancel_and_refund_order`, the call is turned away before any +downstream token is requested. Otherwise the server swaps the user's token twice, once for +orders and once for payments. + +## How little auth code lives in the tool + +The SDK takes care of checking the token, publishing the login metadata, swapping tokens, +caching them and checking their scopes. The tool itself reads like ordinary business code. One +decorator checks the caller's scope, and one call per service hands back a token that is ready +to use. + +```python +@mcp.tool +@with_required_scopes("tool:cancel_and_refund_order") +async def cancel_and_refund_order(order_id: str, amount: str) -> str: + auth0 = require_caller_mcp_client() + orders_token = await auth0.get_token_on_behalf_of(audience=ORDERS_AUDIENCE, scope="cancel:orders") + payments_token = await auth0.get_token_on_behalf_of(audience=PAYMENTS_AUDIENCE, scope="refund:payments") + ... +``` + +Here is what happens behind those few lines. + +- Before any handler runs, the auth provider checks the incoming token. It also serves the + RFC 9728 `/.well-known/oauth-protected-resource/mcp` metadata that tells Claude Code how to + log in. A request with a missing or bad token gets a `401` with a `WWW-Authenticate` header + and never reaches a tool. +- The middleware then works out who the caller is (the `Principal`) and makes it available to + the handler for the length of the request. +- `get_token_on_behalf_of` swaps the user's token for a service token and caches it under the + scopes Auth0 actually granted. If Auth0 grants fewer scopes than the tool asked for, the call + raises, so a tool never quietly runs with too little access. Reusing a cached token is safe + for the same reason, because its scopes are checked again every time. +- `with_required_scopes` checks the caller's scopes before the tool body runs and raises + `InsufficientScopeError` if one is missing. + +You can leave out `scope`. Auth0 then returns a token with no `scope`, and in `non_strict` mode +such a call never reuses a cached token. + +## The wiring + +The package ships the auth provider and the middleware, so the server only has to mount them. +This is from `server.py`: + +```python +# FastMCP appends /mcp to resource_url, and the audience defaults to that URL. +auth0 = Auth0FastMCP( + Auth0FastMCPOptions( + domain=DOMAIN, + resource_url=RESOURCE_URL, + tool_scopes=TOOL_SCOPES, + client_id=OBO_CLIENT_ID, + client_secret=OBO_CLIENT_SECRET, + token_store=store, + scope_matching=SCOPE_MATCHING, + organization_policy="required" if ORGANIZATION_ID else "allow", + organization_id=ORGANIZATION_ID, + ) +) + +mcp = FastMCP(name="B2B Ecommerce MCP Server", auth=auth0.provider) +mcp.add_middleware(auth0.middleware) +mcp.add_middleware(CallerAuditMiddleware()) +``` + +FastMCP runs middleware in the order it is added, so `auth0.middleware` goes first and later middleware can already read the caller. + +## FastMCP features + +`fastmcp_features.py` shows the parts of FastMCP that work with the Auth0 request context. + +- `CallerAuditMiddleware` is a normal FastMCP `Middleware`. It logs each tool call with the caller's `sub` from `get_caller_principal()`. +- `whoami` is a tool that returns the caller's `sub`, scopes and `org_id` from the `Principal`, plus the token expiry. +- `store://catalog/summary` is a resource and `fulfillment_checklist` is a prompt. Each calls `require_scopes(get_caller_principal(), ...)` at the top of the handler, which does the same check as `with_required_scopes`. A caller without the scope gets `Missing one or more required scopes.` + +## The OBO token cache + +Every `get_token_on_behalf_of` call swaps the user's token for a service token at Auth0, which +costs a network round trip. With a cache, the SDK keeps each token it gets and hands it back the +next time a tool asks for the same service and scopes with the same user access token. A new +login gives a new access token, so it starts with an empty cache. Caching is on when you pass a +`token_store` to `Auth0FastMCPOptions`, and off without one. This example keeps tokens in memory +(`token_store.py`), so a restart loses them. [Token Caching](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md) explains what a shared, +production store needs. + +The `scope_matching` option on `Auth0FastMCPOptions` decides when a saved token is good enough to hand back. +This example reads it from `SCOPE_MATCHING` in `.env`. + +The two modes, their trade-offs and the store each one needs are in [Configuration](../../docs/Configuration.md#scope-matching-for-cached-obo-tokens). With `non_strict`, this example switches to `InMemoryIndexedTokenStore` on its own. + +## What you need on the tenant + +You will register eight APIs, one for the MCP server itself and one per downstream service, and +two clients. Every step uses the Auth0 CLI against your own tenant. Swap in your own ids where +the commands say `YOUR_...`. + +> **Log in to the Auth0 CLI with extra scopes first.** A plain `auth0 login` cannot run every +> command below. Log in once like this before you start. +> +> ```bash +> auth0 login --scopes create:client_grants,create:organization_clients,create:organization_members,update:tenant_settings,update:connections +> ``` +> +> If a command still fails with `access token lacks scope: `, run +> `auth0 login --scopes ` and try the command again. + +### 1. Register the APIs + +The MCP server's own API is the one Claude Code's login token is issued for, so its identifier +has to be `MCP_RESOURCE_URL` plus `/mcp`. + +```bash +auth0 apis create --name "B2B Ecommerce MCP" --identifier "http://localhost:3002/mcp" --signing-alg RS256 +auth0 apis create --name "Orders Service" --identifier "https://orders.api/" --signing-alg RS256 --scopes "cancel:orders,read:orders,write:orders" +auth0 apis create --name "Payments Service" --identifier "https://payments.api/" --signing-alg RS256 --scopes "refund:payments" +auth0 apis create --name "Inventory Service" --identifier "https://inventory.api/" --signing-alg RS256 --scopes "read:inventory,write:inventory" +auth0 apis create --name "Shipping Service" --identifier "https://shipping.api/" --signing-alg RS256 --scopes "read:shipments,create:shipments" +auth0 apis create --name "Customers Service" --identifier "https://customers.api/" --signing-alg RS256 --scopes "read:customers,write:customers" +auth0 apis create --name "Notifications Service" --identifier "https://notifications.api/" --signing-alg RS256 --scopes "send:notifications" +``` + +Then add the five tool scopes to the MCP API, using the id from its create output. + +```bash +auth0 api patch "resource-servers/YOUR_MCP_API_ID" --data '{"scopes":[ + {"value":"tool:cancel_and_refund_order","description":"Cancel and refund an order"}, + {"value":"tool:fulfill_order","description":"Fulfill an order"}, + {"value":"tool:order_status_report","description":"Read an order status report"}, + {"value":"tool:update_customer_and_notify","description":"Update a customer and notify"}, + {"value":"tool:sales_analytics_report","description":"Run the sales analytics report"} +]}' +``` + +### 2. Create the OBO client + +Token swapping only works through a Custom API client, which is a client with `app_type` set to +`resource_server` and tied to the MCP server's API. + +```bash +auth0 api post clients --data '{ + "name": "B2B Ecommerce OBO Client", + "app_type": "resource_server", + "resource_server_identifier": "http://localhost:3002/mcp" +}' +``` + +A Regular Web App will not work here. Trying it fails with +`This client is not a resource server and cannot exchange access tokens`. + +The create output includes the client id. Auth0 CLI v1.38.0 and later remove `client_secret` from +`auth0 api` output, so read the secret for `OBO_CLIENT_SECRET` with +`auth0 api get "clients/YOUR_OBO_CLIENT_ID" --reveal-secrets`. + +Next, turn on the OBO exchange for that client. OBO token exchange must be enabled on the +exchange client, as described in the +[Auth0 On-Behalf-Of token exchange docs](https://auth0.com/docs/secure/call-apis-on-users-behalf/on-behalf-of-token-exchange). + +```bash +auth0 api patch "clients/YOUR_OBO_CLIENT_ID" \ + --data '{"token_exchange":{"allow_any_profile_of_type":["on_behalf_of_token_exchange"]}}' +``` + +Now give the OBO client access to every downstream API on behalf of users, which is why +`subject_type` is `user`. Give each service exactly the scopes its tools ask for. + +```bash +auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://orders.api/","scope":["cancel:orders","read:orders","write:orders"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://payments.api/","scope":["refund:payments"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://inventory.api/","scope":["read:inventory","write:inventory"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://shipping.api/","scope":["read:shipments","create:shipments"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://customers.api/","scope":["read:customers","write:customers"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://notifications.api/","scope":["send:notifications"],"subject_type":"user"}' +``` + +If these fail with `access token lacks scope: create:client_grants`, the CLI is missing the +login scopes from the top of [What you need on the tenant](#what-you-need-on-the-tenant). Log +in again with them and rerun the grants. + +Last, allow the OBO client to swap tokens for each downstream API without user consent. The +swap is refused with a `400` unless this is on. Run this on each downstream API with its id. + +```bash +auth0 api patch "resource-servers/YOUR_ORDERS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +auth0 api patch "resource-servers/YOUR_PAYMENTS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +auth0 api patch "resource-servers/YOUR_INVENTORY_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +auth0 api patch "resource-servers/YOUR_SHIPPING_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +auth0 api patch "resource-servers/YOUR_CUSTOMERS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +auth0 api patch "resource-servers/YOUR_NOTIFICATIONS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +``` + +Without it the swap fails with +`The API must have "Allow Skipping User Consent for Verifiable First-Party Clients" enabled`, +which is the Dashboard name for this same setting. + +### 3. Register Claude Code as a client + +Claude Code does not register itself on the fly. It identifies itself with a Client ID Metadata +Document (CIMD), which is a public URL it uses as its client id, so you register that URL on +your tenant. First turn on Settings > Advanced > `Client ID Metadata Document Registration`, +then run these two calls. + +```bash +auth0 api post "clients/cimd/preview" --data '{"external_client_id":"https://claude.ai/oauth/claude-code-client-metadata"}' +auth0 api post "clients/cimd/register" --data '{"external_client_id":"https://claude.ai/oauth/claude-code-client-metadata"}' +``` + +The `preview` call only fetches and checks the document, so it is safe to run first. +`register` creates the client and returns a client id that starts with `tpc_`. If you skip +this, the login fails with +`invalid_request: Unknown client: https://claude.ai/oauth/claude-code-client-metadata`. + +Then give that `tpc_` client access to the MCP API with all five tool scopes, using the id from +the register output. + +```bash +auth0 api post client-grants --data '{"client_id":"YOUR_TPC_CLIENT_ID","audience":"http://localhost:3002/mcp","scope":["tool:cancel_and_refund_order","tool:fulfill_order","tool:order_status_report","tool:update_customer_and_notify","tool:sales_analytics_report"],"subject_type":"user"}' +``` + +This grant is easy to forget. Without it the login never starts. Auth0 shows an error page with +`invalid_request: Client "YOUR_TPC_CLIENT_ID" is not authorized to access resource server "YOUR_MCP_AUDIENCE"`, +so Claude Code never gets a token. Third-party (`tpc_`) clients never get an API's allow-all +access, so they always need their own grant. + +Two more tenant settings are needed for the Claude Code login. The resource parameter profile +must be `compatibility`, which is off by default, and the connection your test user signs in +with must be domain-level, because a `tpc_` client only sees domain-level connections. + +```bash +auth0 api patch "tenants/settings" --data '{"resource_parameter_profile":"compatibility"}' +auth0 api get connections +auth0 api patch "connections/YOUR_CONNECTION_ID" --data '{"is_domain_connection":true}' +``` + +The profile is a tenant-wide setting, so run these on a development tenant and test your other +applications afterwards. + +### 4. Set up the analytics API for the M2M tool + +`sales_analytics_report` gets its token with client credentials instead of swapping the user's +token. Create the analytics API and let the OBO client use client credentials against it. + +```bash +# Create the API +auth0 apis create --name "Analytics Service" \ + --identifier "https://analytics.api/" \ + --signing-alg RS256 \ + --scopes "read:analytics" + +# Let the OBO client use client credentials against it +auth0 api post "client-grants" \ + --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://analytics.api/","scope":["read:analytics"]}' +``` + +The OBO client also needs `client_credentials` in its grant types. The CLI's `apps update` has +no option for that, so use the Management API. A PATCH replaces the whole list, so look at the +current list first. + +```bash +auth0 api get "clients/YOUR_OBO_CLIENT_ID?fields=grant_types" +``` + +Then send back that same list with `client_credentials` added. For example, if the list was `authorization_code` and `refresh_token`: + +```bash +auth0 api patch "clients/YOUR_OBO_CLIENT_ID" \ + --data '{"grant_types":["authorization_code","refresh_token","client_credentials"]}' +``` + +## Configure and run + +Copy `.env.example` to `.env` and fill it in. + +```bash +cp .env.example .env +``` + +``` +AUTH0_DOMAIN=your-tenant.us.auth0.com +AUTH0_ORGANIZATION_ID= +MCP_RESOURCE_URL=http://localhost:3002 +ORDERS_AUDIENCE=https://orders.api/ +PAYMENTS_AUDIENCE=https://payments.api/ +INVENTORY_AUDIENCE=https://inventory.api/ +SHIPPING_AUDIENCE=https://shipping.api/ +CUSTOMERS_AUDIENCE=https://customers.api/ +NOTIFICATIONS_AUDIENCE=https://notifications.api/ +ANALYTICS_AUDIENCE=https://analytics.api/ +TOKEN_STORE_SECRET= +SCOPE_MATCHING=strict +OBO_CLIENT_ID= +OBO_CLIENT_SECRET= +``` + +Run `poetry install` in the package folder, then start the server from the example folder. + +```bash +poetry install +cd examples/b2b_ecommerce && poetry run python server.py +``` + +Add the server to Claude Code. The URL has to end in `/mcp`, because that is where the server +listens. Pointing Claude Code at `http://localhost:3002` alone fails with +`MCP endpoint not found`. + +```bash +claude mcp add --transport http b2b-ecommerce http://localhost:3002/mcp +``` + +## Adding a new downstream service and tool + +Say you want a `returns` service and a `process_return` tool that reads orders and writes a +return. These are the steps. + +1. Register the service API and its scopes. + + ```bash + auth0 apis create --name "Returns Service" --identifier "https://returns.api/" --signing-alg RS256 --scopes "read:returns,write:returns" + ``` + +2. Give the OBO client access to it on behalf of users, with the scopes the tool will ask for. + + ```bash + auth0 api post client-grants --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://returns.api/","scope":["read:returns","write:returns"],"subject_type":"user"}' + ``` + +3. Allow token swaps on the new API without user consent. + + ```bash + auth0 api patch "resource-servers/YOUR_RETURNS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' + ``` + +4. Add the tool's scope to the MCP API. A PATCH replaces the whole `scopes` list, so read the + current list and send it back with the new scope added. + + ```bash + SCOPES=$(auth0 api get "resource-servers/YOUR_MCP_API_ID?fields=scopes" | jq -c '.scopes + [{"value":"tool:process_return","description":"Process a return"}]') + auth0 api patch "resource-servers/YOUR_MCP_API_ID" --data "{\"scopes\":$SCOPES}" + ``` + +5. Update the `tpc_` client's grant to include the new scope. The new scope list replaces the + old one, so send every existing `tool:*` scope plus `tool:process_return`. + +6. Add `RETURNS_AUDIENCE=https://returns.api/` to `.env` and read it in the tools file. + +7. Write the tool. Check the scope, then ask for one token per service it calls. + + ```python + @mcp.tool(name="process_return", title="Process Return") + @with_required_scopes("tool:process_return") + async def process_return(order_id: str) -> str: + auth0 = require_caller_mcp_client() + orders_token = await auth0.get_token_on_behalf_of(audience=ORDERS_AUDIENCE, scope="read:orders") + returns_token = await auth0.get_token_on_behalf_of(audience=RETURNS_AUDIENCE, scope="read:returns write:returns") + ... + ``` + +8. Add `tool:process_return` to `TOOL_SCOPES` in `server.py` so Claude Code asks for it at login. + +## Running in B2B org mode + +Setting `AUTH0_ORGANIZATION_ID` in `.env` makes the server accept only users from that one +organization. A token with no `org_id` claim is turned away, and so is a token whose `org_id` +is a different organization. The setup below gets Auth0 to put the right `org_id` in the token +when the user logs in. You only do it once per tenant. + +**1. Let the OBO client work with organizations.** + +```bash +auth0 api patch "clients/YOUR_OBO_CLIENT_ID" \ + --data '{"organization_usage":"allow"}' +``` + +OBO clients do not have this by default, and it only matters in org mode. A user's token in +org mode carries an `org_id`, so the swap happens inside that organization, and a client +without `organization_usage` set to `allow` is refused with a `400` and +`parameter organization is not allowed for this client`. + +**2. Add your test user to the organization.** + +```bash +auth0 api post "organizations/YOUR_ORG_ID/members" --data '{"members":["YOUR_USER_ID"]}' +``` + +Keep the quotes, because a user id contains a `|`. The call returns no body, so check it with +`auth0 orgs members list YOUR_ORG_ID`. + +**3. Make the Claude Code client require an organization at login.** + +```bash +auth0 api patch "clients/YOUR_TPC_CLIENT_ID" \ + --data '{"organization_usage":"require","organization_require_behavior":"post_login_prompt"}' +``` + +With this, Auth0 asks the user to pick the organization after they log in, and the token comes +back with `org_id`. Claude Code does not need to know anything about Auth0 organizations. + +**4. Add the Claude Code client to the organization.** + +```bash +auth0 api post "organizations/YOUR_ORG_ID/clients" \ + --data '{"clients":[{"client_id":"YOUR_TPC_CLIENT_ID","use_for_member_access":true}]}' +``` + +The user being a member is not enough on its own, because the client has to be added to the +organization as well. Without this step the login fails with `client requires organization +membership, but user does not belong to any organization`, even for a real member. + +You do not have to pass the organization when swapping tokens. Auth0 copies `org_id` from the +user's token into every downstream token on its own. + +## Troubleshooting + +### A new scope never shows up in the token + +Adding a scope to the MCP API does not add it to existing grants, and Auth0 silently leaves a +scope out of the token when the client's grant does not include it. Update the `tpc_` client's +grant with the full list. + +```bash +auth0 api patch "client-grants/YOUR_GRANT_ID" \ + --data '{"scope":["existing:scope","new:scope"]}' +# find the grant id: auth0 api get "client-grants?client_id=YOUR_TPC_CLIENT_ID&audience=YOUR_AUDIENCE" +``` + +### A tool call fails with an error + +The client may only see a short message, so read the reason in the server terminal. It is +usually one of these. + +- A scope is missing. The message is `Missing one or more required scopes.` +- Auth0 refused the token swap, for example `Client "YOUR_OBO_CLIENT_ID" is not authorized to access resource server ...` + because the OBO client has no grant for that service. +- Auth0 granted fewer scopes than the tool asked for. + +A token keeps the scopes it was issued with, so after you change a grant or `TOOL_SCOPES`, log +in again with `/mcp`. + +### Every request gets a 401 + +Make sure `MCP_RESOURCE_URL` does not end in `/mcp`. FastMCP adds `/mcp` itself, so the server +would advertise a `/mcp/mcp` resource. The audience follows that value and no longer matches the +API registered in Auth0. MCP clients then never get a token the server accepts. +[Configuration](../../docs/Configuration.md) explains how the two values relate. + +### Tokens still carry `org_id` after leaving org mode + +Step 3 of org mode set the `tpc_` client's `organization_usage` to `require`, and with that +every token carries `org_id` even when `AUTH0_ORGANIZATION_ID` is empty. Set it back to `allow` +before running without an organization. + +## Test the full example + +With the server running and added to Claude Code, try these prompts in order. + +``` +Use the b2b-ecommerce MCP server to cancel and refund order ORD-1042, amount 250 USD. +Use b2b-ecommerce to fulfill order ORD-1042. +Use b2b-ecommerce to run an order status report for ORD-1042. +Use b2b-ecommerce to update customer CUST-77 and notify them. +Use b2b-ecommerce to run the sales analytics report. +Use b2b-ecommerce to run an order status report for ORD-1042 again. +``` + +The first prompt opens a browser to log in and approve access. After that the tool runs, and +behind the scenes the server swaps your one token for an orders token and a payments token. +The status prompt reuses the orders token from the fulfill prompt. The analytics prompt is +the M2M tool, so it runs without your token. The repeated status report comes straight from the +cache. + +The cache is kept per access token, not per user, so a new login starts with an empty cache. To +see the swaps, count the `POST .../oauth/token` lines in the server log. + +To try org mode, set `AUTH0_ORGANIZATION_ID`, restart the server and run the first prompt again. +[Running in B2B org mode](#running-in-b2b-org-mode) has the setup. diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py new file mode 100644 index 0000000..769b1e6 --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py @@ -0,0 +1,48 @@ +"""FastMCP features that sit on top of the Auth0 request context: a middleware, a resource and a prompt.""" + +import logging + +from auth0_ai_mcp import get_caller_principal, require_caller_principal +from fastmcp import FastMCP +from fastmcp.server.middleware import Middleware, MiddlewareContext + +from auth0_ai_fastmcp import require_scopes + +logger = logging.getLogger("audit") + + +class CallerAuditMiddleware(Middleware): + """Logs who called each tool. Add it after auth0.middleware so the caller is already set.""" + + async def on_call_tool(self, context: MiddlewareContext, call_next): + principal = get_caller_principal() + logger.info("tool=%s caller=%s", context.message.name, principal.sub if principal else None) + return await call_next(context) + + +def register_features(mcp: FastMCP) -> None: + @mcp.tool( + name="whoami", + description="Returns the caller's subject, scopes and organization from the verified token.", + annotations={"readOnlyHint": True}, + ) + async def whoami() -> dict: + principal = require_caller_principal() + return { + "sub": principal.sub, + "scopes": principal.scopes, + "org_id": principal.org_id, + "expires_at": principal.expires_at, + } + + @mcp.resource("store://catalog/summary") + async def catalog_summary() -> str: + """Catalog summary for callers who may run order status reports.""" + require_scopes(get_caller_principal(), "tool:order_status_report") + return "12 product lines across 3 warehouses (demo data)" + + @mcp.prompt + async def fulfillment_checklist(order_id: str) -> str: + """Checklist prompt for callers who may fulfill orders.""" + require_scopes(get_caller_principal(), "tool:fulfill_order") + return f"Confirm stock, create the shipment and notify the customer for order {order_id}." diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/server.py b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/server.py new file mode 100644 index 0000000..6f90e3a --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/server.py @@ -0,0 +1,69 @@ +"""B2B ecommerce MCP server on Prefect FastMCP, secured with Auth0 through auth0-ai-fastmcp.""" + +import logging +import os + +from dotenv import load_dotenv +from fastmcp import FastMCP +from fastmcp_features import CallerAuditMiddleware, register_features +from token_store import InMemoryIndexedTokenStore, InMemoryTokenStore + +from auth0_ai_fastmcp import Auth0FastMCP, Auth0FastMCPOptions + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s") + +DOMAIN = os.environ["AUTH0_DOMAIN"] +RESOURCE_URL = os.environ["MCP_RESOURCE_URL"] +ORGANIZATION_ID = os.environ.get("AUTH0_ORGANIZATION_ID") or None +SCOPE_MATCHING = os.environ.get("SCOPE_MATCHING", "strict") +TOKEN_STORE_SECRET = os.environ["TOKEN_STORE_SECRET"] +OBO_CLIENT_ID = os.environ.get("OBO_CLIENT_ID") +OBO_CLIENT_SECRET = os.environ.get("OBO_CLIENT_SECRET") + +# Fail at startup, otherwise the first tool call fails with a generic error. +if not OBO_CLIENT_ID or not OBO_CLIENT_SECRET: + raise SystemExit("Set OBO_CLIENT_ID and OBO_CLIENT_SECRET in .env, see step 2 of the README.") + +# Advertised in the RFC 9728 metadata so the MCP client requests them at login. +TOOL_SCOPES = [ + "tool:cancel_and_refund_order", + "tool:fulfill_order", + "tool:order_status_report", + "tool:update_customer_and_notify", + "tool:sales_analytics_report", +] + +# non_strict needs an index-backed store. strict works with the plain store. +store_class = InMemoryIndexedTokenStore if SCOPE_MATCHING == "non_strict" else InMemoryTokenStore +store = store_class(secret=TOKEN_STORE_SECRET) + +# FastMCP appends /mcp to resource_url, and the audience defaults to that URL. +auth0 = Auth0FastMCP( + Auth0FastMCPOptions( + domain=DOMAIN, + resource_url=RESOURCE_URL, + tool_scopes=TOOL_SCOPES, + client_id=OBO_CLIENT_ID, + client_secret=OBO_CLIENT_SECRET, + token_store=store, + scope_matching=SCOPE_MATCHING, + organization_policy="required" if ORGANIZATION_ID else "allow", + organization_id=ORGANIZATION_ID, + ) +) + +mcp = FastMCP(name="B2B Ecommerce MCP Server", auth=auth0.provider) +mcp.add_middleware(auth0.middleware) +mcp.add_middleware(CallerAuditMiddleware()) + +# Imported here because tools.py reads its audience variables at import time. +from tools import register_tools # noqa: E402 + +register_tools(mcp) +register_features(mcp) + + +if __name__ == "__main__": + mcp.run(transport="http", host="127.0.0.1", port=3002) diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/token_store.py b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/token_store.py new file mode 100644 index 0000000..0317a6d --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/token_store.py @@ -0,0 +1,48 @@ +"""In-memory token stores for the example. + +Tokens are lost on restart, and an expired entry is only dropped when its own key is read, so +memory grows with each login. Fine for a demo, use a shared store with a TTL in production. +""" + +import time + +from auth0_ai_mcp import AbstractTokenStore, IndexedTokenStore, TokenIndexMember, TokenSet + + +class InMemoryTokenStore(AbstractTokenStore): + """Keeps encrypted tokens in a dict. Enough for scope_matching="strict".""" + + def __init__(self, *, secret: str) -> None: + super().__init__(secret=secret) + self._tokens: dict[str, tuple[str, int]] = {} + + async def get(self, key: str) -> TokenSet | None: + entry = self._tokens.get(key) + if entry is None: + return None + encrypted, expires_at = entry + if expires_at <= int(time.time()): + del self._tokens[key] + return None + return self.decrypt(key, encrypted) + + async def set(self, key: str, value: TokenSet) -> None: + self._tokens[key] = (self.encrypt(key, value), value["expires_at"]) + + async def delete(self, key: str) -> None: + self._tokens.pop(key, None) + + +class InMemoryIndexedTokenStore(InMemoryTokenStore, IndexedTokenStore): + """Adds the token index that scope_matching="non_strict" requires.""" + + def __init__(self, *, secret: str) -> None: + super().__init__(secret=secret) + self._indexes: dict[str, dict[str, TokenIndexMember]] = {} + + async def add_index_member(self, index_key: str, member: TokenIndexMember) -> None: + # No await between read and write, so this is atomic on one event loop. Members are never pruned. + self._indexes.setdefault(index_key, {})[member["token_key"]] = member + + async def list_index_members(self, index_key: str) -> list[TokenIndexMember]: + return list(self._indexes.get(index_key, {}).values()) diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/tools.py b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/tools.py new file mode 100644 index 0000000..03f20f4 --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/tools.py @@ -0,0 +1,122 @@ +"""Tools for the B2B ecommerce server. + +The flagship `cancel_and_refund_order` spans two services. It gates on the caller's +incoming scopes, then mints one downstream token per service. Each token comes from +`get_token_on_behalf_of`, which reuses a cached token when a token store is configured. +""" + +import os + +from auth0_ai_mcp import require_caller_mcp_client +from fastmcp import FastMCP + +from auth0_ai_fastmcp import with_required_scopes + +ORDERS_AUDIENCE = os.environ["ORDERS_AUDIENCE"] +PAYMENTS_AUDIENCE = os.environ["PAYMENTS_AUDIENCE"] +INVENTORY_AUDIENCE = os.environ["INVENTORY_AUDIENCE"] +SHIPPING_AUDIENCE = os.environ["SHIPPING_AUDIENCE"] +CUSTOMERS_AUDIENCE = os.environ["CUSTOMERS_AUDIENCE"] +NOTIFICATIONS_AUDIENCE = os.environ["NOTIFICATIONS_AUDIENCE"] +ANALYTICS_AUDIENCE = os.environ["ANALYTICS_AUDIENCE"] + + +def register_tools(mcp: FastMCP) -> None: + @mcp.tool( + name="cancel_and_refund_order", + title="Cancel and Refund Order", + description="Cancels an order and refunds its payment in one step.", + annotations={"destructiveHint": True}, + ) + @with_required_scopes("tool:cancel_and_refund_order") + async def cancel_and_refund_order(order_id: str, amount: str) -> str: + auth0 = require_caller_mcp_client() + orders_token = await auth0.get_token_on_behalf_of( + audience=ORDERS_AUDIENCE, scope="cancel:orders" + ) + payments_token = await auth0.get_token_on_behalf_of( + audience=PAYMENTS_AUDIENCE, scope="refund:payments" + ) + _ = (orders_token, payments_token) + return f"Order {order_id} cancelled and {amount} refunded (demo, no real change made)" + + @mcp.tool( + name="fulfill_order", + title="Fulfill Order", + description="Reserves inventory, creates a shipment, and sends a fulfillment notification.", + annotations={"destructiveHint": True}, + ) + @with_required_scopes("tool:fulfill_order") + async def fulfill_order(order_id: str) -> str: + auth0 = require_caller_mcp_client() + orders_token = await auth0.get_token_on_behalf_of( + audience=ORDERS_AUDIENCE, scope="read:orders" + ) + inventory_token = await auth0.get_token_on_behalf_of( + audience=INVENTORY_AUDIENCE, scope="read:inventory write:inventory" + ) + shipping_token = await auth0.get_token_on_behalf_of( + audience=SHIPPING_AUDIENCE, scope="read:shipments create:shipments" + ) + notifications_token = await auth0.get_token_on_behalf_of( + audience=NOTIFICATIONS_AUDIENCE, scope="send:notifications" + ) + _ = (orders_token, inventory_token, shipping_token, notifications_token) + return f"Order {order_id} fulfilled (demo, no real change made)" + + @mcp.tool( + name="order_status_report", + title="Order Status Report", + description="Returns a summary of order, inventory, shipment, and customer status.", + annotations={"readOnlyHint": True}, + ) + @with_required_scopes("tool:order_status_report") + async def order_status_report(order_id: str) -> str: + auth0 = require_caller_mcp_client() + orders_token = await auth0.get_token_on_behalf_of( + audience=ORDERS_AUDIENCE, scope="read:orders" + ) + inventory_token = await auth0.get_token_on_behalf_of( + audience=INVENTORY_AUDIENCE, scope="read:inventory" + ) + shipping_token = await auth0.get_token_on_behalf_of( + audience=SHIPPING_AUDIENCE, scope="read:shipments" + ) + customers_token = await auth0.get_token_on_behalf_of( + audience=CUSTOMERS_AUDIENCE, scope="read:customers" + ) + _ = (orders_token, inventory_token, shipping_token, customers_token) + return f"Status report for order {order_id} (demo, no real data)" + + @mcp.tool( + name="update_customer_and_notify", + title="Update Customer and Notify", + description="Updates customer data and sends a notification.", + annotations={"destructiveHint": True}, + ) + @with_required_scopes("tool:update_customer_and_notify") + async def update_customer_and_notify(customer_id: str) -> str: + auth0 = require_caller_mcp_client() + customers_token = await auth0.get_token_on_behalf_of( + audience=CUSTOMERS_AUDIENCE, scope="read:customers write:customers" + ) + notifications_token = await auth0.get_token_on_behalf_of( + audience=NOTIFICATIONS_AUDIENCE, scope="send:notifications" + ) + _ = (customers_token, notifications_token) + return f"Customer {customer_id} updated and notified (demo, no real change made)" + + @mcp.tool( + name="sales_analytics_report", + title="Sales Analytics Report", + description="Returns aggregated sales analytics using a service-to-service token.", + annotations={"readOnlyHint": True}, + ) + @with_required_scopes("tool:sales_analytics_report") + async def sales_analytics_report() -> str: + auth0 = require_caller_mcp_client() + token = await auth0.get_client_credentials_token( + audience=ANALYTICS_AUDIENCE, scope="read:analytics" + ) + _ = token + return "Monthly revenue: $142,000 | Top product: Enterprise Suite" From d836e384cb7f6746b6042c57b695541376810da4 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 5 Nov 2026 20:29:39 +0530 Subject: [PATCH 2/3] fix: escape the tool name in the example audit log --- .../auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py index 769b1e6..0c4713b 100644 --- a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/fastmcp_features.py @@ -16,7 +16,7 @@ class CallerAuditMiddleware(Middleware): async def on_call_tool(self, context: MiddlewareContext, call_next): principal = get_caller_principal() - logger.info("tool=%s caller=%s", context.message.name, principal.sub if principal else None) + logger.info("tool=%r caller=%s", context.message.name, principal.sub if principal else None) return await call_next(context) From 78d92a73c6d94231d1e48b562e33437b0c25864a Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Thu, 5 Nov 2026 20:29:39 +0530 Subject: [PATCH 3/3] docs: fix CLI commands and sign-in step in the example README --- .../examples/b2b_ecommerce/README.md | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md index a379809..eaebc5c 100644 --- a/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md @@ -152,13 +152,13 @@ The MCP server's own API is the one Claude Code's login token is issued for, so has to be `MCP_RESOURCE_URL` plus `/mcp`. ```bash -auth0 apis create --name "B2B Ecommerce MCP" --identifier "http://localhost:3002/mcp" --signing-alg RS256 -auth0 apis create --name "Orders Service" --identifier "https://orders.api/" --signing-alg RS256 --scopes "cancel:orders,read:orders,write:orders" -auth0 apis create --name "Payments Service" --identifier "https://payments.api/" --signing-alg RS256 --scopes "refund:payments" -auth0 apis create --name "Inventory Service" --identifier "https://inventory.api/" --signing-alg RS256 --scopes "read:inventory,write:inventory" -auth0 apis create --name "Shipping Service" --identifier "https://shipping.api/" --signing-alg RS256 --scopes "read:shipments,create:shipments" -auth0 apis create --name "Customers Service" --identifier "https://customers.api/" --signing-alg RS256 --scopes "read:customers,write:customers" -auth0 apis create --name "Notifications Service" --identifier "https://notifications.api/" --signing-alg RS256 --scopes "send:notifications" +auth0 apis create --name "B2B Ecommerce MCP" --identifier "http://localhost:3002/mcp" --signing-alg RS256 --no-input +auth0 apis create --name "Orders Service" --identifier "https://orders.api/" --signing-alg RS256 --scopes "cancel:orders,read:orders,write:orders" --no-input +auth0 apis create --name "Payments Service" --identifier "https://payments.api/" --signing-alg RS256 --scopes "refund:payments" --no-input +auth0 apis create --name "Inventory Service" --identifier "https://inventory.api/" --signing-alg RS256 --scopes "read:inventory,write:inventory" --no-input +auth0 apis create --name "Shipping Service" --identifier "https://shipping.api/" --signing-alg RS256 --scopes "read:shipments,create:shipments" --no-input +auth0 apis create --name "Customers Service" --identifier "https://customers.api/" --signing-alg RS256 --scopes "read:customers,write:customers" --no-input +auth0 apis create --name "Notifications Service" --identifier "https://notifications.api/" --signing-alg RS256 --scopes "send:notifications" --no-input ``` Then add the five tool scopes to the MCP API, using the id from its create output. @@ -286,16 +286,18 @@ token. Create the analytics API and let the OBO client use client credentials ag auth0 apis create --name "Analytics Service" \ --identifier "https://analytics.api/" \ --signing-alg RS256 \ - --scopes "read:analytics" + --scopes "read:analytics" \ + --no-input # Let the OBO client use client credentials against it auth0 api post "client-grants" \ --data '{"client_id":"YOUR_OBO_CLIENT_ID","audience":"https://analytics.api/","scope":["read:analytics"]}' ``` -The OBO client also needs `client_credentials` in its grant types. The CLI's `apps update` has -no option for that, so use the Management API. A PATCH replaces the whole list, so look at the -current list first. +The OBO client also needs `client_credentials` in its grant types. The CLI's +`apps update --grants` replaces the whole list and only knows the CLI's own grant names, so this +uses the Management API instead. A PATCH replaces the whole list too, so look at the current list +first. ```bash auth0 api get "clients/YOUR_OBO_CLIENT_ID?fields=grant_types" @@ -348,6 +350,9 @@ listens. Pointing Claude Code at `http://localhost:3002` alone fails with claude mcp add --transport http b2b-ecommerce http://localhost:3002/mcp ``` +Adding the server does not sign you in. Sign in once with `claude mcp login b2b-ecommerce`, or +run `/mcp` inside Claude Code and choose Authenticate. + ## Adding a new downstream service and tool Say you want a `returns` service and a `process_return` tool that reads orders and writes a @@ -356,7 +361,7 @@ return. These are the steps. 1. Register the service API and its scopes. ```bash - auth0 apis create --name "Returns Service" --identifier "https://returns.api/" --signing-alg RS256 --scopes "read:returns,write:returns" + auth0 apis create --name "Returns Service" --identifier "https://returns.api/" --signing-alg RS256 --scopes "read:returns,write:returns" --no-input ``` 2. Give the OBO client access to it on behalf of users, with the scopes the tool will ask for. @@ -503,7 +508,7 @@ Use b2b-ecommerce to run the sales analytics report. Use b2b-ecommerce to run an order status report for ORD-1042 again. ``` -The first prompt opens a browser to log in and approve access. After that the tool runs, and +Once you are signed in, the first prompt runs the tool, and behind the scenes the server swaps your one token for an orders token and a payments token. The status prompt reuses the orders token from the fulfill prompt. The analytics prompt is the M2M tool, so it runs without your token. The repeated status report comes straight from the