diff --git a/packages/auth0-ai-mcp/AGENTS.md b/packages/auth0-ai-mcp/AGENTS.md new file mode 100644 index 0000000..6a8d514 --- /dev/null +++ b/packages/auth0-ai-mcp/AGENTS.md @@ -0,0 +1,3 @@ +# AI Agent Guidelines for auth0-ai-mcp + +@./CLAUDE.md for all coding guidelines, commands, project structure, code style, testing conventions, and boundaries. diff --git a/packages/auth0-ai-mcp/CLAUDE.md b/packages/auth0-ai-mcp/CLAUDE.md new file mode 100644 index 0000000..31a641c --- /dev/null +++ b/packages/auth0-ai-mcp/CLAUDE.md @@ -0,0 +1,104 @@ +# AI Agent Guidelines for auth0-ai-mcp + +## Your Role + +You are a Python SDK engineer on auth0-ai-mcp, the async library MCP servers use to verify Auth0 access tokens, enforce scopes, and exchange the caller's token for downstream API tokens. It is built on auth0-api-python and owns no MCP framework. + +--- + +## 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_mcp/ + __init__.py # Public API: everything in __all__ is the supported surface + client.py # McpServerClient (verify, OBO, client credentials, Token Vault) and create_mcp_client + middleware.py # Auth0AsgiMiddleware and build_protected_resource_metadata. Not in __all__, import from auth0_ai_mcp.middleware + principal.py # Principal dataclass and build_principal (normalizes verified claims) + request_context.py # ContextVar request state and the get_caller_* accessors + request_scoped_client.py # RequestScopedMcpClient: the client bound to one request's verified token + scopes.py # require_scopes and the with_required_scopes decorator + errors.py # Errors owned by this package, all extend BaseAuthError + utils/_internal/ # Private helpers by topic (exchange, scope_input, urls, headers, mcp_session). A public helper is a plain-named file in utils/ +tests/ + test_client.py, test_middleware.py, test_principal.py, test_request_context.py, test_scopes.py, test_docs.py +docs/ # Hand-written guides, linked from README (see references/docs-update.md) +examples/b2b_ecommerce/ # Runnable server (server.py, tools.py, token_store.py) 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) +``` + +Token verification, the OBO exchange, caching, and token stores live in auth0-api-python. This package wraps them and does not reimplement them. Before changing a flow, read its row in [references/flow-map.md](references/flow-map.md) for entry points, supporting helpers, tests, and docs. + +--- + +## Boundaries + +### Always Do + +- Run `poetry run pytest` and `poetry run ruff check .` before committing (both run in CI, see the Python matrix in `.github/workflows/test.yml`). +- 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. Never reorder existing declarations. +- Add unit tests for new functionality. Async tests need no marker because `asyncio_mode = "auto"` is set. Stand in for the core client with a `Stub...` class, as `tests/test_client.py` does (no real network calls). +- Raise typed errors from `errors.py` (subclasses of `BaseAuthError` with `get_status_code()` / `get_error_code()`). Let the core SDK's own errors propagate unchanged. Never return `None` or a bare `dict` to signal failure. +- Export any new public symbol through `__init__.py` `__all__`. +- Update `README.md`, `EXAMPLES.md`, and 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, and `TYPE_CHECKING` blocks are the place for typing-only imports. + +### Ask First + +- Any breaking change to a version that has already shipped. Stop and ask the maintainer before writing a renamed or removed public symbol, a changed signature or default, a new required option, or a raised Python floor. +- Changing an existing error's `get_error_code()` string or `get_status_code()`. Integrators branch on these, so treat them as public API. +- Adding or bumping dependencies (`pyproject.toml`, `poetry.lock`), including the `auth0-api-python` pin. +- Changes to token verification, the principal built from claims, scope matching, or the downscope check. +- Changes to CI/CD configuration (`.github/workflows/`, `.github/actions/`), `.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. +- 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, scope, exchange, or missing-context failure must raise a typed error or return a `401` challenge, never fall through to a permissive default. +- Hand the raw incoming token to tool code. `RequestScopedMcpClient` closes over it on purpose. +- 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 and the tree is not format-clean (see [references/pitfalls.md](references/pitfalls.md)). +- Modify `.venv*/`, `dist/`, `__pycache__/`, `.coverage`, `coverage.xml`. +- Log, print, or include access tokens, exchanged tokens, client secrets, or token endpoint response bodies in error messages. +- Break backward compatibility without asking first and getting explicit approval. + +--- + +## Security Considerations + +- **Verification is delegated:** every token goes through `McpServerClient.verify_access_token`, which calls the core SDK. Never decode a token yourself, and only pass already-verified claims to `build_principal`. +- **Fail closed in the middleware:** a missing, blank or non-Bearer token gets a `401` with a bare challenge, and a token that fails verification (including a claim set `build_principal` rejects) or has whitespace inside it gets a `401` with `invalid_token`. Both challenges carry `scope` when `scopes_supported` is set, and every `401` and `503` has `Cache-Control: no-store`. If Auth0 cannot be reached (`AuthServerUnavailableError`) the answer is a `503` with no challenge. WebSocket connections are closed and `OPTIONS` passes through without a token. The tool never runs, so keep it that way for any new rejection path. +- **Scope checks need every scope:** `require_scopes` raises `InsufficientScopeError` if any required scope is missing and `UnauthenticatedError` when there is no principal. `with_required_scopes` reads the principal from the request context, so a call outside a request is rejected. +- **Downscope detection:** `exchange.reject_if_downscoped` raises `DownscopedTokenError` when the exchange grants fewer scopes than were requested, for both On-Behalf-Of and client credentials but not Token Vault. An absent `scope` field in the response means the grant matched the request, so only a present field is compared. +- **Expired results are rejected:** after every exchange `exchange.reject_if_expired` raises the flow's own error when `expires_at` is missing or not in the future, so tool code never gets an already-expired token. Any leeway for clock skew belongs in the core SDK. +- **Generic error messages:** `InsufficientScopeError` and `DownscopedTokenError` deliberately do not name the scopes, and tests assert it. The invalid-token challenge uses the fixed description "The access token is not valid" and the real reason only goes to the log, so keep verification messages free of token contents. +- **Token stores:** a store is the integrator's `AbstractTokenStore`, and the base class takes a secret of at least 32 characters and provides `encrypt` and `decrypt` helpers that the store has to call itself in `set` and `get`. Core treats a failing store read as a cache miss and a failed write as non-fatal, so a failing store must not turn into an authorization decision. +- **Request context is per request:** `RequestContext` is a `ContextVar` set and reset around each request in `Auth0AsgiMiddleware`. Always reset it in a `finally`, otherwise one caller's identity can leak into the next request. +- **Dependency scanning:** Snyk (`sca_scan.yml`), CodeQL (`codeql.yml`), OpenSSF Scorecard (`scorecard.yml`), 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 core 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-mcp/EXAMPLES.md b/packages/auth0-ai-mcp/EXAMPLES.md new file mode 100644 index 0000000..8a0ee2a --- /dev/null +++ b/packages/auth0-ai-mcp/EXAMPLES.md @@ -0,0 +1,70 @@ +# Examples + +This page walks through a complete Auth0-secured MCP server using the ASGI adapter shipped with `auth0-ai-mcp`. The goal is to show the full wiring from client construction to a gated tool that mints a downstream token, so you can see what the library handles and what stays in your code. + +- [Building the client](#building-the-client) +- [Wiring the ASGI adapter](#wiring-the-asgi-adapter) +- [Writing a gated tool](#writing-a-gated-tool) +- [Complete example - b2b_ecommerce](#complete-example---b2b_ecommerce) + +## Building the client + +```python +from auth0_ai_mcp import create_mcp_client + +client = create_mcp_client( + domain="your-tenant.auth0.com", + audience="https://your-mcp-server/mcp", + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", +) +``` + +Every client option is described in [Configuration](./docs/Configuration.md). + +## Wiring the ASGI adapter + +```python +from mcp.server.mcpserver import MCPServer +from auth0_ai_mcp.middleware import Auth0AsgiMiddleware + +mcp = MCPServer(name="My Server") + +secured_app = Auth0AsgiMiddleware( + mcp.streamable_http_app(), + mcp_client=client, + scopes_supported=["read:weather"], + resource_name="My Server", + resource_documentation="https://your-mcp-server/docs", +) +``` + +From this point on, the library verifies every incoming token, rejects bad ones with a `401` before any tool runs, and serves the protected resource metadata. For a public URL, pass `transport_security` as shown in [Writing an Adapter](./docs/WritingAnAdapter.md#ready-made-integrations), or the `mcp` SDK answers every request with a `421`. See [Token Verification](./docs/TokenVerification.md) and [Configuration](./docs/Configuration.md). + +## Writing a gated tool + +```python +from auth0_ai_mcp import require_caller_mcp_client, with_required_scopes + +@mcp.tool() +@with_required_scopes("read:weather") +async def get_weather(location: str) -> dict: + auth0 = require_caller_mcp_client() + token = await auth0.get_token_on_behalf_of( + audience="https://weather-api/", + scope="read:weather", + ) + # Use token["access_token"] in your downstream HTTP call. + ... +``` + +Keep `@mcp.tool()` as the top decorator, see [Scope Enforcement](./docs/ScopeEnforcement.md#decorator-form). For how scope failures are reported, see [Scope Enforcement](./docs/ScopeEnforcement.md#errors). + +For how `require_caller_mcp_client` and the caller's token work, see [Request Context](./docs/RequestContext.md#requestscopedmcpclient). + +## Complete example - b2b_ecommerce + +The [`examples/b2b_ecommerce`](./examples/b2b_ecommerce/) directory contains a runnable server whose flagship `cancel_and_refund_order` tool touches two downstream services (orders and payments) in one call, minting a separate scoped token for each via On-Behalf-Of. It also shows: + +- An in-memory `token_store` passed to `create_mcp_client` for caching OBO tokens, and `scope_matching` driven by an environment variable to switch between matching modes at deploy time. See [Token Caching](./docs/TokenCaching.md) for what a production store needs and the available modes. +- Organization policy configured to accept tokens only from a specific organization. The policy is optional and defaults to `"allow"`. See [Organization Policy](./docs/OrganizationPolicy.md). diff --git a/packages/auth0-ai-mcp/README.md b/packages/auth0-ai-mcp/README.md index 01e4153..4619f78 100644 --- a/packages/auth0-ai-mcp/README.md +++ b/packages/auth0-ai-mcp/README.md @@ -4,16 +4,16 @@ An MCP server is just another resource server. This library ships no MCP framewo ![Release](https://img.shields.io/pypi/v/auth0-ai-mcp) ![Downloads](https://img.shields.io/pypi/dw/auth0-ai-mcp) [![License](https://img.shields.io/:license-Apache--2.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) ## How it works -1. **Verify** - every incoming bearer token is checked before the MCP handler runs. -2. **Context** - the verified caller is available to tool code. -3. **Scopes** - tools can require scopes on the caller's token. -4. **On-Behalf-Of** - exchange the caller's token for a downstream token. -5. **Token Vault** - exchange the caller's token for a federated provider token. -6. **Client Credentials** - get a token for the server itself. +1. **Verify** - every incoming bearer token is checked before the MCP handler runs. See [Token Verification](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenVerification.md). +2. **Context** - the verified caller is available to tool code. See [Request Context](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/RequestContext.md). +3. **Scopes** - tools can require scopes on the caller's token. See [Scope Enforcement](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/ScopeEnforcement.md). +4. **On-Behalf-Of** - exchange the caller's token for a downstream token. See [On-Behalf-Of](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OnBehalfOf.md). +5. **Token Vault** - exchange the caller's token for a federated provider token. See [Token Vault](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenVault.md). +6. **Client Credentials** - get a token for the server itself. See [On-Behalf-Of](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OnBehalfOf.md#server-identity-calls-client-credentials). ## Getting Started @@ -53,7 +53,7 @@ client = create_mcp_client( > [!IMPORTANT] > `audience` must be the public URL of your MCP server. It is also the API identifier you register in Auth0, so a token issued for any other value is rejected with a `401`. -`client_id` and `client_secret` are only needed for token exchanges. +`client_id` and `client_secret` are only needed for token exchanges. Every option is described in [Configuration](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/Configuration.md). ### 3. Wire an adapter @@ -76,7 +76,9 @@ secured_app = Auth0AsgiMiddleware( ) ``` -The `mcp` SDK only accepts requests for `127.0.0.1`, `localhost` and `::1` by default, so list your public host in `allowed_hosts`, with and without a port, or every request gets a `421`. `scopes_supported` is advertised in the metadata, so clients know to request the scopes your tools need. Without it a client may ask for no scope and every gated tool fails. +The `mcp` SDK only accepts requests for `127.0.0.1`, `localhost` and `::1` by default, and only with a port, so list your public host in `allowed_hosts`, with and without a port, or every request gets a `421`. A browser request whose `Origin` is not in `allowed_origins` gets a `403`. `scopes_supported` is advertised in the metadata, so clients know to request the scopes your tools need. Without it a client may ask for no scope and every gated tool fails. + +See [Writing an Adapter](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/WritingAnAdapter.md) for other frameworks and for the native FastMCP adapter package `auth0-ai-fastmcp`. For a complete runnable server, see [`examples/b2b_ecommerce`](https://github.com/auth0/auth0-ai-python/tree/main/packages/auth0-ai-mcp/examples/b2b_ecommerce). ### 4. Write a gated tool @@ -95,7 +97,20 @@ async def get_weather(location: str) -> dict: ... ``` -`with_required_scopes` raises before the tool body runs if the caller's token is missing a listed scope. Keep `@mcp.tool()` as the top decorator. If it sits below, the framework registers the unguarded function and the check is skipped. `require_caller_mcp_client` reads the request-scoped client from the `contextvar` set by the adapter. +`with_required_scopes` raises before the tool body runs if the caller's token is missing a listed scope. Keep `@mcp.tool()` as the top decorator. If it sits below, the framework registers the unguarded function and the check is skipped. See [Scope Enforcement](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/ScopeEnforcement.md). `require_caller_mcp_client` reads the request-scoped client from the `contextvar` set by the adapter. + +## Documentation + +- [Configuration](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/Configuration.md) - every client and middleware option. +- [Token Verification](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenVerification.md) - how the incoming token is checked and the `Principal` is built. +- [Request Context](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/RequestContext.md) - reading the caller inside a tool. +- [Scope Enforcement](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/ScopeEnforcement.md) - gating tools on the caller's scopes. +- [On-Behalf-Of](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OnBehalfOf.md) - exchanging the caller's token for a downstream token. +- [Token Vault](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenVault.md) - exchanging the caller's token for a federated provider token via Token Vault. +- [Token Caching](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/TokenCaching.md) - the optional OBO cache and scope-matching modes. +- [Organization Policy](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/OrganizationPolicy.md) - accepting or requiring an Organization. +- [Writing an Adapter](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/docs/WritingAnAdapter.md) - wiring the core into any MCP framework. +- [Examples](https://github.com/auth0/auth0-ai-python/blob/main/packages/auth0-ai-mcp/EXAMPLES.md) - a full runnable server. ## Feedback diff --git a/packages/auth0-ai-mcp/docs/Configuration.md b/packages/auth0-ai-mcp/docs/Configuration.md new file mode 100644 index 0000000..086e779 --- /dev/null +++ b/packages/auth0-ai-mcp/docs/Configuration.md @@ -0,0 +1,86 @@ +# Configuration + +Two things are configured once at startup. `create_mcp_client` builds the client that verifies tokens and runs token exchanges, and `Auth0AsgiMiddleware` wraps your app with it. + +## create_mcp_client + +```python +from auth0_ai_mcp import create_mcp_client + +client = create_mcp_client( + domain="your-tenant.auth0.com", + audience="https://your-mcp-server/mcp", + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", +) +``` + +- `domain` (str) - Auth0 tenant domain as a bare host such as `your-tenant.auth0.com`, with no scheme, path or whitespace. It must match the issuer of your tokens, so pass your custom domain here if you use one. Required unless `api_client` is passed. +- `audience` (str) - the API identifier incoming tokens are validated against. See [audience](#audience). Required unless `api_client` is passed. +- `client_id` (str | None, default `None`) - client id for token exchanges. Needed for [On-Behalf-Of](./OnBehalfOf.md), [client credentials](./OnBehalfOf.md#server-identity-calls-client-credentials) and [Token Vault](./TokenVault.md). Verifying incoming tokens works without it. +- `client_secret` (str | None, default `None`) - client secret for the same exchanges. +- `token_store` (AbstractTokenStore | None, default `None`) - backing store for cached tokens. See [Token Caching](./TokenCaching.md). +- `scope_matching` (str, default `"strict"`) - either `"strict"` or `"non_strict"`. See [Scope-matching modes](./TokenCaching.md#scope-matching-modes). +- `organization_policy` (str, default `"allow"`) - either `"allow"` or `"required"`. See [Organization Policy](./OrganizationPolicy.md). +- `organization_id` (str | list[str] | None, default `None`) - the organization or organizations accepted when the policy is `"required"`. See [Organization Policy](./OrganizationPolicy.md). +- `client_info` (dict | None, default `None`) - identifies a package built on this one in the `Auth0-Client` header. A dict with non-empty string `name` and `version` keys and an optional `env` dict of string keys and values. +- `api_client` (ApiClient | None, default `None`) - a ready-made client to use as is. See [Passing a prebuilt client](#passing-a-prebuilt-client). + +`scope_matching` and `organization_policy` only accept the values listed above. + +### audience + +> [!IMPORTANT] +> `audience` must be the public URL of your MCP server. It is also the API identifier you register in Auth0, so a token issued for any other value is rejected with a `401`. + +### Passing a prebuilt client + +`ApiClient` comes from [`auth0-api-python`](https://github.com/auth0/auth0-api-python), the library `auth0-ai-mcp` is built on. Build one yourself when you need an option `create_mcp_client` does not expose, such as multi-domain mode. + +```python +from auth0_api_python import ApiClient, ApiClientOptions +from auth0_ai_mcp import create_mcp_client + +api_client = ApiClient(ApiClientOptions( + domain="your-tenant.auth0.com", + audience="https://your-mcp-server/mcp", +)) +client = create_mcp_client(api_client=api_client) +``` + +Pass `api_client` and nothing else. Combining it with any other option raises `ConfigurationError`, because those options would be ignored. + +## Auth0AsgiMiddleware + +```python +from auth0_ai_mcp.middleware import Auth0AsgiMiddleware + +secured_app = Auth0AsgiMiddleware( + app, + mcp_client=client, + scopes_supported=["read:weather"], + resource_name="My Server", + resource_documentation="https://your-mcp-server/docs", +) +``` + +The middleware reads the resource from the client's `audience` and the authorization server from its `domain`, so neither is set twice. + +- `app` - the ASGI app to protect. +- `mcp_client` - the client from `create_mcp_client`. +- `resource_url` (str | None, default `None`) - absolute http or https URL of the protected resource. Defaults to the client's `audience` and must equal it when given, otherwise `ConfigurationError` is raised. +- `authorization_servers` (list[str] | None, default `None`) - issuer URLs to advertise, each an absolute https URL. Defaults to the client's `domain`. Required when the client has no single `domain`, for example a prebuilt `api_client` in multi-domain mode. +- `scopes_supported` (list[str] | None, default `None`) - scopes to advertise, so the MCP client requests them at login. It must be a list, and a bare string, or an entry that is blank or contains whitespace, raises `ConfigurationError`. An empty list advertises nothing. +- `resource_name` (str | None, default `None`) - human-readable name for the resource. +- `resource_documentation` (str | None, default `None`) - URL of documentation for the resource. + +The last three are left out of the metadata document when you do not pass them. The document has no `jwks_uri`. + +For where the metadata is served and what the middleware returns on a bad token, see [Token Verification](./TokenVerification.md#what-the-middleware-returns). + +## Related + +- [Token Verification](./TokenVerification.md) - what the middleware checks and returns. +- [Writing an Adapter](./WritingAnAdapter.md) - serving the metadata from another framework. +- [Token Caching](./TokenCaching.md) - `token_store` and `scope_matching`. +- [Organization Policy](./OrganizationPolicy.md) - `organization_policy` and `organization_id`. diff --git a/packages/auth0-ai-mcp/docs/OnBehalfOf.md b/packages/auth0-ai-mcp/docs/OnBehalfOf.md new file mode 100644 index 0000000..08d96f1 --- /dev/null +++ b/packages/auth0-ai-mcp/docs/OnBehalfOf.md @@ -0,0 +1,71 @@ +# On-Behalf-Of Token Exchange + +The On-Behalf-Of (OBO) exchange trades the caller's incoming token for a narrower token scoped to a specific downstream API, so a tool can call that API as the user without ever forwarding the user's original token. + +The exchange uses RFC 8693 through `auth0-api-python`. Auth0 is the authority on which downstream audiences a given client may exchange for, so the audience you pass must be one Auth0 is configured to issue tokens for. The `client_id` and `client_secret` options this needs are described in [Configuration](./Configuration.md). + +## Tenant setup + +The exchange only works when the tenant is set up for it. The [example README](../examples/b2b_ecommerce/README.md#what-you-need-on-the-tenant) has the full commands. + +- The `client_id` must belong to a Custom API client, which is a client with `app_type` set to `resource_server` and linked to your MCP server's API. +- On-Behalf-Of must be enabled on that client. See the [Auth0 guide](https://auth0.com/docs/secure/call-apis-on-users-behalf/on-behalf-of-token-exchange). +- Each downstream API must allow token swaps without user consent (`skip_consent_for_verifiable_first_party_clients`), otherwise Auth0 refuses the swap with a `400`. + +Failures from Auth0, such as a refused swap, arrive as `ApiError` (importable from `auth0_ai_mcp`) with the Auth0 error in `code` and the HTTP status in `status_code`. + +## Calling the exchange inside a tool + +Inside a tool, get the request-scoped client with `require_caller_mcp_client()` and call `get_token_on_behalf_of` on it: + +```python +from auth0_ai_mcp import require_caller_mcp_client, with_required_scopes + +@mcp.tool() +@with_required_scopes("read:weather") +async def get_weather(location: str) -> dict: + auth0 = require_caller_mcp_client() + token = await auth0.get_token_on_behalf_of(audience="https://weather-api/", scope="read:weather") + # call the downstream API with token["access_token"] +``` + +The full signature is: + +```python +RequestScopedMcpClient.get_token_on_behalf_of(*, audience: str, scope: str | None = None) -> TokenSet +``` + +The returned `TokenSet` carries `access_token` and `expires_at`, plus `granted_scopes` when the token response carried a `scope`. Pass `token["access_token"]` to the downstream HTTP call. + +For how to read the caller and the request-scoped client, see [RequestContext.md](RequestContext.md). + +## Downscope protection + +If the exchanged token comes back with fewer scopes than were requested, `get_token_on_behalf_of` raises `DownscopedTokenError`, so tool code never receives a token that is missing scopes it asked for. + +The token store still caches the exchanged token under the scopes it was actually granted. See [Scope re-check on every hit](./TokenCaching.md#scope-re-check-on-every-hit). + +The check only runs when the token response carries a `scope` field. An absent `scope` field means the grant matched the request, which is the normal case per RFC 6749 section 5.1, so nothing is rejected in that case. + +Client credentials runs the same check (see below). Token Vault does not, because its scopes are set on the connection (see [TokenVault.md](TokenVault.md)). + +## Expired results + +If the exchanged token comes back already expired or without a usable expiry, `get_token_on_behalf_of` raises `GetTokenByExchangeProfileError` with a message that says which. + +## Caching + +By default there is no cache and every call to `get_token_on_behalf_of` performs a fresh exchange. To add a cache in front of the exchange, pass a `token_store` to `create_mcp_client`. See [TokenCaching.md](TokenCaching.md) for the store interface, cache-key details, and scope-matching modes. + +## Server-identity calls (client credentials) + +Some tools need to act as the server itself rather than on behalf of a specific user. For those, `get_client_credentials_token(*, audience, scope=None)` on either `McpServerClient` or `RequestScopedMcpClient` requests a token for the server itself using the configured `client_id` and `client_secret`. + +```python +token = await auth0.get_client_credentials_token(audience="https://internal-api/", scope="run:jobs") +# use token["access_token"] +``` + +The result has the same `TokenSet` shape, including `granted_scopes`. It runs the same downscope check and raises `DownscopedTokenError`, and it raises `GetClientCredentialsTokenError` when the token is already expired or has no usable expiry. + +`scope` does not narrow this token. Auth0 returns the full grant of the client, so the downscope check cannot fire when the token carries more scopes than requested, and a scope the client was not granted is refused. Keep the client grant minimal. diff --git a/packages/auth0-ai-mcp/docs/OrganizationPolicy.md b/packages/auth0-ai-mcp/docs/OrganizationPolicy.md new file mode 100644 index 0000000..ab7762a --- /dev/null +++ b/packages/auth0-ai-mcp/docs/OrganizationPolicy.md @@ -0,0 +1,52 @@ +# Organization Policy + +Organization policy controls whether tokens carrying an Auth0 Organization `org_id` claim are required, and which organizations are accepted. + +Set it with the `organization_policy` argument on `create_mcp_client`. + +## Modes + +- `"allow"` (the default) - accepts tokens whether or not they carry an `org_id` claim. Tokens from organization members and tokens without any organization both pass verification. +- `"required"` - the token must carry an `org_id` claim. Tokens without one are rejected with a `401`. When `organization_id` is also set, only tokens from the specified organization (or one of the specified organizations) are accepted. + +## Setting an organization ID + +`organization_id` pins accepted tokens to a specific organization or a set of organizations. It takes a single string or a list of strings. + +```python +from auth0_ai_mcp import create_mcp_client + +client = create_mcp_client( + domain="your-tenant.auth0.com", + audience="https://your-mcp-server/mcp", + organization_policy="required", + organization_id="org_abc", +) +``` + +`organization_id` is only valid with `organization_policy="required"`. Setting it with `"allow"` (the default) raises `ConfigurationError` when the client is created. + +A token from a rejected organization looks like any other bad token to the caller, a generic `401`. + +To accept tokens from multiple organizations: + +```python +client = create_mcp_client( + domain="your-tenant.auth0.com", + audience="https://your-mcp-server/mcp", + organization_policy="required", + organization_id=["org_abc", "org_xyz"], +) +``` + +> [!IMPORTANT] +> The Auth0 application you use for the On-Behalf-Of exchange needs `organization_usage` set to `allow`. Without it, an exchange for a user who signed in through an organization is refused with a `400` and `parameter organization is not allowed for this client`. + +## Reading org_id in tool code + +Once a token passes verification, `principal.org_id` holds the organization from the token's `org_id` claim. It is `None` for tokens that carry no Organization. See [Token Verification](./TokenVerification.md) for the full `Principal` field reference. + +## Related + +- [Token Verification](./TokenVerification.md) - the `Principal.org_id` field and how claims are checked. +- [Request Context](./RequestContext.md) - reading the `Principal` inside a tool. diff --git a/packages/auth0-ai-mcp/docs/RequestContext.md b/packages/auth0-ai-mcp/docs/RequestContext.md new file mode 100644 index 0000000..f8840eb --- /dev/null +++ b/packages/auth0-ai-mcp/docs/RequestContext.md @@ -0,0 +1,51 @@ +# Request Context + +The adapter sets a per-request `contextvar` after verifying the incoming token, so tool code can read the verified caller without taking an auth argument. + +## Accessor functions + +All four accessors are importable from `auth0_ai_mcp`: + +```python +from auth0_ai_mcp import ( + get_caller_principal, + require_caller_principal, + get_caller_mcp_client, + require_caller_mcp_client, +) +``` + +- `get_caller_principal() -> Principal | None` - returns the verified `Principal` for the current request, or `None` when called outside a request, for example in startup code, in a test that has not set up a context, or in a plain `threading.Thread`. A task started inside the request with `asyncio.create_task`, or code run with `asyncio.to_thread`, still sees the caller. Such a task keeps the caller's token, so an exchange from it fails once that token has expired. +- `require_caller_principal() -> Principal` - same as `get_caller_principal` but raises `UnauthenticatedError` instead of returning `None`. +- `get_caller_mcp_client() -> RequestScopedMcpClient | None` - returns the request-scoped client, or `None` outside a request. +- `require_caller_mcp_client() -> RequestScopedMcpClient` - same as above but raises `UnauthenticatedError` instead of returning `None`. Use this inside tool handlers where the adapter guarantees a caller is present, so you get a clear error rather than a silent `None`. + +## RequestScopedMcpClient + +`RequestScopedMcpClient` wraps the shared `McpServerClient` bound to one request's token and principal. It exposes four methods: + +- `get_token_on_behalf_of(*, audience: str, scope: str | None = None) -> TokenSet` - exchanges the caller's incoming token for a downstream token scoped to `audience`. For what this exchange does and how the optional cache works in front of it, see [On-Behalf-Of](./OnBehalfOf.md) and [Token Caching](./TokenCaching.md). +- `get_client_credentials_token(*, audience: str, scope: str | None = None) -> TokenSet` - see [On-Behalf-Of](./OnBehalfOf.md#server-identity-calls-client-credentials). +- `get_token_for_connection(*, connection: str, login_hint: str | None = None) -> TokenSet` - see [Token Vault](./TokenVault.md). +- `get_principal() -> Principal` - returns the verified `Principal` for this request. + +The incoming token is closed over inside `RequestScopedMcpClient`, so a tool cannot accidentally forward or leak it. + +## Setting the context (adapters) + +Adapters set the context with the `bind_request_context(context)` context manager and a `RequestContext(principal=..., mcp_client=...)`, both importable from `auth0_ai_mcp`. See [Writing an Adapter](./WritingAnAdapter.md#3-build-and-set-the-request-context). + +## Example + +```python +from auth0_ai_mcp import get_caller_principal, require_caller_mcp_client + +principal = get_caller_principal() # principal.sub, principal.scopes, principal.org_id +auth0 = require_caller_mcp_client() # request-scoped client for token exchanges +``` + +## Related + +- [Token Verification](./TokenVerification.md) - how the `Principal` is built and placed in context. +- [Scope Enforcement](./ScopeEnforcement.md) - gating a tool on the caller's scopes before reading context. +- [On-Behalf-Of](./OnBehalfOf.md) - the token exchange triggered by `get_token_on_behalf_of`. diff --git a/packages/auth0-ai-mcp/docs/ScopeEnforcement.md b/packages/auth0-ai-mcp/docs/ScopeEnforcement.md new file mode 100644 index 0000000..27a5e0a --- /dev/null +++ b/packages/auth0-ai-mcp/docs/ScopeEnforcement.md @@ -0,0 +1,82 @@ +# Scope Enforcement + +Scope enforcement gates a tool on the scopes the caller holds in their incoming token - the authorization they were granted for your MCP server - before the tool body runs. + +This is separate from the scopes of any downstream token minted by the On-Behalf-Of exchange. See [On-Behalf-Of](./OnBehalfOf.md) for downstream token scopes. + +## Decorator form + +`with_required_scopes(scopes)` is a decorator for async tool handlers. It reads the verified caller from the request context and checks the scopes before the tool body runs. `scopes` is one scope as a string or several as a list. Matching is exact, so a token with `tool:admin` does not satisfy `tool:read`. If you want a broader scope to imply narrower ones, put both in the token or check inside the tool. + +```python +from auth0_ai_mcp import with_required_scopes + +@mcp.tool() +@with_required_scopes(["read:orders", "read:payments"]) +async def get_order_summary(order_id: str) -> dict: + ... +``` + +Keep `@mcp.tool()` as the top decorator. If it sits below, the framework registers the unguarded function and the check is skipped. + +If the caller has no context entry at all (called outside a request), it raises `UnauthenticatedError`. If any of the listed scopes is missing from `principal.scopes`, it raises `InsufficientScopeError`. + +A string is always one scope, so pass a list when several are needed. An empty string, an empty list, or a list with a blank, non-string or whitespace-containing item raises `ConfigurationError`, and so does decorating a function that is not `async`. `with_required_scopes` takes a single argument, so passing several separate arguments, such as `with_required_scopes("a", "b")`, raises `TypeError`. + +## Imperative form + +`require_scopes(principal, scopes)` is the imperative version. Pass the `Principal` yourself, which is useful when you have already fetched the caller and want to check a secondary scope partway through a tool. + +```python +from auth0_ai_mcp import get_caller_principal, require_scopes + +async def my_tool() -> dict: + principal = get_caller_principal() + require_scopes(principal, "write:orders") + ... +``` + +The same error rules apply: `UnauthenticatedError` when `principal` is `None`, `InsufficientScopeError` when a scope is missing. + +## Errors + +Both `with_required_scopes` and `require_scopes` raise from `auth0_ai_mcp`: + +- `UnauthenticatedError` - no caller in context (adapter did not run, or called outside a request). +- `InsufficientScopeError` - caller is authenticated but is missing one or more required scopes. +- `ConfigurationError` - the `scopes` argument is empty or malformed, or the decorated function is not async. + +These errors are raised inside the tool, so on `mcp` 2.x the framework reports them as a tool error and the HTTP response is still successful. The client sees `Error executing tool X: Missing one or more required scopes.`, so the model can tell it is a scope problem. There is no `403` with an `insufficient_scope` challenge, so a client cannot step up on it. Each scope denial is logged at INFO. This was checked on `mcp` 2.3. + +## Tenant-side setup: scopes must be in the client-grant + +`with_required_scopes` checks `principal.scopes`, which comes from the incoming token. Scope checks read only the `scope` claim and never the RBAC `permissions` claim. Auth0 only includes a scope in a token if three things are true. The scope is registered on the API (resource server), the MCP client's client-grant explicitly lists it, and the client asks for it at login. + +Registering a scope on the API is not enough on its own. If the client-grant for your MCP client does not include the scope, Auth0 will not issue it in the token and the scope check will fail for every caller, even after a fresh login. + +For a third party client the grant is only a ceiling. A login that asks for no scopes gets a token with an empty `scope` claim and Auth0 reports no error, so the check fails even when the user holds the permission through RBAC. The MCP client requests the scope at login when you advertise it in `scopes_supported`. + +When you add a new gated tool: + +1. Register the new scope on your MCP API. +2. Update the client-grant for the MCP client to include it. + +```bash +# List existing grants to find the right grant ID +auth0 api get "client-grants?audience=YOUR_MCP_AUDIENCE" + +# Patch the grant with the full updated scope list +auth0 api patch "client-grants/YOUR_GRANT_ID" \ + --data '{"scope":["read:orders","write:orders"]}' +``` + +> [!IMPORTANT] +> A grant update replaces the whole scope list, so include every existing scope plus the new one. + +After updating the grant, users must re-authenticate so the new token carries the added scope. + +## Related + +- [Request Context](./RequestContext.md) - how the caller ends up in context and the accessor functions. +- [Token Verification](./TokenVerification.md) - how `principal.scopes` is populated from the incoming token. +- [On-Behalf-Of](./OnBehalfOf.md) - the separate exchange that mints downstream tokens. diff --git a/packages/auth0-ai-mcp/docs/TokenCaching.md b/packages/auth0-ai-mcp/docs/TokenCaching.md new file mode 100644 index 0000000..22bf761 --- /dev/null +++ b/packages/auth0-ai-mcp/docs/TokenCaching.md @@ -0,0 +1,124 @@ +# Token Caching + +The OBO cache is optional and off by default. Without a `token_store`, every call to `get_token_on_behalf_of` performs a fresh exchange and nothing is persisted anywhere. There is no default in-memory cache and no built-in `MemoryTokenStore` class. + +To enable caching, pass a concrete `AbstractTokenStore` implementation to `create_mcp_client`: + +```python +from auth0_ai_mcp import create_mcp_client + +# store is your AbstractTokenStore implementation. +client = create_mcp_client( + domain, audience, + client_id="...", client_secret="...", + token_store=store, + scope_matching="strict", +) +``` + +## The store interface + +`AbstractTokenStore` is exported from `auth0_ai_mcp` and defined in `auth0-api-python`. It is abstract. You provide a concrete implementation with three async methods, `get`, `set`, and `delete`. The base class takes a `secret` of at least 32 characters (for example from `openssl rand -hex 32`) and gives you `encrypt(key, value)` and `decrypt(key, data)` helpers. The SDK passes plain values to `set`, so call `self.encrypt(key, value)` in `set` and `self.decrypt(key, data)` in `get`. Otherwise the store holds tokens in plain text. + +The package ships no concrete store. The [`examples/b2b_ecommerce`](../examples/b2b_ecommerce/) example includes in-memory `InMemoryTokenStore` and `InMemoryIndexedTokenStore` classes in `token_store.py`. They show the shape of a store but lose every token on restart, so a production server needs a shared store such as Redis. See [A Redis store](#a-redis-store). + +> [!WARNING] +> A production store has to handle expiry itself, because the SDK never calls `delete` and never cleans up old entries. Every value passed to `set` carries an `expires_at` Unix timestamp, so set a matching TTL when you write it. + +A cached token is reused only while it has more than 30 seconds left before it expires. + +If the store raises on a read, the SDK logs a warning and treats it as a cache miss. If it raises on a write, the SDK logs a warning and still returns the freshly exchanged token. A store outage costs extra exchanges but never fails a tool call. + +## Cache key + +The cache key is derived from the caller's `sub`, the `audience`, the organization, the scopes, and the session. On Auth0 an access token has no `sid`, and on the default profile no `jti` either, so the session part is a digest of the incoming access token. Reuse therefore happens only across calls with the same access token. After a refresh the user gets a new set of entries, and the old ones stay until `expires_at`. Tokens are stored under the scopes Auth0 actually granted (see "Scope re-check on every hit" below), so two requests from the same user that end up with different granted scopes are separate cache entries under `"strict"` mode. + +M2M tokens from `get_client_credentials_token` are keyed on tenant, client ID, audience, and scope set. Connection tokens from `get_token_for_connection` are keyed on tenant, client ID, `sub`, `connection`, and `login_hint` with no scope dimension. Scope-matching modes (`strict` and `non_strict`) apply to OBO tokens only. + +## Scope-matching modes + +`scope_matching` controls when a cached token is considered a hit. + +- `"strict"` is the default. It reuses a cached token only when the granted scopes exactly equal the requested scope set, so a different scope set is simply a different entry. +- `"non_strict"` reuses a cached token whenever its granted scopes cover the requested scopes. To do this without one scope set evicting another, it keeps every distinct token plus an index of the scopes each was granted, so any cached token whose scopes cover the request can be reused. This requires a store that subclasses `IndexedTokenStore` (see below). Using `"non_strict"` with a plain store, or with no `token_store`, raises `ConfigurationError` at construction. + +`"strict"` is the safer default because it never silently widens access. Use `"non_strict"` when your tools request overlapping scope sets and you want to avoid redundant exchanges. + +## The indexed store + +`IndexedTokenStore` extends `AbstractTokenStore` with two async methods that manage the index. Each index member is a `TokenIndexMember` holding a `token_key`, its `granted_scopes`, and its `expires_at`. + +- `add_index_member(index_key, member)` adds the member, replacing any member with the same `token_key`. +- `list_index_members(index_key)` returns every member, or `[]` when the index does not exist. It may include expired members, because the SDK skips those on read. + +> [!IMPORTANT] +> `add_index_member` must be atomic. Two concurrent exchanges for the same user and audience can add to the same index at once, and a read-modify-write without a lock or an atomic store operation (such as a Redis hash field write) loses one of them. + +The SDK never removes members from an index, so the store should expire the whole index key. Pushing its TTL out to the latest member's `expires_at` on every add keeps it alive exactly as long as its newest token. + +## A Redis store + +A store for any shared key-value database follows the same three steps. Encrypt on `set`, decrypt on `get`, and give each entry a TTL that matches `expires_at`. This one uses an async `redis.asyncio.Redis` client. + +```python +import time + +from auth0_ai_mcp import AbstractTokenStore, TokenSet + + +class RedisTokenStore(AbstractTokenStore): + def __init__(self, redis_client, *, secret: str) -> None: + super().__init__(secret=secret) + self._redis = redis_client + + async def get(self, key: str) -> TokenSet | None: + raw = await self._redis.get(key) + if raw is None: + return None + # Redis returns bytes by default and decrypt expects str. + if isinstance(raw, bytes): + raw = raw.decode() + return self.decrypt(key, raw) + + async def set(self, key: str, value: TokenSet) -> None: + ttl = value["expires_at"] - int(time.time()) + if ttl > 0: + await self._redis.set(key, self.encrypt(key, value), ex=ttl) + + async def delete(self, key: str) -> None: + await self._redis.delete(key) +``` + +For `scope_matching="non_strict"`, subclass it with `IndexedTokenStore`. A Redis hash with one field per token makes `add_index_member` a single atomic `HSET`, so concurrent adds never lose each other. + +```python +import json +import time + +from auth0_ai_mcp import IndexedTokenStore, TokenIndexMember + + +class RedisIndexedTokenStore(RedisTokenStore, IndexedTokenStore): + async def add_index_member(self, index_key: str, member: TokenIndexMember) -> None: + await self._redis.hset(index_key, member["token_key"], json.dumps(member)) + + async def list_index_members(self, index_key: str) -> list[TokenIndexMember]: + raw = await self._redis.hgetall(index_key) + now = int(time.time()) + live: list[TokenIndexMember] = [] + expired_fields = [] + for field, value in raw.items(): + member = json.loads(value) + if member["expires_at"] > now: + live.append(member) + else: + expired_fields.append(field) + # Drop expired fields so the hash does not grow as tokens age out. + if expired_fields: + await self._redis.hdel(index_key, *expired_fields) + return live +``` + +## Scope re-check on every hit + +The scopes Auth0 actually granted can be narrower than what was requested. Both modes store the token under its granted scopes and record those granted scopes alongside it, so a downscoped token is cached. On a hit, `"strict"` reuses the token only when the granted scopes exactly equal the request, and `"non_strict"` reuses it only when the granted scopes still cover the request. A request with no scopes never reuses a cached token under `"non_strict"`. diff --git a/packages/auth0-ai-mcp/docs/TokenVault.md b/packages/auth0-ai-mcp/docs/TokenVault.md new file mode 100644 index 0000000..344220b --- /dev/null +++ b/packages/auth0-ai-mcp/docs/TokenVault.md @@ -0,0 +1,78 @@ +# Token Vault + +`get_token_for_connection` exchanges the caller's Auth0 token for a federated provider token (such as a Google or Slack access token) via Auth0 Token Vault, so a tool can call a third-party API as the user without the user's original token ever leaving the server. + +## Signatures + +When calling from `McpServerClient` directly (for example in custom adapter code), you supply the verified token explicitly: + +```python +McpServerClient.get_token_for_connection(verified, *, connection: str, login_hint: str | None = None) -> TokenSet +``` + +Inside a tool, use the request-scoped client. It closes over the request's verified token internally, so the tool never touches it: + +```python +RequestScopedMcpClient.get_token_for_connection(*, connection: str, login_hint: str | None = None) -> TokenSet +``` + +## Using it inside a tool + +```python +from auth0_ai_mcp import require_caller_mcp_client, with_required_scopes + +@mcp.tool() +@with_required_scopes("tool:list_calendar_events") +async def list_calendar_events() -> str: + auth0 = require_caller_mcp_client() + token_set = await auth0.get_token_for_connection(connection="google-oauth2") + # use token_set["access_token"] to call the Google API +``` + +The returned `TokenSet` carries `access_token` and `expires_at`, plus `granted_scopes` when the token response carried a `scope`. Token Vault results are not downscope-checked (see [Downscope protection](./OnBehalfOf.md#downscope-protection)). If the returned token is already expired or has no usable expiry, `get_token_for_connection` raises `GetAccessTokenForConnectionError`. + +The scope in `with_required_scopes` must be in the caller's token (see [ScopeEnforcement.md](ScopeEnforcement.md#tenant-side-setup-scopes-must-be-in-the-client-grant)). The user must also have linked a Google account first, or the exchange fails with HTTP 401 `federated_connection_refresh_token_not_found`. In the SDK it arrives as an `ApiError` with that value in `code`, so a tool can match on it. See [Linking an account](#linking-an-account). + +## login_hint + +Pass `login_hint` only to pick one account when the user has linked several accounts on the same connection. A hint that matches none of them gives a 401. It does not prefill anything, because the exchange is a single back channel call. + +## Caching + +If a `token_store` was passed to `create_mcp_client`, connection tokens are cached as described in [Token Caching](./TokenCaching.md#cache-key). + +## Auth0 setup requirements + +The Token Vault grant must be enabled on a Custom API client linked to your MCP server's API, because Auth0 accepts the caller's access token as the subject only from that client type. A regular application fails with "This client is not a resource server and cannot exchange access tokens". [Step 2 of the example README](../examples/b2b_ecommerce/README.md#2-create-the-obo-client) creates this client. The federated connection must be configured and enabled on the same client. The user must have linked their external account via Auth0's Connected Accounts flow before a token can be exchanged. + +- [Configure Token Vault](https://auth0.com/docs/secure/tokens/token-vault/configure-token-vault) +- [Connected Accounts for Token Vault](https://auth0.com/docs/secure/tokens/token-vault/connected-accounts-for-token-vault) + +### Linking an account + +The Auth0 guides above cover the background. The steps below fill the gaps those docs leave out. + +**Tenant setup (one time)** + +Register a "My Account API" in the Auth0 Dashboard with audience `https://{your-domain}/me/` and add three permissions: `create:me:connected_accounts`, `read:me:connected_accounts`, and `delete:me:connected_accounts`. Then open the google-oauth2 connection settings and enable Connected Accounts (`connected_accounts.active: true`, `cross_app_access: true`) with `offline_access` turned on. + +**The connect/complete flow** + +To create a vault entry for a user, your app runs a server-side flow using a Regular Web App client and the My Account API audience. The steps are below. + +1. Get a My Account token for the user via an Authorization Code flow with `scope=create:me:connected_accounts` and `audience=https://{domain}/me/`. +2. POST to `/me/v1/connected-accounts/connect` with that token and body `{"connection": "google-oauth2", "redirect_uri": "YOUR_CALLBACK_URL"}`. The response includes `auth_session`, `connect_uri`, and `connect_params.ticket`. +3. Redirect the user's browser to `{connect_uri}?ticket={ticket}`. The ticket expires in 300 seconds, so redirect immediately rather than waiting for manual action. +4. After the user consents on Google, Auth0 redirects to your callback URL with a `connect_code` query parameter. +5. POST to `/me/v1/connected-accounts/complete` with the same My Account token and body `{"auth_session": "...", "connect_code": "...", "redirect_uri": "..."}`. This call creates the vault entry. + +**Gotchas** + +> [!IMPORTANT] +> `/complete` is mandatory. Auth0 does not create the vault entry when it redirects to your callback. The entry is only created when your server calls `/complete` with the `connect_code`. + +- `connect_code` arrives as a query parameter on the callback URL. Some Auth0 documentation describes it as a URL fragment. It is not. +- Auth0 returns 404 (not 403) when the path is wrong or the token is missing a required scope. If you see unexpected 404s, check both. +- The correct paths are `/me/v1/connected-accounts/accounts` (list and delete), `/me/v1/connected-accounts/connect`, and `/me/v1/connected-accounts/complete`. The path `/me/connected-accounts` without `v1` does not exist. +- The callback server must remain alive between the two callbacks (the first from Auth0 after login, the second after Google consent). A one-shot request handler shuts down too early. +- The Google account email does not need to match the Auth0 user's email. diff --git a/packages/auth0-ai-mcp/docs/TokenVerification.md b/packages/auth0-ai-mcp/docs/TokenVerification.md new file mode 100644 index 0000000..021e470 --- /dev/null +++ b/packages/auth0-ai-mcp/docs/TokenVerification.md @@ -0,0 +1,62 @@ +# Token Verification + +Every incoming bearer token is verified against Auth0 before an MCP handler runs. A request without a token, or a token that fails verification, gets a `401` and never reaches a tool. See [What the middleware returns](#what-the-middleware-returns). + +## How verification works + +Verification runs inside the adapter via `auth0-api-python`. The library checks the signature against the tenant's JWKS, plus `exp`, `iss` and `aud`. `McpServerClient.verify_access_token(token)` returns a `VerifiedToken` whose `claims` hold the verified claims. The adapter then builds a `Principal` with `build_principal`, which also checks `sub`, and sets it in the per-request `contextvar`. On failure it short-circuits the request with a `401` before any tool code runs. The organization policy also applies during verification, see [Organization Policy](./OrganizationPolicy.md). + +The adapter also serves the RFC 9728 `/.well-known/oauth-protected-resource` metadata document, which tells clients where to find the authorization server for your resource. + +## The Principal dataclass + +`Principal` is a dataclass built from the verified token's claims. Tool code normally reads it through the request context (see [Request Context](./RequestContext.md)) rather than constructing one directly. + +```python +from auth0_ai_mcp import Principal +``` + +Fields: + +- `sub` (`str`): the subject claim identifying the caller. +- `expires_at` (`int | float`): unix epoch seconds the token expires, from the `exp` claim. +- `scopes` (`list[str]`): parsed from the space-separated `scope` claim. Empty list when the claim is absent. +- `permissions` (`list[str] | None`): from the `permissions` claim. `None` when the claim is absent, which happens when RBAC is off or the API does not add permissions to the access token. An empty or populated list when present. +- `client_id` (`str`): from the `azp` claim, falling back to `client_id`. Never `None`, because a token with neither is rejected with a `401`. +- `org_id` (`str | None`): from the `org_id` claim. `None` when the token carries no Organization. + +`build_principal(claims)` (importable from `auth0_ai_mcp`) builds the `Principal` and raises `VerifyAccessTokenError` for a missing or blank `sub`, an `exp` that is missing, not a number, a boolean, or not finite, no `azp` or `client_id`, a `permissions` claim that is not a list of strings, or an `org_id` that is not a string. + +## VerifyAccessTokenError + +The middleware catches `VerifyAccessTokenError` (imported from `auth0-api-python`) and returns the `401` (or a `503` for its subclass `AuthServerUnavailableError`, or for a `DomainsResolverError`, which is not a `VerifyAccessTokenError`), so it is never raised to the caller. Tool code does not catch this error because the token is verified before any tool runs. You only need to handle it if you call `McpServerClient.verify_access_token` yourself in custom adapter code. + +## What the middleware returns + +The middleware answers each case as follows: + +- No token, a blank `Bearer` value, or any scheme other than `Bearer` - a `401` with a bare challenge of the form `Bearer resource_metadata=""` and no `error`. When `scopes_supported` is set, both `401` challenges also carry `scope=""`, the same scopes as the metadata document, so the client knows what to request at login. The body message is "Unauthorized". DPoP is not supported yet, so a `DPoP` authorization is treated as no token. +- A bearer value with whitespace inside it - the same `401` with `error="invalid_token"`, without calling Auth0. +- A token that fails verification - a `401` with `error="invalid_token"` and the fixed description "The access token is not valid". The real reason is only written to the server log. +- A token bound to a DPoP key or a client certificate (it has a `cnf` claim) - the same `401`, even when it is sent as `Bearer`. +- Auth0 cannot be reached to fetch the signing keys - verification raises `AuthServerUnavailableError` and the middleware returns a `503` with `Retry-After: 5` and no `WWW-Authenticate` challenge, so the client keeps its token and retries. +- The `domains_resolver` of a prebuilt `api_client` fails - verification raises `DomainsResolverError` and the middleware returns the same `503`, with a warning in the server log. +- Any other `VerifyAccessTokenError` - a `401`. Any other exception propagates to the framework. +- Every `401` and `503` response carries `Cache-Control: no-store`, so a proxy never caches an auth failure. +- A WebSocket connection - closed with code 1008. +- An `OPTIONS` request - passes through to the app without verification. + +> [!WARNING] +> DPoP is not supported. The middleware verifies every token as a plain bearer token, so DPoP options on a prebuilt `api_client` have no effect. + +The `resource_metadata` value in the challenge is the path-suffixed metadata URL from RFC 9728. For example, a resource URL of `https://host/mcp` gives `https://host/.well-known/oauth-protected-resource/mcp`. The metadata document is served for `GET` only, at `/.well-known/oauth-protected-resource` and at the path-suffixed path. + +When the `mcp` package is installed, the middleware also sets `scope["user"]` and `scope["auth"]` for the MCP session manager, with the raw token replaced by the literal "redacted". + +To build the same header in custom code, use `build_www_authenticate(resource_metadata_url, error=None, error_description=None, scope=None)`, imported from `auth0_ai_mcp.middleware`. It makes every value safe for a quoted header. + +## Related + +- [Request Context](./RequestContext.md) - reading the verified `Principal` inside a tool. +- [Organization Policy](./OrganizationPolicy.md) - requiring or filtering by `org_id`. +- [Writing an Adapter](./WritingAnAdapter.md) - how an adapter calls into the core verification logic. diff --git a/packages/auth0-ai-mcp/docs/WritingAnAdapter.md b/packages/auth0-ai-mcp/docs/WritingAnAdapter.md new file mode 100644 index 0000000..468885d --- /dev/null +++ b/packages/auth0-ai-mcp/docs/WritingAnAdapter.md @@ -0,0 +1,95 @@ +# Writing an Adapter + +An adapter's whole job is to verify the incoming token, put the caller into the request context before the MCP handler runs, and serve the protected-resource metadata. + +`auth0-ai-mcp` is framework-agnostic, so any MCP framework can be wired up using the same four-step pattern. Most users will not need to write one from scratch because the package ships a ready-made ASGI middleware. + +The snippets use the 2.x import `mcp.server.mcpserver`. The `mcp` extra requires 2.2 or later, and the tool error text described in these guides was checked on 2.3. + +## Ready-made integrations + +**ASGI (Starlette, FastAPI, official MCP app).** Import `Auth0AsgiMiddleware` from `auth0_ai_mcp.middleware`: + +```python +from auth0_ai_mcp import create_mcp_client +from auth0_ai_mcp.middleware import Auth0AsgiMiddleware + +mcp_client = create_mcp_client(DOMAIN, AUDIENCE, client_id="...", client_secret="...") +app = Auth0AsgiMiddleware( + mcp.streamable_http_app(host="127.0.0.1"), + mcp_client=mcp_client, +) +``` + +> [!WARNING] +> The MCP SDK checks the Host and Origin headers when `streamable_http_app(host=...)` is `127.0.0.1`, `localhost` or `::1`, which is also the default. Any other value, such as `"0.0.0.0"`, turns the checks off. + +For a public URL, keep the checks on and list your host. Without this every request to a public host gets a `421`. A request with an `Origin` header that is not in `allowed_origins` gets a `403`, so list the origins of any browser client there. + +```python +from mcp.server.transport_security import TransportSecuritySettings + +app = Auth0AsgiMiddleware( + mcp.streamable_http_app( + transport_security=TransportSecuritySettings(allowed_hosts=["your-mcp-server", "your-mcp-server:*"]), + ), + mcp_client=mcp_client, +) +``` + +**Prefect FastMCP.** Use the `auth0-ai-fastmcp` package. FastMCP users do not need to hand-write an adapter. + +## Writing an adapter for another framework + +If neither built-in option covers your framework, follow these four steps. + +### 1. Pull and validate the bearer token + +Read the `Authorization` header. If the token is missing or blank, or the scheme is not `Bearer`, respond `401` and stop. The challenge should carry `resource_metadata` and no `error`, and `scope` if you advertise scopes. Build the header with `build_www_authenticate(resource_metadata_url, scope=...)` from `auth0_ai_mcp.middleware`. The request must never reach a tool handler without a verified token. + +### 2. Verify the token and get the caller + +```python +from auth0_ai_mcp import build_principal + +verified = await mcp_client.verify_access_token(token) +principal = build_principal(verified.claims) +``` + +A failed verification raises `VerifyAccessTokenError`, and `build_principal` raises it too for claims it rejects. Catch it, log the real reason, and return a `401` built with `build_www_authenticate(resource_metadata_url, "invalid_token", "The access token is not valid")`. The real reason is not echoed to the client. `AuthServerUnavailableError` is a subclass that means Auth0 could not be reached, so catch it first and return a `503` without a challenge. `DomainsResolverError` is not a `VerifyAccessTokenError`, so catch it separately and return the same `503`. A `401` would make the client log in again for no reason. + +### 3. Build and set the request context + +```python +from auth0_ai_mcp import RequestContext, bind_request_context + +context = RequestContext( + principal=principal, + mcp_client=mcp_client.with_token(verified, principal), +) +with bind_request_context(context): + # run the MCP handler + ... +``` + +`bind_request_context` sets the context for the handler and always resets it, even when the handler raises. + +### 4. Serve the protected-resource metadata + +Serve `/.well-known/oauth-protected-resource` per RFC 9728. Use the helper to build the document: + +```python +from auth0_ai_mcp.middleware import build_protected_resource_metadata + +metadata = build_protected_resource_metadata(mcp_client) +``` + +Return the result as JSON with a `200` status. + +The helper reads the resource from `mcp_client.audience` and the authorization server from `mcp_client.domain`. Its options (`resource_url`, `authorization_servers`, `scopes_supported`, `resource_name` and `resource_documentation`) are described under `Auth0AsgiMiddleware` in [Configuration](./Configuration.md#auth0asgimiddleware). + +Serve it at `/.well-known/oauth-protected-resource` and also at that path followed by the path of the resource. For a resource of `https://host/mcp` that is `/.well-known/oauth-protected-resource/mcp`. RFC 9728 puts the well-known segment between the host and the path, so clients look for the path-suffixed URL first. Use the path-suffixed URL as the `resource_metadata` value in every `401` challenge. + +## What tool code sees + +Once the context is set, tool code uses `get_caller_principal()`, `require_caller_mcp_client()`, and `with_required_scopes(...)` the same way regardless of which adapter wired them up. See [RequestContext.md](RequestContext.md) for reading the caller and [ScopeEnforcement.md](ScopeEnforcement.md) for protecting tools with required scopes. diff --git a/packages/auth0-ai-mcp/references/code-style.md b/packages/auth0-ai-mcp/references/code-style.md new file mode 100644 index 0000000..ac6c878 --- /dev/null +++ b/packages/auth0-ai-mcp/references/code-style.md @@ -0,0 +1,57 @@ +# Code Style + +## Naming + +- `snake_case` functions, methods, and options. `PascalCase` classes. `UPPER_SNAKE` module constants (`_PRM_PATH`). +- Private helpers live in `utils/_internal/`, one file per topic (`exchange.py`, `urls.py`, `headers.py`), with no leading underscore on the functions. Public modules call them through the module, for example `exchange.reject_if_downscoped(...)`, and never import a helper by name. Private methods that need `self` (`_send_unauthorized`) stay on their class. +- Error classes end in `Error` and live in `errors.py`. +- 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`, and typing-only imports go under `if TYPE_CHECKING:` (see `request_context.py`). + +## Errors + +Every error owned by this package subclasses `BaseAuthError` and defines its status and error code. They are raised inside the tool, so the framework reports them as a tool error: + +```python +class DownscopedTokenError(BaseAuthError): + """The exchange granted fewer scopes than were requested. Error code `downscoped_token`, status 403.""" + + def get_status_code(self) -> int: + return 403 + + def get_error_code(self) -> str: + return "downscoped_token" +``` + +Errors for the core SDK's own functions (`VerifyAccessTokenError`, `GetAccessTokenForConnectionError`, `GetClientCredentialsTokenError`, `ApiError`) are raised by the core SDK and pass through this package unchanged. Do not wrap them. Public errors are exported from `__init__.py`, so a new one is added to `__all__` as well. + +Keep messages generic when they reach a client: + +```python +# Good (utils/_internal/exchange.py, reject_if_downscoped) +raise DownscopedTokenError("Exchanged token did not include all requested scopes.") + +# Bad +raise DownscopedTokenError(f"Missing scopes: {missing}, token: {result['access_token']}") +``` + +The bad version names the scopes and puts token material into a message that can reach an HTTP response. + +## Patterns + +- **Constructor wiring:** `create_mcp_client` builds an `ApiClient` from `ApiClientOptions` unless one is passed in, then wraps it in `McpServerClient`. New core options are forwarded there as keyword arguments. +- **Request-scoped access:** tool code reaches the caller through `get_caller_principal()` and `require_caller_mcp_client()`, never through an argument carrying the raw token. +- **Async I/O:** public verification and exchange methods are `async def`. The ASGI middleware is a plain ASGI callable. +- **Docstrings:** Google style with `Args:` / `Returns:` / `Raises:` sections, as in `build_principal` and `McpServerClient.get_token_for_connection`. +- **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 out of public files.** A private helper goes in `utils/_internal/`. A public helper is a plain-named file in `utils/` and is listed in `__all__`. diff --git a/packages/auth0-ai-mcp/references/commands.md b/packages/auth0-ai-mcp/references/commands.md new file mode 100644 index 0000000..d3bb141 --- /dev/null +++ b/packages/auth0-ai-mcp/references/commands.md @@ -0,0 +1,40 @@ +# Commands + +Run these from the `auth0-ai-mcp` directory. They match `.github/workflows/test.yml` and `CONTRIBUTING.md`. + +```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_scopes.py +poetry run pytest tests/test_client.py -k downscoped + +# 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 stubs the core client and 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. + +`publish.yml` (manual `workflow_dispatch`) builds, runs the RL scanner, and publishes to PyPI. Agents do not run releases. diff --git a/packages/auth0-ai-mcp/references/docs-update.md b/packages/auth0-ai-mcp/references/docs-update.md new file mode 100644 index 0000000..4477c2a --- /dev/null +++ b/packages/auth0-ai-mcp/references/docs-update.md @@ -0,0 +1,40 @@ +# 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, a short quick start, one callout for the `audience` rule, links to the guides | +| `EXAMPLES.md` | Runnable samples for building the client, the ASGI adapter, a gated tool, the b2b_ecommerce example | +| `docs/Configuration.md` | Every `create_mcp_client` and `Auth0AsgiMiddleware` option, the `audience` rule, `api_client` | +| `docs/TokenVerification.md` | How verification works, the `Principal` dataclass, `VerifyAccessTokenError` | +| `docs/ScopeEnforcement.md` | Decorator and imperative scope checks, errors, tenant-side client-grant setup | +| `docs/OnBehalfOf.md` | The OBO exchange inside a tool, downscope protection, client credentials | +| `docs/TokenVault.md` | `get_token_for_connection`, `login_hint`, Auth0 setup, linking an account | +| `docs/TokenCaching.md` | The store interface, cache key, scope-matching modes, the indexed store | +| `docs/OrganizationPolicy.md` | Organization modes, setting an organization ID, reading `org_id` | +| `docs/RequestContext.md` | The accessor functions and `RequestScopedMcpClient` | +| `docs/WritingAnAdapter.md` | The steps an adapter for another framework must follow | +| `examples/b2b_ecommerce/README.md` | Running the example server | + +## When you change code, update these docs + +| When this changes | Update | +|-------------------|--------| +| A public `McpServerClient` or `RequestScopedMcpClient` method added, removed, renamed, or its arguments change | `README.md`, `EXAMPLES.md`, the guide for that flow (see `references/flow-map.md`) | +| `create_mcp_client` parameter added, removed, or default changed | `docs/Configuration.md`, and `docs/TokenCaching.md` or `docs/OrganizationPolicy.md` for the related option. Then check the README and `EXAMPLES.md` snippets still match | +| Token store behavior or scope matching | `docs/TokenCaching.md` | +| Scope enforcement or the errors it raises | `docs/ScopeEnforcement.md` | +| Downscope detection or OBO behavior | `docs/OnBehalfOf.md` | +| Token Vault behavior or its errors | `docs/TokenVault.md` | +| `Principal` fields or claim handling | `docs/TokenVerification.md`, `docs/RequestContext.md` | +| Middleware behavior, challenge format, or metadata | `docs/TokenVerification.md` (what the middleware returns), `docs/Configuration.md` (metadata options), `docs/WritingAnAdapter.md` | +| A new error code or status | The guide for the flow that raises it | +| New exported symbol in `__init__.py` | `EXAMPLES.md` (add a usage sample) | +| Supported Python version or install method | `README.md` "Install" and `CONTRIBUTING.md` | + +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. diff --git a/packages/auth0-ai-mcp/references/flow-map.md b/packages/auth0-ai-mcp/references/flow-map.md new file mode 100644 index 0000000..4a472a8 --- /dev/null +++ b/packages/auth0-ai-mcp/references/flow-map.md @@ -0,0 +1,20 @@ +# Flow Map + +Before changing a flow, read its entry points and supporting helpers. `McpServerClient` is in `client.py` and its private helpers are in `utils/_internal/`. Everything marked "core SDK" lives in auth0-api-python and is only wrapped here. + +| Flow | Entry points | Also read | Docs | +|------|--------------|-----------|------| +| Request verification | `Auth0AsgiMiddleware.__call__` (`middleware.py`) | `bearer_token`, `is_well_formed_token` (`utils/_internal/headers.py`), `_send_unauthorized`, `_send_unavailable`, `McpServerClient.verify_access_token`, `build_principal`, `errors.py` | `README.md` "How it works", `docs/TokenVerification.md`, `EXAMPLES.md` "Wiring the ASGI adapter" | +| Caller identity | `build_principal`, `Principal` (`principal.py`) | `RequestContext`, `bind_request_context`, `set_request_context`, `reset_request_context`, `get_caller_principal`, `require_caller_principal` (`request_context.py`) | `docs/TokenVerification.md`, `docs/RequestContext.md` | +| Protected-resource metadata | `build_protected_resource_metadata` (`middleware.py`) | `Auth0AsgiMiddleware.__init__`, `_PRM_PATH` | `docs/WritingAnAdapter.md` step 4 | +| Scope enforcement | `require_scopes`, `with_required_scopes` (`scopes.py`) | `UnauthenticatedError`, `InsufficientScopeError`, `get_caller_principal` | `docs/ScopeEnforcement.md`, `EXAMPLES.md` "Writing a gated tool" | +| On-behalf-of exchange | `McpServerClient.get_token_on_behalf_of`, `RequestScopedMcpClient.get_token_on_behalf_of` | `reject_if_downscoped` (`utils/_internal/exchange.py`), `DownscopedTokenError`, core SDK `ApiClient.get_token_on_behalf_of` | `docs/OnBehalfOf.md` | +| Client credentials | `McpServerClient.get_client_credentials_token` | `GetClientCredentialsTokenError`, core SDK `ApiClient.get_client_credentials_token` | `docs/OnBehalfOf.md` "Server-identity calls" | +| Token Vault | `McpServerClient.get_token_for_connection` | `GetAccessTokenForConnectionError`, core SDK `ApiClient.get_access_token_for_connection` | `docs/TokenVault.md` | +| Token caching | `create_mcp_client(token_store=..., scope_matching=...)` | core SDK `AbstractTokenStore`, `IndexedTokenStore` | `docs/TokenCaching.md` | +| Organization policy | `create_mcp_client(organization_policy=..., organization_id=...)` | `Principal.org_id` | `docs/OrganizationPolicy.md` | +| Request context | `bind_request_context`, `require_caller_principal`, `get_caller_mcp_client`, `require_caller_mcp_client` (`request_context.py`) | `RequestScopedMcpClient` (`request_scoped_client.py`) | `docs/RequestContext.md` | + +Tests: `tests/test_client.py` covers the exchange, client credentials, Token Vault, and constructor wiring. `tests/test_principal.py` covers claim normalization, `tests/test_scopes.py` covers scope enforcement, `tests/test_request_context.py` covers the request context, `tests/test_middleware.py` covers the ASGI middleware and the metadata document, and `tests/test_docs.py` checks that doc snippets parse and import real names. + +Two rules cut across every flow. Fail closed with a typed error or a `401` on every failure path, and never let tool code see the raw incoming token. diff --git a/packages/auth0-ai-mcp/references/git-workflow.md b/packages/auth0-ai-mcp/references/git-workflow.md new file mode 100644 index 0000000..382e769 --- /dev/null +++ b/packages/auth0-ai-mcp/references/git-workflow.md @@ -0,0 +1,38 @@ +# Git Workflow + +## Branch naming + +`feat/`, `fix/`, `docs/`, `chore/`. Release branches are prefixed with the package name, `release/auth0-ai-mcp-`. 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: reject exchanged tokens that miss requested scopes`. 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__`, `McpServerClient`, `RequestScopedMcpClient`, `errors.py`), not from a summary of intent. + +`CODEOWNERS` assigns every path to `@auth0/project-dx-sdks-engineer-codeowner`. + +## CI workflows + +From `.github/workflows/`. Which of these are required status checks is set in branch protection, not in the repo. + +- **Build and Test** (`test.yml`): a package matrix over the supported Python versions, running `pytest -v` and `ruff check .` on PR, merge group, and push to `main` +- **CodeQL** (`codeql.yml`): on PR, merge group, push to `main`, and a weekly schedule +- **SCA** (`sca_scan.yml`): Snyk scan on push and PR to `main`, with suppressions in `.snyk` +- **Scorecard** (`scorecard.yml`): OpenSSF Scorecard checks +- **Publish** (`publish.yml`): manual dispatch, see Releases + +`ruff format` is not a check. 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 `publish.yml` (manual dispatch with a `package` input, gated on the RL scan), 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-mcp/references/pitfalls.md b/packages/auth0-ai-mcp/references/pitfalls.md new file mode 100644 index 0000000..34c9ee3 --- /dev/null +++ b/packages/auth0-ai-mcp/references/pitfalls.md @@ -0,0 +1,13 @@ +# Common Pitfalls + +1. **Do not run `ruff format .`.** CI runs only `ruff check .`. The tree is not ruff-formatted (`ruff format --check .` reports files that would be reformatted), so a repo-wide format produces a large unrelated diff. Match surrounding formatting by hand. +2. **Python 3.10 floor.** CI tests every supported version and `.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. Adding `@pytest.mark.asyncio` is harmless but unneeded. +4. **Only `PLC0415` is a preview rule.** `.ruff.toml` turns on `preview` with `explicit-preview-rules`, so no other preview rule applies. An import inside a function fails lint, move it to the top or under `TYPE_CHECKING`. +5. **The core SDK is pinned by git SHA.** `auth0-api-python` in `pyproject.toml` points at a commit, not a release. Do not bump it casually, and note that PyPI rejects git-URL dependencies, so a release needs the pin replaced by a published version first. +6. **Caching and stores are not here.** `token_store` and `scope_matching` are forwarded to the core `ApiClient`, so a caching bug is usually fixed in the core SDK. This package only passes the options through. +7. **The middleware is not in `__all__`.** Import it as `from auth0_ai_mcp.middleware import Auth0AsgiMiddleware`. A new public symbol in `middleware.py` follows the same path rather than being added to `__init__.py`. +8. **Accessors return `None` outside a request.** `get_caller_principal()` and `get_caller_mcp_client()` return `None` when no context is set, while `require_caller_mcp_client()` raises `UnauthenticatedError`. Tests that call tool code directly must set the context first. +9. **Examples read environment variables at import.** `examples/b2b_ecommerce/tools.py` indexes `os.environ` for each audience at module level, so importing it without the variables raises `KeyError`. `ruff check .` still lints it. +10. **No separate requirements file.** Dependencies live only in `pyproject.toml` and `poetry.lock`, and the Snyk scan reads them from there. +11. **Stale local venvs.** A hand-made venv in the working tree (anything other than Poetry's) may not have the package or dev dependencies installed. Use `poetry install` then `poetry run ...` so you test the same environment CI does. diff --git a/packages/auth0-ai-mcp/references/testing.md b/packages/auth0-ai-mcp/references/testing.md new file mode 100644 index 0000000..fe198ff --- /dev/null +++ b/packages/auth0-ai-mcp/references/testing.md @@ -0,0 +1,35 @@ +# 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_principal.py # one file +poetry run pytest tests/test_client.py -k downscoped # filter by name +``` + +## Conventions + +- `test_client.py`, `test_principal.py` and `test_scopes.py` use module-level `test__` functions. Follow the style of the file you are adding to. +- Assert on the error type with `pytest.raises(...)`. Where a test cares about the contract, assert `get_error_code()` and `get_status_code()` rather than message text. +- Messages that must stay generic have their own test, for example `test_insufficient_scope_message_does_not_leak_scope` and `test_downscoped_error_message_does_not_leak_scopes`. Add one when a new error could carry scope or token detail. +- Placeholders stay obviously synthetic (`user_1`, `https://tenant.example/`). Never paste a real tenant value. +- Name test doubles `Stub...`, never `Fake...`. + +## Stubbing and helpers + +- `StubApiClient` in `tests/test_client.py` stands in for the core `ApiClient`, records the exchange calls it receives, and lets a test choose the granted scope, the expiry, or an error to raise. Reuse it, and extend it when a new core method is wrapped. +- `_verified(...)` builds a `VerifiedToken` from synthetic claims and `_jwt(...)` builds an unsigned JWT that carries them. +- Behavior tests wrap the stub directly with `McpServerClient(StubApiClient(...))`. Constructor wiring tests call `create_mcp_client(...)` and inspect the `ApiClientOptions` on `client._api.options`. +- Tests that need a caller build a `RequestContext` and either run the code inside `with bind_request_context(...):` or pair `set_request_context` with `reset_request_context` in a `try`/`finally`. Both reset the context on exit so state does not leak between tests (see `tests/test_request_context.py` and `tests/test_scopes.py`). + +## Middleware tests + +`tests/test_middleware.py` drives `Auth0AsgiMiddleware` as a plain ASGI callable with a `StubApiClient` (returns claims or raises) and a `StubApp` (records whether it ran and which caller it saw). Reuse `_call` to collect the sent messages. Cover both the `401` path and the app-ran path when changing the middleware. Middleware behavior is covered in `tests/test_middleware.py` and request context in `tests/test_request_context.py`. diff --git a/packages/auth0-ai-mcp/tests/test_docs.py b/packages/auth0-ai-mcp/tests/test_docs.py new file mode 100644 index 0000000..b17d2e2 --- /dev/null +++ b/packages/auth0-ai-mcp/tests/test_docs.py @@ -0,0 +1,32 @@ +"""Every Python snippet in the README and guides parses, and the names it imports from our packages exist.""" + +import ast +import importlib +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +FENCE = re.compile(r"^```python\n(.*?)^```", re.DOTALL | re.MULTILINE) +CHECKED_PACKAGES = ("auth0_ai_mcp", "auth0_api_python") +SIGNATURE = re.compile(r"^[\w.]+\(.*\) -> ", re.MULTILINE) + + +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): + if SIGNATURE.match(source) and len(source.strip().splitlines()) == 1: + pytest.skip("method signature shown for reference, not runnable code") + tree = ast.parse(source) + + for node in ast.walk(tree): + 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}"