diff --git a/packages/auth0-ai-fastmcp/AGENTS.md b/packages/auth0-ai-fastmcp/AGENTS.md new file mode 100644 index 0000000..02cde09 --- /dev/null +++ b/packages/auth0-ai-fastmcp/AGENTS.md @@ -0,0 +1,3 @@ +# AI Agent Guidelines for auth0-ai-fastmcp + +@./CLAUDE.md for all coding guidelines, commands, project structure, code style, testing conventions, and boundaries. diff --git a/packages/auth0-ai-fastmcp/CLAUDE.md b/packages/auth0-ai-fastmcp/CLAUDE.md new file mode 100644 index 0000000..7115759 --- /dev/null +++ b/packages/auth0-ai-fastmcp/CLAUDE.md @@ -0,0 +1,97 @@ +# AI Agent Guidelines for auth0-ai-fastmcp + +## Your Role + +You are a Python SDK engineer on auth0-ai-fastmcp, the adapter that secures a Prefect FastMCP server with Auth0. It verifies Auth0 access tokens through FastMCP's own auth provider and makes the verified caller available to tools, resources, and prompts. It is built on auth0-ai-mcp and owns no verification, exchange, or caching logic. + +--- + +## Working Principles + +- **Think before coding.** State assumptions. When a request is ambiguous, surface the interpretations and ask before building. +- **Simplicity first.** Write the minimum code that solves the stated problem. No speculative options, single-use abstractions, or error handling for impossible states. +- **Surgical changes.** Every changed line traces to the request. Clean up imports your change orphaned, leave pre-existing dead code alone. +- **Verify before done.** Turn the request into a checkable criterion (e.g. "reject X" becomes a failing test for X, then a fix) and run it before claiming success. + +--- + +## Project Structure + +``` +src/auth0_ai_fastmcp/ + __init__.py # Public API: everything in __all__ is the supported surface + config.py # Auth0FastMCPOptions and Auth0FastMCP (builds the client, provider, and middleware) + verifier.py # Auth0TokenVerifier: FastMCP TokenVerifier backed by McpServerClient + middleware.py # Auth0ContextMiddleware: binds the verified caller for every request and shortens failed token exchange errors + scopes.py # require_scopes and with_required_scopes: auth0-ai-mcp checks whose denials are FastMCP ToolErrors + utils/_internal/ # Private helpers by topic (validation, telemetry, outage) +tests/ + test_config.py, test_middleware.py, test_outage.py, test_scopes.py, test_verifier.py, test_docs.py +docs/ # Hand-written guides, linked from README (see references/docs-update.md) +examples/b2b_ecommerce/ # Runnable FastMCP server with its own README +README.md, EXAMPLES.md +pyproject.toml # Poetry manifest and source of truth for dependencies +.version, .shiprc # Release version source (.shiprc keeps .version and pyproject.toml in sync) +.ruff.toml # Lint config (CI-enforced) +``` + +Verification, scope checks, token exchange, caching, and token stores live in auth0-ai-mcp and auth0-api-python. This package wires them into FastMCP and does not reimplement them. Before changing a flow, read its row in [references/flow-map.md](references/flow-map.md). + +--- + +## Boundaries + +### Always Do + +- Run `poetry run pytest` and `poetry run ruff check .` before committing. +- Make surgical changes. Touch only what the request requires. Do not refactor or reformat adjacent code. +- Follow existing style and naming. Private helpers go in `utils/_internal/` and public modules call them through the module, never by imported name. +- Add unit tests for new functionality. Async tests need no marker because `asyncio_mode = "auto"` is set. Stand in for `McpServerClient` with a `Stub...` class, as `tests/test_verifier.py` does (no real network calls). +- Use the public names auth0-ai-mcp exports (`build_principal`, `bind_request_context`, and so on). Do not import from its private modules. +- Export any new public symbol through `__init__.py` `__all__`. +- Update `README.md`, `EXAMPLES.md`, the matching `docs/*.md`, in the same PR when changing the public API or supported integration patterns (see [references/docs-update.md](references/docs-update.md)). +- Keep code compatible with Python 3.10 (`target-version = "py310"`). +- Put imports at the top of the module. The `PLC0415` lint rule rejects imports inside functions. + +### Ask First + +- Any breaking change to a version that has already shipped, such as a renamed or removed public symbol, a changed signature or default, or a raised Python floor. +- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`), including the `fastmcp` range and the auth0-ai-mcp reference. +- Changes to how a token is verified, how the caller is built, or which requests need a token. +- Changes to CI/CD configuration, `.ruff.toml` rule selection, or `.snyk` suppressions. + +### Never Do + +- Commit secrets, client secrets, real tokens, or private keys. Examples read tenant values from environment variables and `.env` stays untracked. +- Hand-edit `poetry.lock` or `CHANGELOG.md`, or edit `.version` / the `pyproject.toml` version (the release flow owns them via `.shiprc`). +- Remove or skip a failing test instead of fixing it, or weaken an assertion to make it pass. +- Fail open. A verification failure or a missing token must reject the request, never fall through to a permissive default. +- Hand the raw incoming token to tool code. `RequestScopedMcpClient` closes over it on purpose, so tools read the caller with `get_caller_principal`. FastMCP's `get_access_token()` still holds the raw token because the verifier returns it, so do not point tool authors at it. +- Add a cache or token store to this package. Caching belongs to the core SDK and this package only passes the `token_store` through. +- Run `ruff format .` repo-wide. It is not a CI gate. +- Modify `.venv*/`, `dist/`, `__pycache__/`, `.coverage`, `coverage.xml`. +- Log, print, or include access tokens, exchanged tokens, or client secrets in error messages. + +--- + +## Security Considerations + +- **Verification is delegated:** every token goes through `McpServerClient.verify_access_token`. Never decode a token yourself. +- **Fail closed in the verifier:** `Auth0TokenVerifier.verify_token` returns `None` for a blank token, any token that fails verification, or one that `build_principal` rejects (missing `sub`, `exp`, or client id, malformed `org_id`, or a `permissions` claim that is not a list of strings), and FastMCP answers `401`. If Auth0 cannot be reached, `AuthServerUnavailableError` is re-raised and the provider from `Auth0FastMCP` answers `503` (JSON-RPC code `-32003`) with `Retry-After` and no challenge, so never turn it into `None`. Keep both for any new rejection path. +- **Fail closed in the middleware:** `Auth0ContextMiddleware.on_request` raises `UnauthenticatedError` when a request that needs a caller has no verified token. Only `initialize`, `ping` and `server/discover` are exempt. It rebuilds the principal from the claims the verifier already checked, so it is not an independent verification. +- **Required scopes live on the verifier:** FastMCP reads `required_scopes` from the token verifier, so the verifier must pass them to `TokenVerifier.__init__`. Passing them only to the provider has no effect once a custom verifier is set. +- **Request context is per request:** the context is bound with `bind_request_context`, which resets on exit even when the handler raises. Never set it without that reset, otherwise one caller's identity can leak into the next request. +- **Audience is derived from the advertised resource:** FastMCP appends the MCP path to `resource_url`, so `audience` defaults to `resource_url` plus `mcp_path` and an explicit value that differs raises `ConfigurationError`. +- **Dependency scanning:** Snyk, CodeQL, OpenSSF Scorecard, and ruff `S` (bandit) rules in CI. + +--- + +## Reference Files + +- Commands for install, test, lint, and build: [references/commands.md](references/commands.md) +- Test layout, async mode, and stubbing the client: [references/testing.md](references/testing.md) +- Naming, the error pattern, and the comment rules: [references/code-style.md](references/code-style.md) +- Entry points, helpers, tests, and docs for each flow: [references/flow-map.md](references/flow-map.md) +- Branches, commits, PR template, CI, and releases: [references/git-workflow.md](references/git-workflow.md) +- Traps that cost time: [references/pitfalls.md](references/pitfalls.md) +- Which docs to update for which code change: [references/docs-update.md](references/docs-update.md) diff --git a/packages/auth0-ai-fastmcp/EXAMPLES.md b/packages/auth0-ai-fastmcp/EXAMPLES.md new file mode 100644 index 0000000..9d5aeec --- /dev/null +++ b/packages/auth0-ai-fastmcp/EXAMPLES.md @@ -0,0 +1,79 @@ +# Examples + +This page walks through building a complete Auth0-secured FastMCP server using `Auth0FastMCP`. + +## Full server example + +The example below shows every piece you need: configuring `Auth0FastMCP`, registering the provider and middleware, advertising tool scopes, and gating a tool that mints a downstream token on behalf of the caller. + +```python +from fastmcp import FastMCP +from auth0_ai_fastmcp import ( + Auth0FastMCP, + Auth0FastMCPOptions, + require_scopes, + with_required_scopes, +) +from auth0_ai_mcp import get_caller_principal, require_caller_mcp_client + +# The provider fetches the tenant's OIDC configuration at startup. +auth0 = Auth0FastMCP(Auth0FastMCPOptions( + domain="your-tenant.auth0.com", + resource_url="https://your-api.example.com", + tool_scopes=["tool:read_orders", "tool:place_order", "resource:recent_orders", "prompt:order_summary"], + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", +)) + +mcp = FastMCP(name="My Store Server", auth=auth0.provider) +mcp.add_middleware(auth0.middleware) + + +@mcp.tool +@with_required_scopes("tool:read_orders") +async def read_orders() -> list[dict]: + """Return the caller's orders from the downstream API.""" + client = require_caller_mcp_client() + token = await client.get_token_on_behalf_of( + audience="https://store-api/", + scope="read:orders", + ) + # Use token["access_token"] to call the downstream API. + ... + + +@mcp.tool +@with_required_scopes("tool:place_order") +async def place_order(item_id: str, quantity: int) -> dict: + """Place an order on behalf of the caller.""" + client = require_caller_mcp_client() + token = await client.get_token_on_behalf_of( + audience="https://store-api/", + scope="write:orders", + ) + # Use token["access_token"] to call the downstream API. + ... +``` + +For what each `Auth0FastMCPOptions` field does, see [Configuration](./docs/Configuration.md#auth0fastmcpoptions-fields). + +## Resources and prompts + +The middleware sets the caller for resources and prompts too. Check the scope with `require_scopes` inside the handler, or put `with_required_scopes` on the handler as it is on a tool. The scopes used here are in `tool_scopes` in the full server above, so the MCP client requests them at login. + +```python +@mcp.resource("orders://recent") +async def recent_orders() -> str: + require_scopes(get_caller_principal(), "resource:recent_orders") + return "..." + + +@mcp.prompt +async def order_summary() -> str: + require_scopes(get_caller_principal(), "prompt:order_summary") + return "Summarize the caller's recent orders." +``` + +## Runnable version + +A complete runnable server using this pattern lives in [`examples/b2b_ecommerce`](./examples/b2b_ecommerce/). diff --git a/packages/auth0-ai-fastmcp/README.md b/packages/auth0-ai-fastmcp/README.md index 8837c86..dd35451 100644 --- a/packages/auth0-ai-fastmcp/README.md +++ b/packages/auth0-ai-fastmcp/README.md @@ -4,7 +4,7 @@ It is the FastMCP adapter for [`auth0-ai-mcp`](https://github.com/auth0/auth0-ai ![Release](https://img.shields.io/pypi/v/auth0-ai-fastmcp) ![Downloads](https://img.shields.io/pypi/dw/auth0-ai-fastmcp) [![License](https://img.shields.io/:license-Apache%202.0-blue.svg?style=flat)](https://opensource.org/licenses/Apache-2.0) -🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback) +📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback) ## Getting Started @@ -43,11 +43,13 @@ mcp.add_middleware(auth0.middleware) > [!IMPORTANT] > Both `auth=auth0.provider` and `mcp.add_middleware(auth0.middleware)` are required. Without the middleware, tools cannot read the caller. -The provider checks every incoming token and the middleware makes the verified caller available to your tools, resources and prompts. +The provider checks every incoming token and the middleware makes the verified caller available to your tools, resources and prompts. See [What the middleware does](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-fastmcp/docs/Configuration.md#what-the-middleware-does). > [!WARNING] > Do not add `/mcp` to the end of `resource_url`. FastMCP appends the MCP path itself. +The tenant also needs the `compatibility` resource parameter profile, the scopes added to the API as permissions, and a client grant for the MCP client. See [What you need on the tenant](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-fastmcp/examples/b2b_ecommerce/README.md#what-you-need-on-the-tenant) for the commands. + ### 3. Gate a tool ```python @@ -65,7 +67,23 @@ async def do_thing() -> str: ### 4. Gate a resource or prompt -Resources and prompts are gated the same way. +Resources and prompts are gated the same way. See [Gating resources and prompts](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-fastmcp/docs/ToolScopes.md#gating-resources-and-prompts). + +## Documentation + +- [Configuration](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-fastmcp/docs/Configuration.md) - all `Auth0FastMCPOptions` fields. +- [Tool Scopes](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-fastmcp/docs/ToolScopes.md) - how scopes are advertised at login and how tools, resources and prompts are gated. +- [Examples](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-fastmcp/EXAMPLES.md) - code samples for a full server. The runnable version is [`examples/b2b_ecommerce`](https://github.com/auth0/auth0-ai-python/tree/main/packages/auth0-ai-fastmcp/examples/b2b_ecommerce). + +### Core guides + +These guides cover the behavior this package builds on. In FastMCP code, import the scope helpers from `auth0_ai_fastmcp` and not from `auth0_ai_mcp`. The `auth0_ai_mcp` versions still deny the call, but with `mask_error_details=True` the client sees only `Error calling tool 'x'` and the server logs an ERROR traceback. + +- [On-Behalf-Of](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OnBehalfOf.md) - get a token for a downstream API for the caller. +- [Token Vault](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenVault.md) - get a token for a third-party provider such as Google. +- [Token Caching](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md) - reuse exchanged tokens with a token store. +- [Request Context](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/RequestContext.md) - read the caller inside a tool. +- [Scope Enforcement](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/ScopeEnforcement.md) - check the caller's scopes before a tool runs. ## Feedback diff --git a/packages/auth0-ai-fastmcp/docs/Configuration.md b/packages/auth0-ai-fastmcp/docs/Configuration.md new file mode 100644 index 0000000..7668cec --- /dev/null +++ b/packages/auth0-ai-fastmcp/docs/Configuration.md @@ -0,0 +1,156 @@ +# Configuration + +Everything is set on `Auth0FastMCPOptions`, which you pass once to `Auth0FastMCP(options)` at startup. The `Auth0FastMCP` object then hands back a provider and a middleware for you to mount on your FastMCP server. + +```python +from fastmcp import FastMCP +from auth0_ai_fastmcp import Auth0FastMCP, Auth0FastMCPOptions + +auth0 = Auth0FastMCP( + Auth0FastMCPOptions( + domain="your-tenant.auth0.com", + resource_url="https://your-api.example.com", + tool_scopes=["tool:read_orders"], + ) +) + +mcp = FastMCP(name="My MCP Server", auth=auth0.provider) +mcp.add_middleware(auth0.middleware) +``` + +The domain, resource URL, scope lists, audience, mount path, scope matching and organization settings are checked when `Auth0FastMCP(options)` runs, so a bad value fails at server startup rather than on the first request. `client_id` and `client_secret` are not checked at startup. + +## Auth0FastMCPOptions fields + +- `domain` (str) - Auth0 tenant domain as a bare host with no scheme, path, query or whitespace, for example `"your-tenant.auth0.com"`. An empty value, or one with a scheme, a path, a query, `#`, `@` or whitespace, raises `ConfigurationError`. The OIDC discovery document is fetched when `Auth0FastMCP` is created, so an unreachable domain fails there with a connection error. +- `resource_url` (str) - base URL of this server without the MCP path. See [resource_url and audience](#resource_url-and-audience). +- `audience` (str | None, default `None`) - this server's API identifier in Auth0, which incoming tokens are validated against. Defaults to `resource_url` plus `mcp_path` and must equal it when set. See [resource_url and audience](#resource_url-and-audience). +- `mcp_path` (str, default `"/mcp"`) - path the MCP endpoint is mounted at. See [resource_url and audience](#resource_url-and-audience). +- `tool_scopes` (list[str], default `[]`) - scopes advertised so the MCP client requests them at login. See [Tool Scopes](./ToolScopes.md). +- `client_id` (str | None, default `None`) - client id for token exchanges, needed only when tools call `get_token_on_behalf_of`, `get_client_credentials_token` or `get_token_for_connection`. Leaving them out is not an error at startup. A tool that needs them fails at its first exchange with an error that names the missing credentials. That error is not rewritten, so with `mask_error_details=True` the client sees only `Error calling tool 'x'`. +- `client_secret` (str | None, default `None`) - client secret for the same exchanges. +- `token_store` (AbstractTokenStore | None, default `None`) - backing store for cached OBO tokens. See [Scope matching for cached OBO tokens](#scope-matching-for-cached-obo-tokens). +- `scope_matching` (str, default `"strict"`) - how cached OBO tokens are matched to the scopes a tool asks for, either `"strict"` or `"non_strict"`. See [Scope matching for cached OBO tokens](#scope-matching-for-cached-obo-tokens). +- `organization_policy` (str, default `"allow"`) - whether tokens must carry an organization, either `"allow"` or `"required"`. See [Organization policy](#organization-policy). +- `organization_id` (str | list[str] | None, default `None`) - the organization or organizations accepted when the policy is `"required"`. See [Organization policy](#organization-policy). +- `required_scopes` (list[str] | None, default `None`) - scopes every token must carry. A token missing any of them is rejected with a `403` and `insufficient_scope` before any handler runs. See [Required scopes](#required-scopes), and [Tool Scopes](./ToolScopes.md) for how they are advertised. + +## resource_url and audience + +`resource_url` is the base URL of your server with no MCP path on the end. FastMCP adds the path the MCP endpoint is mounted at (`/mcp` by default) and advertises the result as the resource in the protected-resource metadata. + +The MCP client reads that advertised resource and asks Auth0 for a token for it, so the audience that incoming tokens are validated against has to be the same value. It defaults to `resource_url` plus `mcp_path`, which means `resource_url="https://your-api.example.com"` gives the audience `https://your-api.example.com/mcp`. That value is also the API identifier to register in Auth0. + +The tenant also needs the `compatibility` resource parameter profile, the scopes added to that API as permissions, and a client grant for the MCP client. See [What you need on the tenant](../examples/b2b_ecommerce/README.md#what-you-need-on-the-tenant) for the commands. + +Set `mcp_path` if the server is mounted somewhere other than `/mcp`, and pass the same path to FastMCP. You can set `audience` yourself, but it must equal `resource_url` plus `mcp_path`. Any other value raises `ConfigurationError` at startup, because tokens minted for it would never match the advertised resource. If the path given to `http_app` or `run` differs from `mcp_path`, building the app raises the same error. + +> [!WARNING] +> Do not add `/mcp` to the end of `resource_url`. FastMCP would then advertise `https://your-api.example.com/mcp/mcp`, so the audience becomes that value too and no longer matches the API you registered in Auth0. The client never gets a token the server accepts, and every request gets a `401`. + +## Organization policy + +`organization_policy` is `"allow"` (the default) or `"required"`, and `organization_id` is a single string or a list of strings accepted when the policy is `"required"`. For what each policy accepts, see [Organization Policy](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OrganizationPolicy.md). + +`organization_id` only works with `organization_policy="required"`. Setting it while the policy is `"allow"` raises `ConfigurationError` at startup, and so does an empty string or an empty list. + +```python +Auth0FastMCPOptions( + domain="your-tenant.auth0.com", + resource_url="https://your-api.example.com", + tool_scopes=["tool:read_orders"], + organization_policy="required", + organization_id="org_abc", +) +``` + +The check runs inside `Auth0TokenVerifier`, before any tool, resource or prompt runs. A rejected token gets a `401` with `invalid_token` from FastMCP, the same as any other bad token, so when every request fails in org mode, check the `org_id` in the token first. + +Auth0 copies `org_id` from the user's token into every token from `get_token_on_behalf_of` on its own, so tool code passes nothing extra. For the tenant setting the On-Behalf-Of client needs in organization mode, see [Organization Policy](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OrganizationPolicy.md). For the example tenant setup, see [Running in B2B org mode](../examples/b2b_ecommerce/README.md#running-in-b2b-org-mode). + +## Scope matching for cached OBO tokens + +Caching is off unless you set `token_store`, along with `client_id` and `client_secret` for the On-Behalf-Of exchange. Without a store, every `get_token_on_behalf_of` call does a fresh exchange with Auth0. + +`scope_matching` is `"strict"` (the default) or `"non_strict"` and decides when a saved token is reused. For how each mode matches tokens, see [Scope-matching modes](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md#scope-matching-modes), and for how saved tokens are keyed, see [Cache key](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md#cache-key). + +`"non_strict"` needs a `token_store` that subclasses `IndexedTokenStore`. A plain store, or no `token_store` at all, raises `ConfigurationError` at startup. + +> [!WARNING] +> With `non_strict`, a tool can get a token with more access than it asked for. A tool that asks for `read:inventory` can be handed a saved token that also carries `write:inventory`. Only choose it when that is acceptable for every tool on the server. + +```python +import os + +store = YourIndexedTokenStore(secret=os.environ["TOKEN_STORE_SECRET"]) # your IndexedTokenStore subclass, secret of at least 32 characters + +Auth0FastMCPOptions( + domain="your-tenant.auth0.com", + resource_url="https://your-api.example.com", + tool_scopes=["tool:read_orders"], + client_id="YOUR_OBO_CLIENT_ID", + client_secret="YOUR_OBO_CLIENT_SECRET", + token_store=store, + scope_matching="non_strict", +) +``` + +See [Token Caching](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md) for the store interface, expiry and the index, and a [Redis store example](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md#a-redis-store). + +## Required scopes + +`required_scopes` applies to every request on the server, whatever the tool, resource or prompt. It is separate from `tool_scopes`, which only advertises scopes for the client to ask for at login. For how required scopes are advertised, see [Tool Scopes](./ToolScopes.md). For which token claim scopes are read from, see [Scope Enforcement](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/ScopeEnforcement.md). + +- An invalid, expired or empty token is rejected by `Auth0TokenVerifier`, and FastMCP answers `401`. +- DPoP is not supported. A request that uses the `DPoP` authorization scheme, or a token bound to a DPoP key or a client certificate (it has a `cnf` claim), is rejected the same way with `invalid_token`, because it cannot be used as a Bearer token. +- A valid token that lacks a required scope gets a `403` with `insufficient_scope`. The text names the missing scope (`Required scope: `) because the official `mcp` SDK writes it. +- If Auth0 cannot be reached to check a token, the verifier raises `AuthServerUnavailableError` instead of reporting the token as bad. The server answers `503` with `Retry-After: 5` and `Cache-Control: no-store` headers and no `WWW-Authenticate` challenge, so the client keeps its token and can try again instead of logging in again. This applies to the provider from `Auth0FastMCP` only. A hand-wired provider answers with FastMCP's own `500`. + +In every case the handler never runs. + +This package does not check the `Host` or `Origin` header of a request. FastMCP has its own setting for that, `http_host_origin_protection`, which is off by default. + +## What the middleware does + +`Auth0ContextMiddleware` runs on every MCP request after the token is verified, including tool calls, resource reads, prompt requests and the list calls. It builds the caller's `Principal` from the verified token and makes it available to `get_caller_principal()` and `require_caller_mcp_client()` for the length of the request. The context is cleared when the request ends, even if the handler raises. + +A request with no verified token raises `UnauthenticatedError`. Over HTTP with the provider mounted this does not happen, because FastMCP turns the request away first. Seeing it means the request carried no token, for example the server was created without `auth=auth0.provider`, or the request came from an in-memory `Client(mcp)` or over stdio, which have no bearer token. It is logged with a traceback so the cause is easy to spot. The `initialize`, `ping` and `server/discover` requests are the exception, since they carry no caller. A token without a usable `sub`, `exp` or client id never counts as verified, so it gets a `401` before the middleware runs. + +To test a tool end to end, run the server over HTTP and send a real access token. An in-memory client cannot pass the middleware. + +Tools should read the caller with `get_caller_principal()`. FastMCP's own `get_access_token()` still returns the raw bearer token, because the verifier hands it to FastMCP, so avoid it in tool code. + +FastMCP runs middleware in the order it was added, so add `auth0.middleware` before any middleware of your own that reads the caller. + +### Failed token exchanges + +If a tool, resource or prompt lets an `ApiError` or a `DownscopedTokenError` escape, for example when Auth0 refuses an on-behalf-of or Token Vault exchange, the client gets `Token exchange failed ()`. One example is `Token exchange failed (federated_connection_refresh_token_not_found)`. The client never sees Auth0's own message, whether `mask_error_details` is on or off. The server log still has the full error and its traceback. + +To tell the user something more useful, catch the error in the handler and raise your own `ToolError`, `ResourceError` or `PromptError`. The middleware leaves that message as it is. + +```python +from auth0_ai_mcp import ApiError, require_caller_mcp_client +from fastmcp.exceptions import ToolError + + +@mcp.tool +async def calendar() -> str: + client = require_caller_mcp_client() + try: + token = await client.get_token_for_connection(connection="google-oauth2") + except ApiError as exc: + if exc.code == "federated_connection_refresh_token_not_found": + raise ToolError("Connect your Google account first") from exc + raise + ... +``` + +## Auth0FastMCP properties + +After construction, `Auth0FastMCP` exposes three read-only properties. + +- `.provider` - a FastMCP `Auth0MCPProvider`. Pass it as `FastMCP(auth=auth0.provider)`. It handles token verification and advertises the protected-resource metadata. +- `.middleware` - an `Auth0ContextMiddleware`. Register it with `mcp.add_middleware(auth0.middleware)`. It sets the request context so handler code can call `require_caller_mcp_client()` and `get_caller_principal()`. See [What the middleware does](#what-the-middleware-does). +- `.mcp_client` - the underlying `auth0-ai-mcp` `McpServerClient`, for advanced cases that need direct access to the core client. + +`Auth0TokenVerifier` and `Auth0ContextMiddleware` are also exported directly from `auth0_ai_fastmcp`, for cases where you want to hand-wire the provider yourself instead of using `Auth0FastMCP`. Pass `required_scopes` to `Auth0TokenVerifier`, because the provider ignores its own `required_scopes` once a `token_verifier` is given. A hand-wired provider also gets no mount-path check at startup and no `503` answer when Auth0 is down. diff --git a/packages/auth0-ai-fastmcp/docs/ToolScopes.md b/packages/auth0-ai-fastmcp/docs/ToolScopes.md new file mode 100644 index 0000000..210cdf4 --- /dev/null +++ b/packages/auth0-ai-fastmcp/docs/ToolScopes.md @@ -0,0 +1,38 @@ +# Tool Scopes + +`tool_scopes` are advertised in the server's protected-resource metadata so the MCP client knows to request them at login. `with_required_scopes` and `require_scopes` then check them on the tools, resources and prompts you choose to protect. + +> [!IMPORTANT] +> `tool_scopes` only advertises scopes and does not protect anything. A tool still needs `with_required_scopes` or `require_scopes` to enforce them. + +## Two separate scope layers + +There are two distinct authorization layers at play in an `auth0-ai-fastmcp` server. + +The first layer is the MCP server's own tool authorization. The scopes you list in `tool_scopes` are included in the RFC 9728 protected-resource metadata under `scopes_supported`, and so is every scope in `required_scopes`. With the provider from `Auth0FastMCP`, the `401` that an unauthenticated request gets names the same scopes in its `WWW-Authenticate` header. When an MCP client connects, it reads the metadata or that header and requests those scopes during the login flow. List every scope your clients need, whether it gates a tool, a resource or a prompt. The `with_required_scopes` decorator enforces them on a tool. If the caller's token does not carry a required scope, the tool call is rejected before it runs. + +The second layer is downstream API authorization. When a tool needs to call an external API on behalf of the caller, it uses `get_token_on_behalf_of` from `auth0-ai-mcp` to mint a separate token scoped to that API. Those downstream scopes (like `read:orders`) are entirely separate from the `tool:*` scopes that gate the tool itself. See [On-Behalf-Of](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OnBehalfOf.md) for how that exchange works. + +## Example: gating a tool + +`mcp` is the server from [Configuration](./Configuration.md), created with `auth=auth0.provider`. + +```python +from auth0_ai_fastmcp import with_required_scopes + + +@mcp.tool +@with_required_scopes("tool:read_orders") +async def read_orders() -> list[dict]: + ... +``` + +With `tool_scopes=["tool:read_orders", "tool:place_order"]` set on `Auth0FastMCPOptions`, both scopes appear in the server's metadata, so the MCP client requests them at login. The decorator ensures only callers whose token includes `tool:read_orders` can invoke `read_orders`. For the full server, including the downstream token exchange, see [Examples](../EXAMPLES.md#full-server-example). + +## Gating resources and prompts + +`with_required_scopes` also works on async resource and prompt handlers. A handler can instead call `require_scopes(get_caller_principal(), "")` at the top. See [Resources and prompts](../EXAMPLES.md#resources-and-prompts) for a resource and a prompt that do this. + +To require a scope on every request instead, use `required_scopes` in [Configuration](./Configuration.md#required-scopes). + +Import `with_required_scopes` and `require_scopes` from `auth0_ai_fastmcp`, not `auth0_ai_mcp`. They raise the same `InsufficientScopeError` and `UnauthenticatedError`, which are also FastMCP `ToolError`s. A denied tool call reaches the client as a tool error in a normal `200` response, not an HTTP `403`, and carries the reason even when `mask_error_details` is on, and the server logs the denial at INFO instead of ERROR. A denied resource or prompt is still logged with a traceback by FastMCP. Matching is exact, including case, so a broader scope does not imply a narrower one. Put `@mcp.tool` above `@with_required_scopes`, otherwise FastMCP registers the unguarded function. The wrappers do not cover `require_caller_*`. For the scope rules, see [Scope Enforcement](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/ScopeEnforcement.md). diff --git a/packages/auth0-ai-fastmcp/references/code-style.md b/packages/auth0-ai-fastmcp/references/code-style.md new file mode 100644 index 0000000..cd0c869 --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/code-style.md @@ -0,0 +1,32 @@ +# Code Style + +## Naming + +- `snake_case` functions, methods, and options. `PascalCase` classes. `UPPER_SNAKE` module constants (`_METHODS_WITHOUT_CALLER`). +- Private helpers live in `utils/_internal/`, one file per topic (`validation.py`, `telemetry.py`), with no leading underscore on the functions. Public modules call them through the module, for example `validation.resolve_audience(...)`, and never import a helper by name. Module constants carry a leading underscore. +- Type hints use builtin generics and `X | None` unions. ruff `UP` targets py310, so do not import `Optional` or `Union`. +- Modules start with `from __future__ import annotations`, except `__init__.py`. + +## Errors + +This package defines no public error classes. `scopes.py` has two private `ToolError` subclasses that extend the core scope errors so a denial reaches the client as a tool error. `Auth0TokenVerifier` turns `VerifyAccessTokenError` into a rejected token and re-raises `AuthServerUnavailableError`. `Auth0ContextMiddleware` raises `UnauthenticatedError` when a request has no verified token. Do not wrap core SDK errors. + +Keep messages generic when they reach a client, and never put token material or scope lists in them. The one exception is the `403` FastMCP sends for `required_scopes`, which names the missing scope and is not written by this package. + +## Patterns + +- **Constructor wiring:** `Auth0FastMCP` builds the `McpServerClient`, the token verifier, and the context middleware from `Auth0FastMCPOptions`. New options are forwarded there. +- **Async I/O:** `verify_token` and `on_request` are `async def`. +- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections, as in `Auth0ContextMiddleware.on_request`. +- **Placement:** put new code next to related code or at the end, never at an arbitrary position. + +## Comments and docstrings + +Enforced on every change (code, tests, docs, commit messages): + +1. **Comments earn their place.** Only a non-obvious why, such as a protocol citation (`# RFC 9728`), a security decision, or an intentional omission. Never restate the line below. Default to one sharp line or none. +2. **Docstring format.** One-line description, then `Args`, then `Returns` / `Raises`. No design rationale, that belongs in the PR description. +3. **Plain hyphens only.** No em or en dashes in comments, docstrings, commit messages, or Markdown. +4. **No semicolon clause-splicing.** Split independent clauses into separate sentences. +5. **No internal references.** No ticket numbers, planning-doc paths, internal links, or comparisons with other SDKs. +6. **Helpers stay grouped.** Never interleave a private helper between public methods. diff --git a/packages/auth0-ai-fastmcp/references/commands.md b/packages/auth0-ai-fastmcp/references/commands.md new file mode 100644 index 0000000..29bbc31 --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/commands.md @@ -0,0 +1,40 @@ +# Commands + +Run these from the `auth0-ai-fastmcp` directory. CI runs the install, `pytest`, and `ruff check .` on Python 3.10 to 3.14. + +```bash +# Install dependencies and the package (CI runs both steps) +poetry install --no-interaction --no-root +poetry install --no-interaction + +# Run all unit tests (CI runs pytest -v) +poetry run pytest + +# One file, or filter by name +poetry run pytest tests/test_verifier.py +poetry run pytest tests/test_verifier.py -k scope + +# Lint (CI-enforced) +poetry run ruff check . + +# Auto-fix lint issues (review the diff, keep it scoped to your change) +poetry run ruff check . --fix + +# Show formatting state only (not a CI gate, never apply repo-wide) +poetry run ruff format --check . + +# Build sdist and wheel into dist/ +poetry build + +# Clean generated artifacts +rm -rf dist/ .coverage coverage.xml .pytest_cache .ruff_cache +find . -path ./.venv\* -prune -o -name __pycache__ -type d -prune -exec rm -rf {} + +``` + +## Not available here + +- **No typecheck command.** mypy and pyright are not in `pyproject.toml` and no CI step runs them. +- **No coverage tooling.** `pytest-cov` is not a dev dependency and no threshold is configured. +- **No integration tier.** The suite needs no Auth0 tenant. The example server in `examples/b2b_ecommerce` is the only thing that talks to a real tenant, and it is run by hand. + +Agents do not run releases. diff --git a/packages/auth0-ai-fastmcp/references/docs-update.md b/packages/auth0-ai-fastmcp/references/docs-update.md new file mode 100644 index 0000000..ef9f07c --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/docs-update.md @@ -0,0 +1,32 @@ +# Docs Update Rules + +A PR that changes public API, configuration, or integration patterns is not complete until the matching docs are updated in the same PR. + +## Tracked docs + +| Doc | Covers | +|-----|--------| +| `README.md` | Install, wiring the provider and middleware, writing a gated tool, links to the guides | +| `EXAMPLES.md` | Server setup, gated tools, resources and prompts, the b2b_ecommerce example | +| `docs/Configuration.md` | Every `Auth0FastMCPOptions` field, how requests are verified, the errors callers see | +| `docs/ToolScopes.md` | Tool scopes, `with_required_scopes`, `require_scopes` | +| `examples/b2b_ecommerce/README.md` | Running the example server | + +## When you change code, update these docs + +| When this changes | Update | +|-------------------|--------| +| An `Auth0FastMCPOptions` field added, removed, renamed, or its default changes | `docs/Configuration.md`. Then check the README and `EXAMPLES.md` snippets still match | +| Verification behavior or which tokens are rejected | `docs/Configuration.md` | +| Which requests get a caller, or the missing-token behavior | `README.md` "Configure Auth0FastMCP", `docs/Configuration.md` | +| Required scopes or tool scopes | `docs/Configuration.md`, `docs/ToolScopes.md` | +| Token store, scope matching, or OBO options | `docs/Configuration.md` | +| New exported symbol in `__init__.py` | `EXAMPLES.md` (add a usage sample) | +| The `fastmcp` range or the supported Python version | `pyproject.toml` and, if the README states the range, `README.md` | +| The auth0-ai-mcp API this package calls | Check `docs/` here still matches | + +State each fact in one doc and link to it from the others rather than repeating it. When a fact changes, update the page that owns it, then check the pages that link to it. The README keeps a one-line callout at most, and the detail lives in `docs/`. + +Use GitHub alerts (`> [!IMPORTANT]`, `> [!WARNING]`) only for things that fail silently or widen access, at most one or two per page. Put the alert type alone on the first line and the text on the next. Errors that fail loudly stay plain text. + +Links to auth0-ai-mcp guides use absolute GitHub URLs, because this package is published separately. diff --git a/packages/auth0-ai-fastmcp/references/flow-map.md b/packages/auth0-ai-fastmcp/references/flow-map.md new file mode 100644 index 0000000..a330fc7 --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/flow-map.md @@ -0,0 +1,16 @@ +# Flow Map + +Before changing a flow, read its entry points and supporting helpers. Everything marked "auth0-ai-mcp" or "core SDK" lives in another package and is only wired here. + +| Flow | Entry points | Also read | Docs | +|------|--------------|-----------|------| +| Configuration | `Auth0FastMCPOptions`, `Auth0FastMCP.__init__` (`config.py`) | auth0-ai-mcp `create_mcp_client`, FastMCP `Auth0MCPProvider` | `README.md`, `docs/Configuration.md` | +| Token verification | `Auth0TokenVerifier.verify_token` (`verifier.py`) | auth0-ai-mcp `McpServerClient.verify_access_token` and `build_principal`, FastMCP `TokenVerifier` | `docs/Configuration.md`, `README.md` "Configure Auth0FastMCP" | +| Required scopes | `Auth0TokenVerifier.__init__` (`verifier.py`), `Auth0FastMCPOptions.required_scopes` | FastMCP `RemoteAuthProvider` (reads `required_scopes` from the verifier) | `docs/Configuration.md` | +| Caller context | `Auth0ContextMiddleware.on_request` (`middleware.py`) | auth0-ai-mcp `bind_request_context`, `RequestContext`, `get_caller_*` | `README.md`, `EXAMPLES.md` | +| Tool scopes | `Auth0FastMCPOptions.tool_scopes`, `with_required_scopes`, `require_scopes` (`scopes.py`) | auth0-ai-mcp `with_required_scopes` and `require_scopes`, FastMCP `ToolError` | `docs/ToolScopes.md` | +| On-behalf-of exchange and caching | `Auth0FastMCPOptions.token_store`, `scope_matching`, `client_id`, `client_secret` | auth0-ai-mcp `create_mcp_client` | `docs/Configuration.md` | + +Tests: `tests/test_config.py` covers option wiring, defaults, the advertised resource and scopes, the bare-domain rule, and the hidden client secret. `tests/test_verifier.py` covers accept and reject outcomes, blank tokens, an unreachable Auth0, and the warning log that never holds the token. `tests/test_middleware.py` covers the bound caller, its reset, the exempt lifecycle requests, and the missing-token path. `tests/test_scopes.py` covers the scope wrappers, including what the client sees with `mask_error_details` on and off. `tests/test_outage.py` covers the `503` middleware's edge cases. `tests/test_config.py` also sends real requests and expects the `403` and `200` for `required_scopes`, the `401` for a token with no subject, the `503` with `Retry-After` when Auth0 is down, and a tool that reads the caller in stateless and stateful sessions. + +Two rules cut across every flow. Reject on every failure path, and tool code reads the caller through `get_caller_principal`, never the raw incoming token. diff --git a/packages/auth0-ai-fastmcp/references/git-workflow.md b/packages/auth0-ai-fastmcp/references/git-workflow.md new file mode 100644 index 0000000..1d888eb --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/git-workflow.md @@ -0,0 +1,28 @@ +# Git Workflow + +## Branch naming + +`feat/`, `fix/`, `docs/`, `chore/`. Release branches are prefixed with the package name, `release/auth0-ai-fastmcp-`. Dependabot opens `dependabot/...` branches. Base branch is `main`. + +## Commit messages + +Conventional Commits prefixes (`feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:`), lowercase subject, no trailing period. Example: `fix: return no access token when the scope claim is missing`. No commit linter is configured, so match the prefix style by hand. + +## Pull requests + +`.github/PULL_REQUEST_TEMPLATE.md` (at the repository root) sections: + +- **Changes:** what and why, classes and methods added, changed, or deprecated, and a usage summary for public API changes +- **References:** supporting links +- **Testing:** how to test, with checkboxes for unit coverage, integration coverage, and latest-platform testing +- **Checklist:** Auth0 contribution guidelines and Code of Conduct + +The template asks for a method-level inventory, so write the PR body from the diff of the public surface (`__init__.py` `__all__`, `Auth0FastMCP`, `Auth0FastMCPOptions`, `Auth0TokenVerifier`, `Auth0ContextMiddleware`), not from a summary of intent. + +## CI + +Pull requests run `pytest -v` and `ruff check .` on Python 3.10 to 3.14, plus CodeQL, a Snyk scan with suppressions in `.snyk`, and OpenSSF Scorecard. `ruff format` is not a check, so reformatting untouched files creates noise CI won't flag but reviewers will. + +## Releases + +`.version` is the version source. `.shiprc` keeps `pyproject.toml` in step with it. Releases are cut by the publish workflow, not by an agent editing version files. `CHANGELOG.md` is written as part of that flow, so a feature commit does not touch it. diff --git a/packages/auth0-ai-fastmcp/references/pitfalls.md b/packages/auth0-ai-fastmcp/references/pitfalls.md new file mode 100644 index 0000000..18189ac --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/pitfalls.md @@ -0,0 +1,15 @@ +# Common Pitfalls + +1. **Do not run `ruff format .`.** CI runs only `ruff check .`. Match surrounding formatting by hand. +2. **Python 3.10 floor.** `.ruff.toml` targets `py310`. `X | Y` unions and `match` are fine. Syntax newer than 3.10 is not. +3. **Async tests need no marker.** `asyncio_mode = "auto"` is set, so a bare `async def test_...` runs. +4. **Only `PLC0415` is a preview rule.** An import inside a function fails lint, move it to the top or under `TYPE_CHECKING`. +5. **`required_scopes` must reach the verifier.** FastMCP reads the required scopes from the token verifier. `Auth0MCPProvider` only forwards them to its own default verifier, so with a custom verifier they are dropped unless `Auth0TokenVerifier` passes them to `TokenVerifier.__init__`. +6. **`audience` must equal the advertised resource.** `resource_url` is the base URL and FastMCP appends the MCP path (default `/mcp`). `audience` defaults to that value. A server mounted at another path needs `mcp_path` set to match, otherwise `http_app` or `run` raises `ConfigurationError` because every token would fail the audience check. +7. **`initialize`, `ping` and `server/discover` carry no caller.** `Auth0ContextMiddleware` lets them through without a token. Over HTTP, FastMCP's own auth still answers a request with no token with a `401` before the middleware runs. Every other request without a verified token is rejected, including one from an in-memory `Client(mcp)` or stdio, so test tools over HTTP with a real token. +8. **Hooks run in a fixed order.** For `tools/call`, `resources/read`, `prompts/get`, and the list requests, FastMCP runs `on_message`, then `on_request`, then the typed hook. The context is bound in `on_request`, so it is visible to every typed hook that runs after it and to the handler. A middleware registered earlier than `Auth0ContextMiddleware` does not see the caller. +9. **The caller accessors return `None` outside a request.** Tests that call tool code directly must bind a `RequestContext` first. +10. **The provider constructor makes a network call.** `Auth0MCPProvider` performs OIDC discovery when it is built, so `Auth0FastMCP` builds it once and tests patch it. +11. **Examples read environment variables at import.** Importing `examples/b2b_ecommerce/tools.py` without the variables raises `KeyError`. `ruff check .` still lints it. +12. **No separate requirements file.** Dependencies live only in `pyproject.toml` and `poetry.lock`. +13. **Stale local venvs.** Use `poetry install` then `poetry run ...` so you test the same environment CI does. diff --git a/packages/auth0-ai-fastmcp/references/testing.md b/packages/auth0-ai-fastmcp/references/testing.md new file mode 100644 index 0000000..4adc380 --- /dev/null +++ b/packages/auth0-ai-fastmcp/references/testing.md @@ -0,0 +1,29 @@ +# Testing + +- **Framework:** pytest with pytest-asyncio (versions in the `pyproject.toml` dev group) +- **Location:** `tests/`, one `test_.py` per source module +- **Async mode:** `asyncio_mode = "auto"` in `pyproject.toml`, so `async def test_...` runs without a marker +- **Coverage:** none configured + +The suite needs no credentials and makes no network calls. + +## Running tests + +```bash +poetry run pytest # all unit tests +poetry run pytest tests/test_verifier.py # one file +poetry run pytest tests/test_middleware.py -k token # filter by name +``` + +## Conventions + +- Tests are module-level `test__` functions. Follow the style of the file you are adding to. +- Cover the failure path as well as the success path. A rejection test must assert that the handler or the next step did not run. +- Placeholders stay obviously synthetic (`user_1`, `https://tenant.example/`). Never paste a real tenant value. +- Name test doubles `Stub...`, never `Fake...`. + +## Stubbing and helpers + +- `StubMcpClient` in `tests/test_verifier.py` stands in for `McpServerClient`. It returns a `VerifiedToken` with the claims a test chooses, or raises the error a test chooses. +- `tests/test_middleware.py` patches `get_access_token` in the middleware module to control whether a request has a verified token. Its `Recorder` is a `call_next` that records the caller visible while the request runs, so a test can assert on both the bound context and its reset afterwards. +- `tests/test_config.py` patches `create_mcp_client` and `config._Auth0Provider` to stay offline. Where a test needs the real provider, it patches `OIDCConfiguration.get_oidc_configuration` instead. diff --git a/packages/auth0-ai-fastmcp/tests/test_docs.py b/packages/auth0-ai-fastmcp/tests/test_docs.py new file mode 100644 index 0000000..367f4d3 --- /dev/null +++ b/packages/auth0-ai-fastmcp/tests/test_docs.py @@ -0,0 +1,49 @@ +"""Every Python snippet in the README and guides parses, imports names that exist, and builds `Auth0FastMCPOptions` with real fields.""" + +import ast +import dataclasses +import importlib +import re +from pathlib import Path + +import pytest + +from auth0_ai_fastmcp import Auth0FastMCPOptions + +ROOT = Path(__file__).resolve().parent.parent +FENCE = re.compile(r"^```python\n(.*?)^```", re.DOTALL | re.MULTILINE) +CHECKED_PACKAGES = ("auth0_ai_fastmcp", "auth0_ai_mcp", "fastmcp") +OPTION_FIELDS = {field.name for field in dataclasses.fields(Auth0FastMCPOptions)} + + +def _snippets(): + for path in [ROOT / "README.md", ROOT / "EXAMPLES.md", *sorted((ROOT / "docs").glob("*.md"))]: + for index, match in enumerate(FENCE.finditer(path.read_text()), start=1): + yield pytest.param(match.group(1), id=f"{path.relative_to(ROOT)}#{index}") + + +@pytest.mark.parametrize("source", _snippets()) +def test_doc_snippet_parses_and_imports_exist(source): + tree = ast.parse(source) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] in CHECKED_PACKAGES: + importlib.import_module(alias.name) + if isinstance(node, ast.ImportFrom) and node.module and node.module.split(".")[0] in CHECKED_PACKAGES: + module = importlib.import_module(node.module) + missing = [alias.name for alias in node.names if not hasattr(module, alias.name)] + assert not missing, f"{node.module} has no {missing}" + + +@pytest.mark.parametrize("source", _snippets()) +def test_doc_snippet_options_use_real_fields(source): + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Auth0FastMCPOptions": + unknown = [kw.arg for kw in node.keywords if kw.arg and kw.arg not in OPTION_FIELDS] + assert not unknown, f"Auth0FastMCPOptions has no field {unknown}" + + +def test_docs_have_python_snippets(): + assert list(_snippets())