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..eaebc5c --- /dev/null +++ b/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md @@ -0,0 +1,521 @@ +# 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 --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. + +```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" \ + --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 --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" +``` + +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 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 +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" --no-input + ``` + +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. +``` + +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 +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..0c4713b --- /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=%r 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"