diff --git a/.github/workflows/martian-issue-triage.yml b/.github/workflows/martian-issue-triage.yml index 276c7142e..4cc499b85 100644 --- a/.github/workflows/martian-issue-triage.yml +++ b/.github/workflows/martian-issue-triage.yml @@ -8,7 +8,8 @@ jobs: martian-issue-triage: # For labeled events, verify the labeler is a repo member to prevent privilege escalation if: | - (github.event.action == 'opened' && contains(fromJSON('["strawgate", "jlowin"]'), github.actor)) || + (github.event.action == 'opened' && github.actor == 'strawgate') || + (github.event.action == 'opened' && github.actor == 'jlowin' && contains(github.event.issue.labels.*.name, 'bug')) || (github.event.action == 'labeled' && github.event.label.name == 'triage-martian' && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.sender.author_association)) concurrency: diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index 06f63234a..e5d0509f4 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -1,28 +1,28 @@ name: Update MCPServerConfig Schema -# This workflow runs on merges to main to automatically update the config schema -# by creating a PR when changes are needed. +# Regenerates config schema on PRs and commits it back to the branch, +# so the PR is self-contained and main is correct after merge. on: - push: + pull_request: branches: ["main"] paths: - "src/fastmcp/utilities/mcp_server_config/**" - - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" # Exclude the local schema file + - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" workflow_dispatch: permissions: contents: write - pull-requests: write jobs: update-config-schema: timeout-minutes: 5 runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@v6 - - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v2 @@ -30,6 +30,11 @@ jobs: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref || github.ref }} + token: ${{ steps.marvin-token.outputs.token }} + - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -41,51 +46,22 @@ jobs: - name: Generate config schema run: | - echo "πŸ”„ Generating fastmcp.json schema..." - - # Generate schema in docs/public for web access uv run python -c " from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/latest.json') - print('βœ… Latest schema generated in docs/public') - " - - # Also update the v1 schema in docs/public - uv run python -c " - from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/v1.json') - print('βœ… v1 schema generated in docs/public') - " - - # Generate schema in the source directory for local development - uv run python -c " - from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json') - print('βœ… Schema generated in utilities/mcp_server_config/v1/') " - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.marvin-token.outputs.token }} - commit-message: "chore: Update fastmcp.json schema" - title: "chore: Update fastmcp.json schema" - body: | - This PR updates the fastmcp.json schema files to match the current source code. - - The schema is automatically generated from `src/fastmcp/utilities/mcp_server_config/` to ensure consistency. - - **Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. - - πŸ€– Generated by Marvin - branch: marvin/update-config-schema - labels: | - ignore in release notes - delete-branch: true - author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - - - name: Summary + - name: Commit and push if changed run: | - echo "βœ… Config schema generation workflow completed" - echo "PR will be created if there are changes, or closed if schema is already up to date" + git config user.name "marvin-context-protocol[bot]" + git config user.email "225465937+marvin-context-protocol[bot]@users.noreply.github.com" + git add docs/public/schemas/ src/fastmcp/utilities/mcp_server_config/v1/schema.json + if git diff --cached --quiet; then + echo "Config schema is up to date" + else + git commit -m "chore: Update fastmcp.json schema" + git push + echo "Config schema updated and pushed" + fi diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index 10baa1e3f..122f6ddfc 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -1,10 +1,10 @@ name: Update SDK Documentation -# This workflow runs on merges to main to automatically update SDK docs -# by creating a PR when changes are needed. +# Regenerates SDK docs on PRs and commits them back to the branch, +# so the PR is self-contained and main is correct after merge. on: - push: + pull_request: branches: ["main"] paths: - "src/**" @@ -13,16 +13,16 @@ on: permissions: contents: write - pull-requests: write jobs: update-sdk-docs: timeout-minutes: 5 runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@v6 - - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v2 @@ -30,6 +30,11 @@ jobs: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref || github.ref }} + token: ${{ steps.marvin-token.outputs.token }} + - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -43,32 +48,17 @@ jobs: uses: extractions/setup-just@v3 - name: Generate SDK documentation + run: just api-ref-all + + - name: Commit and push if changed run: | - echo "πŸ”„ Generating SDK documentation..." - just api-ref-all - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ steps.marvin-token.outputs.token }} - commit-message: "chore: Update SDK documentation" - title: "chore: Update SDK documentation" - body: | - This PR updates the auto-generated SDK documentation to reflect the latest source code changes. - - πŸ“š Documentation is automatically generated from the source code docstrings and type annotations. - - **Note:** This PR is fully automated and will update itself with any subsequent changes to the SDK, or close automatically if the documentation becomes up-to-date through other means. Feel free to leave it open until you're ready to merge. - - πŸ€– Generated by Marvin - branch: marvin/update-sdk-docs - labels: | - ignore in release notes - delete-branch: true - author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>" - - - name: Summary - run: | - echo "βœ… SDK documentation generation workflow completed" - echo "PR will be created if there are changes, or closed if documentation is already up to date" + git config user.name "marvin-context-protocol[bot]" + git config user.email "225465937+marvin-context-protocol[bot]@users.noreply.github.com" + git add docs/python-sdk/ + if git diff --cached --quiet; then + echo "SDK documentation is up to date" + else + git commit -m "chore: Update SDK documentation" + git push + echo "SDK documentation updated and pushed" + fi diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx index 25804adc3..84fbe2164 100644 --- a/docs/clients/auth/oauth.mdx +++ b/docs/clients/auth/oauth.mdx @@ -55,6 +55,8 @@ You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` β€” - **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings - **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` +- **`client_id`** (`str`, optional): Pre-registered OAuth client ID. When provided, skips Dynamic Client Registration entirely. See [Pre-Registered Clients](#pre-registered-clients) +- **`client_secret`** (`str`, optional): OAuth client secret for pre-registered clients. Optional β€” public clients that rely on PKCE can omit this - **`client_metadata_url`** (`str`, optional): URL-based client identity (CIMD). See [CIMD Authentication](/clients/auth/cimd) for details - **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options - **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration @@ -74,7 +76,7 @@ The client first checks the configured `token_storage` backend for existing, val If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. -If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. Alternatively, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity instead of registering. +If a `client_id` is provided, the client uses those pre-registered credentials directly and skips this step entirely. Otherwise, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity. As a fallback, the client performs Dynamic Client Registration (RFC 7591) if the server supports it. A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:/callback`) acts as the `redirect_uri` for the OAuth flow. @@ -152,3 +154,33 @@ async with Client( ``` See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. + +## Pre-Registered Clients + + + +Some OAuth servers don't support Dynamic Client Registration β€” the MCP spec explicitly makes DCR optional. If your client has been pre-registered with the server (you already have a `client_id` and optionally a `client_secret`), you can provide them directly to skip DCR entirely. + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_id="my-registered-client-id", + client_secret="my-client-secret", + ), +) as client: + await client.ping() +``` + +Public clients that rely on PKCE for security can omit `client_secret`: + +```python +oauth = OAuth(client_id="my-public-client-id") +``` + + +When using pre-registered credentials, the client will not attempt Dynamic Client Registration. If the server rejects the credentials, the error is surfaced immediately rather than falling back to DCR. + diff --git a/docs/clients/generate-cli.mdx b/docs/clients/generate-cli.mdx index 833637423..4d05e0515 100644 --- a/docs/clients/generate-cli.mdx +++ b/docs/clients/generate-cli.mdx @@ -23,7 +23,7 @@ fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py ``` -The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If the file already exists, the command refuses to overwrite unless you pass `-f`: +The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If either the CLI file or its companion `SKILL.md` already exists, the command refuses to overwrite unless you pass `-f`: ```bash fastmcp generate-cli weather -f @@ -85,6 +85,42 @@ Options: Tool names are preserved exactly as the server defines them β€” underscores stay as underscores, so `call-tool get_forecast` matches what the server expects. +## Agent Skill + +Alongside the CLI script, `generate-cli` also writes a `SKILL.md` file β€” a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents the generated CLI. The skill includes every tool's exact invocation syntax, parameter flags with types and descriptions, and the utility commands, so an agent can use the CLI immediately without running `--help` or experimenting with flag names. + +The skill is written to the same directory as the CLI script. For a weather server, it looks something like: + +````markdown +--- +name: "weather-cli" +description: "CLI for the weather MCP server. Call tools, list resources, and get prompts." +--- + +# weather CLI + +## Tool Commands + +### get_forecast + +Get the weather forecast for a city. + +```bash +uv run --with fastmcp python cli.py call-tool get_forecast --city --days +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--city` | string | yes | City name | +| `--days` | integer | no | Number of forecast days | +```` + +To skip skill generation, pass `--no-skill`: + +```bash +fastmcp generate-cli weather --no-skill +``` + ## How It Works The generated script is a client, not a server. It doesn't bundle or embed the MCP server β€” it connects to it on every invocation. For URL-based servers, the server needs to be running. For stdio-based servers, the command specified in `CLIENT_SPEC` must be available on the system's `PATH`. diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index f982513d9..2a286961f 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -120,6 +120,29 @@ Key details: Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) +### Pre-Registered OAuth Clients + +The `OAuth` client helper now accepts `client_id` and `client_secret` parameters for servers where the client is already registered ([#3086](https://github.com/jlowin/fastmcp/pull/3086)). This bypasses Dynamic Client Registration entirely β€” useful when DCR is disabled, or when the server has pre-provisioned credentials for your application. + +```python +from fastmcp import Client +from fastmcp.client.auth import OAuth + +async with Client( + "https://mcp-server.example.com/mcp", + auth=OAuth( + client_id="my-registered-app", + client_secret="my-secret", + scopes=["read", "write"], + ), +) as client: + await client.ping() +``` + +The static credentials are injected before the OAuth flow begins, so the client never attempts DCR. If the server rejects the credentials, the error surfaces immediately rather than retrying with fresh registration (which can't help for fixed credentials). Public clients can omit `client_secret`. + +Documentation: [Pre-Registered Clients](/clients/auth/oauth#pre-registered-clients) + ### CLI: `fastmcp generate-cli` `fastmcp generate-cli` connects to any MCP server, reads its tool schemas, and writes a standalone Python CLI script where every tool becomes a typed subcommand with flags, help text, and tab completion ([#3065](https://github.com/jlowin/fastmcp/pull/3065)). The insight is that MCP tool schemas already contain everything a CLI framework needs β€” parameter names, types, descriptions, required/optional status β€” so the generator maps JSON Schema directly into [cyclopts](https://cyclopts.readthedocs.io/) commands. @@ -203,18 +226,20 @@ The `require_auth` authorization check introduced in beta1 has been removed in f Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) β€” the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet β€” those are future phases. -**Registering tools with UI metadata:** +**Breaking change from beta 2:** The `ui=` parameter on `@mcp.tool()` and `@mcp.resource()` has been renamed to `app=`, and the `ToolUI`/`ResourceUI` classes have been consolidated into a single `AppConfig` class. This follows the established `task=True`/`TaskConfig` pattern. The wire format (`meta["ui"]`, `_meta.ui`) is unchanged. + +**Registering tools with app metadata:** ```python from fastmcp import FastMCP -from fastmcp.server.apps import ToolUI, ResourceUI, ResourceCSP, ResourcePermissions +from fastmcp.server.apps import AppConfig, ResourceCSP, ResourcePermissions mcp = FastMCP("My Server") # Register the HTML bundle as a ui:// resource with CSP @mcp.resource( "ui://my-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP(resource_domains=["https://unpkg.com"]), permissions=ResourcePermissions(clipboard_write={}), ), @@ -224,17 +249,17 @@ def app_html() -> str: return Path("./dist/index.html").read_text() # Tool with UI β€” clients render an iframe alongside the result -@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) +@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) async def list_users() -> list[dict]: return [{"id": "1", "name": "Alice"}] # App-only tool β€” visible to the UI but hidden from the model -@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"])) +@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"])) async def delete_user(id: str) -> dict: return {"deleted": True} ``` -The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a raw dict for forward compatibility. It merges into `meta["ui"]` β€” alongside any other metadata you set. +The `app=` parameter accepts `True` (enable with defaults), an `AppConfig` instance, or a raw dict for forward compatibility. It merges into `meta["ui"]` β€” alongside any other metadata you set. **`ui://` resources** automatically get the correct MIME type (`text/html;profile=mcp-app`) unless you override it explicitly. @@ -242,9 +267,9 @@ The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a r ```python from fastmcp import Context -from fastmcp.server.apps import ToolUI, UI_EXTENSION_ID +from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID -@mcp.tool(ui=ToolUI(resource_uri="ui://dashboard")) +@mcp.tool(app=AppConfig(resource_uri="ui://dashboard")) async def dashboard(ctx: Context) -> dict: data = compute_dashboard() if ctx.client_supports_extension(UI_EXTENSION_ID): @@ -253,11 +278,10 @@ async def dashboard(ctx: Context) -> dict: ``` **Key details:** -- `ToolUI` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional except for typical usage of `resource_uri`) -- `ResourceUI` fields: `csp`, `permissions`, `domain`, `prefers_border` β€” metadata for the resource itself when it's a UI bundle +- `AppConfig` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional). On resources, `resource_uri` and `visibility` are validated as not-applicable and will raise `ValueError` if set. - `csp` accepts a `ResourceCSP` model with structured domain lists: `connect_domains`, `resource_domains`, `frame_domains`, `base_uri_domains` - `permissions` accepts a `ResourcePermissions` model: `camera`, `microphone`, `geolocation`, `clipboard_write` (each set to `{}` to request) -- Both models use `extra="allow"` for forward compatibility with future spec additions +- `AppConfig` uses `extra="allow"` for forward compatibility with future spec additions - Models use Pydantic aliases for wire format (`resourceUri`, `prefersBorder`, `connectDomains`, `clipboardWrite`) - Resource metadata (including CSP/permissions) is propagated to `resources/read` response content items so hosts can read it when rendering the iframe - `ctx.client_supports_extension(id)` is a general-purpose method β€” works for any extension, not just MCP Apps diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index a5b316d88..4376a38ce 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -326,3 +326,135 @@ mcp = FastMCP(name="Azure MI App", auth=auth) For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`. + +## On-Behalf-Of (OBO) + + + +The On-Behalf-Of (OBO) flow allows your FastMCP server to call downstream Microsoft APIsβ€”like Microsoft Graphβ€”using the authenticated user's identity. When a user authenticates to your MCP server, you receive a token for your API. OBO exchanges that token for a new token that can call other services, maintaining the user's identity and permissions throughout the chain. + +This pattern is useful when your tools need to access user-specific data from Microsoft services: reading emails, accessing calendar events, querying SharePoint, or any other Graph API operation that requires user context. + + +OBO features require the `azure` extra: + +```bash +pip install 'fastmcp[azure]' +``` + + +### Azure Portal Setup + +OBO requires additional configuration in your Azure App registration beyond basic authentication. + + + + In your App registration, navigate to **API permissions** and add the Microsoft Graph permissions your tools will need. + + - Click **Add a permission** β†’ **Microsoft Graph** β†’ **Delegated permissions** + - Select the permissions required for your use case (e.g., `Mail.Read`, `Calendars.Read`, `User.Read`) + - Repeat for any other APIs you need to call + + + Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow. + + + + + OBO requires admin consent for the permissions you've added. In the **API permissions** page, click **Grant admin consent for [Your Organization]**. + + Without admin consent, OBO token exchanges will fail with an `AADSTS65001` error indicating the user or administrator hasn't consented to use the application. + + + For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent. + + + + +### Configure AzureProvider for OBO + +The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later. + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.azure import AzureProvider + +auth_provider = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], # Your API scope + # Include Graph scopes for OBO + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + "offline_access", # Enables refresh tokens + ], +) + +mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider) +``` + +Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access. + + +Use fully-qualified scope URIs for downstream APIs (e.g., `https://graph.microsoft.com/Mail.Read`). Short forms like `Mail.Read` work for authorization requests, but fully-qualified URIs are clearer and avoid ambiguity. + + +### EntraOBOToken Dependency + +The `EntraOBOToken` dependency handles the complete OBO flow automatically. Declare it as a parameter default with the scopes you need, and FastMCP exchanges the user's token for a downstream API token before your function runs. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken +import httpx + +auth_provider = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + ], +) + +mcp = FastMCP(name="Email Reader", auth=auth_provider) + +@mcp.tool +async def get_recent_emails( + count: int = 10, + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), +) -> list[dict]: + """Get the user's recent emails from Microsoft Graph.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://graph.microsoft.com/v1.0/me/messages?$top={count}", + headers={"Authorization": f"Bearer {graph_token}"}, + ) + response.raise_for_status() + data = response.json() + + return [ + {"subject": msg["subject"], "from": msg["from"]["emailAddress"]["address"]} + for msg in data.get("value", []) + ] +``` + +The `graph_token` parameter receives a ready-to-use access token for Microsoft Graph. FastMCP handles the OBO exchange transparentlyβ€”your function just uses the token to call the API. + + +**Scope alignment is critical.** The scopes passed to `EntraOBOToken` must be a subset of the scopes in `additional_authorize_scopes`. If you request a scope during OBO that wasn't included in the initial authorization, the exchange will fail. + + + +For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials. + + + +For a complete working example of Azure OBO with FastMCP, see [Pamela Fox's blog post on OBO flow for Entra-based MCP servers](https://blog.pamelafox.org/2026/01/using-on-behalf-of-flow-for-entra-based.html). + diff --git a/docs/python-sdk/fastmcp-cli-generate.mdx b/docs/python-sdk/fastmcp-cli-generate.mdx index 28bd3ea7f..027894e38 100644 --- a/docs/python-sdk/fastmcp-cli-generate.mdx +++ b/docs/python-sdk/fastmcp-cli-generate.mdx @@ -6,7 +6,7 @@ sidebarTitle: generate # `fastmcp.cli.generate` -Generate a standalone CLI script from an MCP server's capabilities. +Generate a standalone CLI script and agent skill from an MCP server. ## Functions @@ -33,7 +33,17 @@ generate_cli_script(server_name: str, server_spec: str, transport_code: str, ext Generate the full CLI script source code. -### `generate_cli_command` +### `generate_skill_content` + +```python +generate_skill_content(server_name: str, cli_filename: str, tools: list[mcp.types.Tool]) -> str +``` + + +Generate a SKILL.md file for a generated CLI script. + + +### `generate_cli_command` ```python generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, discovered name, or .js file')], output: Annotated[str, cyclopts.Parameter(help='Output file path (default: cli.py)')] = 'cli.py') -> None @@ -43,7 +53,8 @@ generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes -a Python script that can invoke them directly. +a Python script that can invoke them directly. Also generates a SKILL.md +agent skill file unless --no-skill is passed. **Examples:** @@ -51,4 +62,5 @@ fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f +fastmcp generate-cli weather --no-skill diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index b9d06beb7..15ce6e8af 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -73,7 +73,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -82,7 +82,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -91,7 +91,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-server-apps.mdx b/docs/python-sdk/fastmcp-server-apps.mdx index c72052339..ad006b492 100644 --- a/docs/python-sdk/fastmcp-server-apps.mdx +++ b/docs/python-sdk/fastmcp-server-apps.mdx @@ -15,17 +15,17 @@ UI metadata for clients that support interactive app rendering. ## Functions -### `ui_to_meta_dict` +### `app_config_to_meta_dict` ```python -ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any] +app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] ``` -Convert a UI model or dict to the wire-format dict for ``meta["ui"]``. +Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. -### `resolve_ui_mime_type` +### `resolve_ui_mime_type` ```python resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None @@ -70,18 +70,17 @@ iframe. Hosts MAY honour these; apps should use JS feature detection as a fallback. -### `ToolUI` +### `AppConfig` -Typed ``_meta.ui`` for tools β€” links a tool to its UI resource. +Configuration for MCP App tools and resources. + +Controls how a tool or resource participates in the MCP Apps extension. +On tools, ``resource_uri`` and ``visibility`` specify which UI resource +to render and where the tool appears. On resources, those fields must +be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format (camelCase). - -### `ResourceUI` - - -Typed ``_meta.ui`` for resources β€” rendering hints for UI-capable clients. - diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 2e403a611..e5773c6fd 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -12,9 +12,38 @@ This provider implements Azure/Microsoft Entra ID OAuth authentication using the OAuth Proxy pattern for non-DCR OAuth flows. +## Functions + +### `EntraOBOToken` + +```python +EntraOBOToken(scopes: list[str]) -> str +``` + + +Exchange the user's Entra token for a downstream API token via OBO. + +This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, +allowing your MCP server to call downstream APIs (like Microsoft Graph) on +behalf of the authenticated user. + +**Args:** +- `scopes`: The scopes to request for the downstream API. For Microsoft Graph, +use scopes like ["https\://graph.microsoft.com/Mail.Read"] or +["https\://graph.microsoft.com/.default"]. + +**Returns:** +- A dependency that resolves to the downstream API access token string + +**Raises:** +- `ImportError`: If fastmcp[azure] is not installed +- `RuntimeError`: If no access token is available, provider is not Azure, +or OBO exchange fails + + ## Classes -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -49,7 +78,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -69,7 +98,29 @@ scopes to determine the resource/audience instead of a separate parameter. - Authorization URL to redirect the user to Azure AD -### `AzureJWTVerifier` +#### `create_obo_credential` + +```python +create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential +``` + +Create an OnBehalfOfCredential for OBO token exchange. + +Uses the AzureProvider's configuration (client_id, client_secret, +tenant_id, authority) to create a credential that can exchange the +user's token for downstream API tokens. + +**Args:** +- `user_assertion`: The user's access token to exchange via OBO. + +**Returns:** +- A configured OnBehalfOfCredential ready for get_token() calls. + +**Raises:** +- `ImportError`: If azure-identity is not installed (requires fastmcp[azure]). + + +### `AzureJWTVerifier` JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -106,7 +157,7 @@ Example:: **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index 060f0c4d5..19d67e5d6 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -465,6 +465,12 @@ in the step for manual execution. - `mask_error_details`: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. +- `tool_concurrency`: Controls parallel execution of tools\: +- None (default)\: Sequential execution (one at a time) +- 0\: Unlimited parallel execution +- N > 0\: Execute at most N tools concurrently +If any tool has sequential=True, all tools execute sequentially +regardless of this setting. **Returns:** - SampleStep containing: @@ -475,7 +481,7 @@ Tools can raise ToolError to bypass masking. - - .text: The text content (if any) -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -484,7 +490,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: With result_type, returns SamplingResult[ResultT]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] @@ -493,7 +499,7 @@ sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ Overload: Without result_type, returns SamplingResult[str]. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] @@ -527,6 +533,12 @@ response is validated against this type. - `mask_error_details`: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. +- `tool_concurrency`: Controls parallel execution of tools\: +- None (default)\: Sequential execution (one at a time) +- 0\: Unlimited parallel execution +- N > 0\: Execute at most N tools concurrently +If any tool has sequential=True, all tools execute sequentially +regardless of this setting. **Returns:** - SamplingResult[T] containing: @@ -535,43 +547,43 @@ Tools can raise ToolError to bypass masking. - - .history: All messages exchanged during sampling -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation @@ -600,7 +612,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -613,7 +625,7 @@ The key is automatically prefixed with the session identifier. State expires after 1 day to prevent unbounded memory growth. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any @@ -624,7 +636,7 @@ Get a value from the session-scoped state store. Returns None if the key is not found. -#### `delete_state` +#### `delete_state` ```python delete_state(self, key: str) -> None @@ -633,7 +645,7 @@ delete_state(self, key: str) -> None Delete a value from the session-scoped state store. -#### `enable_components` +#### `enable_components` ```python enable_components(self) -> None @@ -657,7 +669,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `disable_components` +#### `disable_components` ```python disable_components(self) -> None @@ -681,7 +693,7 @@ ResourceListChangedNotification, and PromptListChangedNotification. - `match_all`: If True, matches all components regardless of other criteria. -#### `reset_visibility` +#### `reset_visibility` ```python reset_visibility(self) -> None diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx index b066c8c8e..496a19e8c 100644 --- a/docs/python-sdk/fastmcp-server-dependencies.mdx +++ b/docs/python-sdk/fastmcp-server-dependencies.mdx @@ -15,7 +15,7 @@ CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions -### `get_task_context` +### `get_task_context` ```python get_task_context() -> TaskContextInfo | None @@ -31,7 +31,7 @@ Returns None if not running in a task context (e.g., foreground execution). - TaskContextInfo with task_id and session_id, or None if not in a task. -### `register_task_session` +### `register_task_session` ```python register_task_session(session_id: str, session: ServerSession) -> None @@ -49,7 +49,7 @@ client disconnects. - `session`: The ServerSession instance -### `get_task_session` +### `get_task_session` ```python get_task_session(session_id: str) -> ServerSession | None @@ -65,7 +65,7 @@ Get a registered session by ID if still alive. - The ServerSession if found and alive, None otherwise -### `is_docket_available` +### `is_docket_available` ```python is_docket_available() -> bool @@ -75,7 +75,7 @@ is_docket_available() -> bool Check if pydocket is installed. -### `require_docket` +### `require_docket` ```python require_docket(feature: str) -> None @@ -89,7 +89,7 @@ Raise ImportError with install instructions if docket not available. "CurrentDocket()"). Will be included in the error message. -### `transform_context_annotations` +### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] @@ -115,7 +115,7 @@ allows them to have defaults in any order. - Function with modified signature (same function object, updated __signature__) -### `get_context` +### `get_context` ```python get_context() -> Context @@ -125,7 +125,7 @@ get_context() -> Context Get the current FastMCP Context instance directly. -### `get_server` +### `get_server` ```python get_server() -> FastMCP @@ -141,7 +141,7 @@ Get the current FastMCP server instance directly. - `RuntimeError`: If no server in context -### `get_http_request` +### `get_http_request` ```python get_http_request() -> Request @@ -153,7 +153,7 @@ Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. -### `get_http_headers` +### `get_http_headers` ```python get_http_headers(include_all: bool = False) -> dict[str, str] @@ -169,7 +169,7 @@ By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. If `include_all` is True, all headers are returned. -### `get_access_token` +### `get_access_token` ```python get_access_token() -> AccessToken | None @@ -187,7 +187,7 @@ request is available. - The access token if an authenticated user is available, None otherwise. -### `without_injected_parameters` +### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] @@ -212,7 +212,7 @@ Handles: - Async wrapper function without injected parameters -### `resolve_dependencies` +### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] @@ -238,7 +238,7 @@ time, so all injection goes through the unified DI system. which will be filtered out) -### `CurrentContext` +### `CurrentContext` ```python CurrentContext() -> Context @@ -257,7 +257,7 @@ current MCP operation (tool/resource/prompt call). - `RuntimeError`: If no active context found (during resolution) -### `CurrentDocket` +### `CurrentDocket` ```python CurrentDocket() -> Docket @@ -277,7 +277,7 @@ automatically creates for background task scheduling. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentWorker` +### `CurrentWorker` ```python CurrentWorker() -> Worker @@ -297,7 +297,7 @@ automatically creates for background task processing. - `ImportError`: If fastmcp[tasks] not installed -### `CurrentFastMCP` +### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP @@ -315,7 +315,7 @@ This dependency provides access to the active FastMCP server. - `RuntimeError`: If no server in context (during resolution) -### `CurrentRequest` +### `CurrentRequest` ```python CurrentRequest() -> Request @@ -335,7 +335,7 @@ current HTTP request. Only available when running over HTTP transports - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) -### `CurrentHeaders` +### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] @@ -352,7 +352,7 @@ safe to use in code that might run over any transport. - A dependency that resolves to a dictionary of header name -> value -### `CurrentAccessToken` +### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken @@ -371,9 +371,32 @@ authenticated request. Raises an error if no authentication is present. - `RuntimeError`: If no authenticated user (use get_access_token() for optional) +### `TokenClaim` + +```python +TokenClaim(name: str) -> str +``` + + +Get a specific claim from the access token. + +This dependency extracts a single claim value from the current access token. +It's useful for getting user identifiers, roles, or other token claims +without needing the full token object. + +**Args:** +- `name`: The name of the claim to extract (e.g., "oid", "sub", "email") + +**Returns:** +- A dependency that resolves to the claim value as a string + +**Raises:** +- `RuntimeError`: If no access token is available or claim is missing + + ## Classes -### `TaskContextInfo` +### `TaskContextInfo` Information about the current background task context. @@ -382,7 +405,7 @@ Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. -### `ProgressLike` +### `ProgressLike` Protocol for progress tracking interface. @@ -393,7 +416,7 @@ and Docket's Progress (worker context). **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None @@ -402,7 +425,7 @@ current(self) -> int | None Current progress value. -#### `total` +#### `total` ```python total(self) -> int @@ -411,7 +434,7 @@ total(self) -> int Total/target progress value. -#### `message` +#### `message` ```python message(self) -> str | None @@ -420,7 +443,7 @@ message(self) -> str | None Current progress message. -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -429,7 +452,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -438,7 +461,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -447,7 +470,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `InMemoryProgress` +### `InMemoryProgress` In-memory progress tracker for immediate tool execution. @@ -459,25 +482,25 @@ progress doesn't need to be observable across processes. **Methods:** -#### `current` +#### `current` ```python current(self) -> int | None ``` -#### `total` +#### `total` ```python total(self) -> int ``` -#### `message` +#### `message` ```python message(self) -> str | None ``` -#### `set_total` +#### `set_total` ```python set_total(self, total: int) -> None @@ -486,7 +509,7 @@ set_total(self, total: int) -> None Set the total/target value for progress tracking. -#### `increment` +#### `increment` ```python increment(self, amount: int = 1) -> None @@ -495,7 +518,7 @@ increment(self, amount: int = 1) -> None Atomically increment the current progress value. -#### `set_message` +#### `set_message` ```python set_message(self, message: str | None) -> None @@ -504,7 +527,7 @@ set_message(self, message: str | None) -> None Update the progress status message. -### `Progress` +### `Progress` FastMCP Progress dependency that works in both server and worker contexts. diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx index fd501251e..bfb3ccea1 100644 --- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx +++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx @@ -37,19 +37,19 @@ Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] diff --git a/docs/python-sdk/fastmcp-server-sampling-run.mdx b/docs/python-sdk/fastmcp-server-sampling-run.mdx index a251946ff..a28542f28 100644 --- a/docs/python-sdk/fastmcp-server-sampling-run.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-run.mdx @@ -10,7 +10,7 @@ Sampling types and helper functions for FastMCP servers. ## Functions -### `determine_handler_mode` +### `determine_handler_mode` ```python determine_handler_mode(context: Context, needs_tools: bool) -> bool @@ -30,7 +30,7 @@ Determine whether to use fallback handler or client for sampling. - `ValueError`: If client lacks required capability and no fallback configured. -### `call_sampling_handler` +### `call_sampling_handler` ```python call_sampling_handler(context: Context, messages: list[SamplingMessage]) -> CreateMessageResult | CreateMessageResultWithTools @@ -44,10 +44,10 @@ sampling_handler is set via determine_handler_mode(). The checks below are safeguards against internal misuse. -### `execute_tools` +### `execute_tools` ```python -execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False) -> list[ToolResultContent] +execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None) -> list[ToolResultContent] ``` @@ -60,12 +60,18 @@ Execute tool calls and return results. When masked, only generic error messages are returned to the LLM. Tools can explicitly raise ToolError to bypass masking when they want to provide specific error messages to the LLM. +- `tool_concurrency`: Controls parallel execution of tools\: +- None (default)\: Sequential execution (one at a time) +- 0\: Unlimited parallel execution +- N > 0\: Execute at most N tools concurrently +If any tool has sequential=True, all tools execute sequentially +regardless of this setting. **Returns:** -- List of tool result content blocks. +- List of tool result content blocks in the same order as tool_calls. -### `prepare_messages` +### `prepare_messages` ```python prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[SamplingMessage] @@ -75,7 +81,7 @@ prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[Sampli Convert various message formats to a list of SamplingMessage objects. -### `prepare_tools` +### `prepare_tools` ```python prepare_tools(tools: Sequence[SamplingTool | Callable[..., Any]] | None) -> list[SamplingTool] | None @@ -85,7 +91,7 @@ prepare_tools(tools: Sequence[SamplingTool | Callable[..., Any]] | None) -> list Convert tools to SamplingTool objects. -### `extract_tool_calls` +### `extract_tool_calls` ```python extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) -> list[ToolUseContent] @@ -95,7 +101,7 @@ extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) Extract tool calls from a response. -### `create_final_response_tool` +### `create_final_response_tool` ```python create_final_response_tool(result_type: type) -> SamplingTool @@ -108,7 +114,7 @@ This tool is used to capture structured responses from the LLM. The tool's schema is derived from the result_type. -### `sample_step_impl` +### `sample_step_impl` ```python sample_step_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SampleStep @@ -121,7 +127,7 @@ Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. -### `sample_impl` +### `sample_impl` ```python sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] @@ -137,7 +143,7 @@ provides a final text response. ## Classes -### `SamplingResult` +### `SamplingResult` Result of a sampling operation. @@ -148,7 +154,7 @@ Result of a sampling operation. - `history`: All messages exchanged during sampling. -### `SampleStep` +### `SampleStep` Result of a single sampling call. @@ -158,7 +164,7 @@ Represents what the LLM returned in this step plus the message history. **Methods:** -#### `is_tool_use` +#### `is_tool_use` ```python is_tool_use(self) -> bool @@ -167,7 +173,7 @@ is_tool_use(self) -> bool True if the LLM is requesting tool execution. -#### `text` +#### `text` ```python text(self) -> str | None @@ -176,7 +182,7 @@ text(self) -> str | None Extract text from the response, if available. -#### `tool_calls` +#### `tool_calls` ```python tool_calls(self) -> list[ToolUseContent] diff --git a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx index 1231624f5..15941b0dc 100644 --- a/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx +++ b/docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx @@ -37,7 +37,7 @@ Create a SamplingTool explicitly when you need custom name/description: **Methods:** -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any] | None = None) -> Any @@ -52,7 +52,7 @@ Execute the tool with the given arguments. - The result of executing the tool function. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> SamplingTool @@ -67,6 +67,10 @@ the tool's parameters. Type hints are used to determine parameter types. - `fn`: The function to create a tool from. - `name`: Optional name override. Defaults to the function's name. - `description`: Optional description override. Defaults to the function's docstring. +- `sequential`: If True, this tool requires sequential execution and prevents +parallel execution of all tools in the batch. Set to True for tools +with shared state, file writes, or other operations that cannot run +concurrently. Defaults to False. **Returns:** - A SamplingTool wrapping the function. diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 004fc53c8..56bfd7696 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -26,7 +26,7 @@ Default lifespan context manager that does nothing. - An empty dictionary as the lifespan result. -### `create_proxy` +### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -54,65 +54,65 @@ use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.pr ## Classes -### `StateValue` +### `StateValue` Wrapper for stored context state values. -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `website_url` +#### `website_url` ```python website_url(self) -> str | None ``` -#### `icons` +#### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `add_provider` +#### `add_provider` ```python add_provider(self, provider: Provider) -> None @@ -132,7 +132,7 @@ always take precedence over providers. - Prompts become "namespace_promptname" -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -144,7 +144,7 @@ Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. -#### `add_transform` +#### `add_transform` ```python add_transform(self, transform: Transform) -> None @@ -159,7 +159,7 @@ They transform tools, resources, and prompts from ALL providers. - `transform`: The transform to add. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -171,7 +171,7 @@ Add a tool transformation. Use ``add_transform(ToolTransform({...}))`` instead. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None @@ -183,7 +183,7 @@ Remove a tool transformation. Tool transformations are now immutable. Use enable/disable controls instead. -#### `list_tools` +#### `list_tools` ```python list_tools(self) -> Sequence[Tool] @@ -196,7 +196,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None @@ -216,7 +216,7 @@ session transforms can override provider-level disables. - The tool if found and enabled, None otherwise. -#### `list_resources` +#### `list_resources` ```python list_resources(self) -> Sequence[Resource] @@ -229,7 +229,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None @@ -248,7 +248,7 @@ transforms (including session-level) have been applied. - The resource if found and enabled, None otherwise. -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] @@ -261,7 +261,7 @@ auth filtering, and middleware execution. Returns all versions (no deduplication Protocol handlers deduplicate for MCP wire format. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None @@ -280,7 +280,7 @@ all transforms (including session-level) have been applied. - The template if found and enabled, None otherwise. -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] @@ -293,7 +293,7 @@ and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None @@ -312,19 +312,19 @@ transforms (including session-level) have been applied. - The prompt if found and enabled, None otherwise. -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `call_tool` +#### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult @@ -354,19 +354,19 @@ return ToolResult. - `ValidationError`: If arguments fail validation -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult @@ -395,19 +395,19 @@ return ResourceResult. - `ResourceError`: If resource read fails -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult @@ -437,7 +437,7 @@ return PromptResult. - `PromptError`: If prompt rendering fails -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool @@ -455,7 +455,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None @@ -471,19 +471,19 @@ Remove tool(s) from the server. - `NotFoundError`: If no matching tool is found. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] @@ -539,7 +539,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate @@ -554,7 +554,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -569,7 +569,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction] @@ -628,7 +628,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt @@ -643,19 +643,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] @@ -732,7 +732,7 @@ Decorator to register a prompt. ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None @@ -779,7 +779,7 @@ mounted server. - `prefix`: Deprecated. Use namespace instead. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None @@ -820,7 +820,7 @@ templates, and prompts are imported with their original names. objects are imported with their original names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -845,7 +845,7 @@ server URL from the OpenAPI spec with a 30-second timeout. - A FastMCP server with an OpenAPIProvider attached. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self @@ -869,7 +869,7 @@ Use this to configure timeout and other client settings. - A FastMCP server with an OpenAPIProvider attached. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -887,7 +887,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str diff --git a/docs/python-sdk/fastmcp-tools-function_tool.mdx b/docs/python-sdk/fastmcp-tools-function_tool.mdx index bd66818a0..4f910a354 100644 --- a/docs/python-sdk/fastmcp-tools-function_tool.mdx +++ b/docs/python-sdk/fastmcp-tools-function_tool.mdx @@ -10,7 +10,7 @@ Standalone @tool decorator for FastMCP. ## Functions -### `tool` +### `tool` ```python tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any @@ -37,11 +37,11 @@ Protocol for functions decorated with @tool. Metadata attached to functions by the @tool decorator. -### `FunctionTool` +### `FunctionTool` **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> mcp.types.Tool @@ -52,7 +52,7 @@ Convert the FastMCP tool to an MCP tool. Extends the base implementation to add task execution mode if enabled. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool @@ -68,7 +68,7 @@ individual parameters must not be passed. Cannot be used together with metadata parameter. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -77,7 +77,7 @@ run(self, arguments: dict[str, Any]) -> ToolResult Run the tool with arguments. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -89,7 +89,7 @@ FunctionTool registers the underlying function, which has the user's Depends parameters for docket to resolve. -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index d986bc52a..27dd3fd0c 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -237,6 +237,37 @@ The `AccessToken` object provides: - **`expires_at`**: Token expiration timestamp (if available) - **`claims`**: Dictionary of all token claims (JWT claims or provider-specific data) +### Token Claims + +When you need just one specific value from the tokenβ€”like a user ID or tenant identifierβ€”`TokenClaim()` extracts it directly without needing the full token object. + +```python +from fastmcp import FastMCP +from fastmcp.server.dependencies import TokenClaim + +mcp = FastMCP("Demo") + + +@mcp.tool +async def add_expense( + amount: float, + user_id: str = TokenClaim("oid"), # Azure object ID +) -> dict: + await db.insert({"user_id": user_id, "amount": amount}) + return {"status": "created", "user_id": user_id} +``` + +`TokenClaim()` raises a `RuntimeError` if the claim doesn't exist, listing available claims to help with debugging. + +Common claims vary by identity provider: + +| Provider | User ID Claim | Email Claim | Name Claim | +|----------|--------------|-------------|------------| +| Azure/Entra | `oid` | `email` | `name` | +| GitHub | `sub` | `email` | `name` | +| Google | `sub` | `email` | `name` | +| Auth0 | `sub` | `email` | `name` | + ### Background Task Dependencies diff --git a/docs/servers/sampling.mdx b/docs/servers/sampling.mdx index f15d50aa3..8ea479eb0 100644 --- a/docs/servers/sampling.mdx +++ b/docs/servers/sampling.mdx @@ -289,6 +289,45 @@ def search(query: str) -> str: `ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle. +### Concurrent Tool Execution + +By default, tools execute sequentially β€” one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`: + +```python +result = await ctx.sample( + messages="Research these three topics", + tools=[search, fetch_url], + tool_concurrency=0, # Unlimited parallel execution +) +``` + +The `tool_concurrency` parameter controls how many tools run at once: + +- **`None`** (default): Sequential execution +- **`0`**: Unlimited parallel execution +- **`N > 0`**: Execute at most N tools concurrently + +For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`: + +```python +from fastmcp.server.sampling import SamplingTool + +db_writer = SamplingTool.from_function( + write_to_db, + sequential=True, # Forces all tools in the batch to run sequentially +) + +result = await ctx.sample( + messages="Process this data", + tools=[search, db_writer], + tool_concurrency=0, # Would be parallel, but db_writer forces sequential +) +``` + + +When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee β€” if one tool needs ordering, all tools in that batch respect it. + + ### Client Requirements @@ -463,6 +502,10 @@ tool_result = ToolResultContent( If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM. + + Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless. + + @@ -511,6 +554,10 @@ tool_result = ToolResultContent( If True, mask detailed error messages from tool execution. + + + Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. + diff --git a/examples/apps/qr_server/README.md b/examples/apps/qr_server/README.md index f2c9ebf1d..2cfe806b1 100644 --- a/examples/apps/qr_server/README.md +++ b/examples/apps/qr_server/README.md @@ -4,7 +4,7 @@ An MCP App server that generates QR codes with an interactive viewer UI. Ported ## What it demonstrates -- Linking a tool to a `ui://` resource via `ToolUI` +- Linking a tool to a `ui://` resource via `AppConfig` - Serving embedded HTML with the `@modelcontextprotocol/ext-apps` JS SDK from CDN - Declaring CSP resource domains via `ResourceCSP` - Returning `ImageContent` (base64 PNG) from a tool diff --git a/examples/apps/qr_server/qr_server.py b/examples/apps/qr_server/qr_server.py index 8478cd557..7a3d5ee64 100644 --- a/examples/apps/qr_server/qr_server.py +++ b/examples/apps/qr_server/qr_server.py @@ -1,7 +1,7 @@ """QR Code MCP App Server β€” generates QR codes with an interactive view UI. Demonstrates MCP Apps with FastMCP: -- Tool linked to a ui:// resource via ToolUI +- Tool linked to a ui:// resource via AppConfig - HTML resource with CSP metadata for CDN-loaded dependencies - Embedded HTML using the @modelcontextprotocol/ext-apps JS SDK - ImageContent return type for binary data @@ -26,7 +26,7 @@ import qrcode # type: ignore[import-untyped] from mcp import types from fastmcp import FastMCP -from fastmcp.server.apps import ResourceCSP, ResourceUI, ToolUI +from fastmcp.server.apps import AppConfig, ResourceCSP from fastmcp.tools import ToolResult VIEW_URI: str = "ui://qr-server/view.html" @@ -104,7 +104,7 @@ EMBEDDED_VIEW_HTML: str = """\ """ -@mcp.tool(ui=ToolUI(resource_uri=VIEW_URI)) +@mcp.tool(app=AppConfig(resource_uri=VIEW_URI)) def generate_qr( text: str = "https://gofastmcp.com", box_size: int = 10, @@ -159,7 +159,7 @@ def generate_qr( @mcp.resource( VIEW_URI, - ui=ResourceUI(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), + app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), ) def view() -> str: """Interactive QR code viewer β€” renders tool results as images.""" diff --git a/pyproject.toml b/pyproject.toml index d1a7c5814..4c0eb22f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,14 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.40.0"] +azure = ["azure-identity>=1.16.0"] openai = ["openai>=1.102.0"] tasks = ["pydocket>=0.17.2"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", - "fastmcp[anthropic,openai,tasks]", + "fastmcp[anthropic,azure,openai,tasks]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "opentelemetry-sdk>=1.20.0", diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py index 7fdc6cd8a..b5e652909 100644 --- a/src/fastmcp/cli/generate.py +++ b/src/fastmcp/cli/generate.py @@ -1,4 +1,4 @@ -"""Generate a standalone CLI script from an MCP server's capabilities.""" +"""Generate a standalone CLI script and agent skill from an MCP server.""" import keyword import re @@ -518,6 +518,152 @@ def generate_cli_script( return "\n".join(lines) +# --------------------------------------------------------------------------- +# Skill (SKILL.md) generation +# --------------------------------------------------------------------------- + +_JSON_SCHEMA_TYPE_LABELS: dict[str, str] = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "null": "null", + "array": "array", + "object": "object", +} + + +def _param_to_cli_flag(prop_name: str) -> str: + """Convert a JSON Schema property name to its CLI flag form. + + Replicates cyclopts' default_name_transform: camelCase β†’ snake_case, + lowercase, underscores β†’ hyphens, strip leading/trailing hyphens. + """ + safe = _to_python_identifier(prop_name) + # camelCase / PascalCase β†’ snake_case + safe = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", safe) + safe = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", safe) + safe = safe.lower().replace("_", "-").strip("-") + return f"--{safe}" if safe else "--arg" + + +def _schema_type_label(prop_schema: dict[str, Any]) -> str: + """Return a human-readable type label for a property schema.""" + schema_type = prop_schema.get("type", "string") + if isinstance(schema_type, list): + labels = [_JSON_SCHEMA_TYPE_LABELS.get(t, t) for t in schema_type] + return " | ".join(labels) + + label = _JSON_SCHEMA_TYPE_LABELS.get(schema_type, schema_type) + + # For arrays, include item type if simple + if schema_type == "array": + items = prop_schema.get("items", {}) + item_type = items.get("type", "") + if isinstance(item_type, str) and item_type in _JSON_SCHEMA_TYPE_LABELS: + return f"array[{item_type}]" + + return label + + +def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str: + """Generate a SKILL.md section for a single tool.""" + schema = tool.inputSchema + properties: dict[str, Any] = schema.get("properties", {}) + required = set(schema.get("required", [])) + + # Build example invocation flags + flag_parts_list: list[str] = [] + for p, p_schema in properties.items(): + flag = _param_to_cli_flag(p) + schema_type = p_schema.get("type") + is_bool = schema_type == "boolean" or ( + isinstance(schema_type, list) and "boolean" in schema_type + ) + if is_bool: + flag_parts_list.append(flag) + else: + flag_parts_list.append(f"{flag} ") + flag_parts = " ".join(flag_parts_list) + invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}" + if flag_parts: + invocation += f" {flag_parts}" + + # Build parameter table rows + rows: list[str] = [] + for prop_name, prop_schema in properties.items(): + flag = f"`{_param_to_cli_flag(prop_name)}`" + type_label = _schema_type_label(prop_schema).replace("|", "\\|") + is_required = "yes" if prop_name in required else "no" + description = prop_schema.get("description", "") + _, needs_json = _schema_to_python_type(prop_schema) + if needs_json: + description = ( + f"{description} (JSON string)" if description else "JSON string" + ) + description = description.replace("\n", " ").replace("|", "\\|") + rows.append(f"| {flag} | {type_label} | {is_required} | {description} |") + + param_table = "" + if rows: + header = "| Flag | Type | Required | Description |\n|------|------|----------|-------------|" + param_table = f"\n{header}\n" + "\n".join(rows) + "\n" + + lines: list[str] = [f"### {tool.name}"] + if tool.description: + lines.extend(["", tool.description]) + lines.extend(["", "```bash", invocation, "```"]) + if param_table: + lines.extend(["", param_table.strip("\n")]) + return "\n".join(lines) + + +def generate_skill_content( + server_name: str, + cli_filename: str, + tools: list[mcp.types.Tool], +) -> str: + """Generate a SKILL.md file for a generated CLI script.""" + skill_name = ( + server_name.replace(" ", "-").lower().replace("\\", "").replace('"', "") + ) + safe_name = server_name.replace("\\", "").replace('"', "") + description = f"CLI for the {safe_name} MCP server. Call tools, list resources, and get prompts." + + lines = [ + "---", + f'name: "{skill_name}-cli"', + f'description: "{description}"', + "---", + "", + f"# {server_name} CLI", + "", + ] + + if tools: + tool_bodies = "\n\n".join( + _tool_skill_section(tool, cli_filename) for tool in tools + ) + lines.extend(["## Tool Commands", "", tool_bodies, ""]) + + lines.extend( + [ + "## Utility Commands", + "", + "```bash", + f"uv run --with fastmcp python {cli_filename} list-tools", + f"uv run --with fastmcp python {cli_filename} list-resources", + f"uv run --with fastmcp python {cli_filename} read-resource ", + f"uv run --with fastmcp python {cli_filename} list-prompts", + f"uv run --with fastmcp python {cli_filename} get-prompt [key=value ...]", + "```", + "", + ] + ) + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # CLI command # --------------------------------------------------------------------------- @@ -555,22 +701,40 @@ async def generate_cli_command( help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, + no_skill: Annotated[ + bool, + cyclopts.Parameter( + "--no-skill", + help="Skip generating a SKILL.md agent skill alongside the CLI", + ), + ] = False, ) -> None: """Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes - a Python script that can invoke them directly. + a Python script that can invoke them directly. Also generates a SKILL.md + agent skill file unless --no-skill is passed. Examples: fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f + fastmcp generate-cli weather --no-skill """ output_path = Path(output) + skill_path = output_path.parent / "SKILL.md" + + # Check both files up front before doing any work + existing: list[Path] = [] if output_path.exists() and not force: + existing.append(output_path) + if not no_skill and skill_path.exists() and not force: + existing.append(skill_path) + if existing: + names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing) console.print( - f"[bold red]Error:[/bold red] [cyan]{output_path}[/cyan] already exists. " + f"[bold red]Error:[/bold red] {names} already exist(s). " f"Use [cyan]-f[/cyan] to overwrite." ) sys.exit(1) @@ -612,6 +776,16 @@ async def generate_cli_command( f"[green]βœ“[/green] Wrote [cyan]{output_path}[/cyan] " f"with {len(tools)} tool command(s)" ) + + if not no_skill: + skill_content = generate_skill_content( + server_name=server_name, + cli_filename=output_path.name, + tools=tools, + ) + skill_path.write_text(skill_content) + console.print(f"[green]βœ“[/green] Wrote [cyan]{skill_path}[/cyan]") + console.print(f"[dim]Run: python {output_path} --help[/dim]") diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 9fc90b4e8..a4d1e9c77 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -154,7 +154,12 @@ class OAuth(OAuthClientProvider): additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, + # Alternative to dynamic client registration: + # --- Clients host a static JSON document at an HTTPS URL --- client_metadata_url: str | None = None, + # --- OR clients provide full client information --- + client_id: str | None = None, + client_secret: str | None = None, ): """ Initialize OAuth client provider for an MCP server. @@ -173,6 +178,9 @@ class OAuth(OAuthClientProvider): provided, this URL is used as the client_id instead of performing Dynamic Client Registration. Must be an HTTPS URL with a non-root path (e.g. "https://myapp.example.com/oauth/client.json"). + client_id: Pre-registered OAuth client ID. When provided, skips dynamic + client registration and uses these static credentials instead. + client_secret: OAuth client secret (optional, used with client_id) """ # Store config for deferred binding if mcp_url not yet known self._scopes = scopes @@ -181,6 +189,9 @@ class OAuth(OAuthClientProvider): self._additional_client_metadata = additional_client_metadata self._callback_port = callback_port self._client_metadata_url = client_metadata_url + self._client_id = client_id + self._client_secret = client_secret + self._static_client_info = None self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient self._bound = False @@ -218,6 +229,23 @@ class OAuth(OAuthClientProvider): **(self._additional_client_metadata or {}), ) + if self._client_id: + # Create the full static client info directly which will avoid DCR. + # Spread client_metadata so redirect_uris, grant_types, response_types, + # scope, etc. are included β€” servers may validate these fields. + metadata = client_metadata.model_dump(exclude_none=True) + # Default token_endpoint_auth_method based on whether a secret is + # provided, unless the caller already set it via additional_client_metadata. + if "token_endpoint_auth_method" not in metadata: + metadata["token_endpoint_auth_method"] = ( + "client_secret_post" if self._client_secret else "none" + ) + self._static_client_info = OAuthClientInformationFull( + client_id=self._client_id, + client_secret=self._client_secret, + **metadata, + ) + token_storage = self._token_storage or MemoryStore() if isinstance(token_storage, MemoryStore): @@ -230,6 +258,7 @@ class OAuth(OAuthClientProvider): stacklevel=2, ) + # Use full URL for token storage to properly separate tokens per MCP endpoint self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( async_key_value=token_storage, server_url=mcp_url ) @@ -249,10 +278,12 @@ class OAuth(OAuthClientProvider): async def _initialize(self) -> None: """Load stored tokens and client info, properly setting token expiry.""" - # Call parent's _initialize to load tokens and client info await super()._initialize() - # If tokens were loaded and have expires_in, update the context's token_expiry_time + if self._static_client_info is not None: + self.context.client_info = self._static_client_info + await self.token_storage_adapter.set_client_info(self._static_client_info) + if self.context.current_tokens and self.context.current_tokens.expires_in: self.context.update_token_expiry(self.context.current_tokens) @@ -342,6 +373,15 @@ class OAuth(OAuthClientProvider): break except ClientNotFoundError: + # Static credentials are fixed β€” retrying won't help. Surface the + # error so the user can correct their client_id / client_secret. + if self._static_client_info is not None: + raise ClientNotFoundError( + "OAuth server rejected the static client credentials. " + "Verify that the client_id (and client_secret, if provided) " + "are correct and that the client is registered with the server." + ) from None + logger.debug( "OAuth client not found on server, clearing cache and retrying..." ) diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py index 87d5367a9..b23222e9d 100644 --- a/src/fastmcp/dependencies.py +++ b/src/fastmcp/dependencies.py @@ -26,6 +26,7 @@ from fastmcp.server.dependencies import ( CurrentWorker, Progress, ProgressLike, + TokenClaim, ) __all__ = [ @@ -39,4 +40,5 @@ __all__ = [ "Depends", "Progress", "ProgressLike", + "TokenClaim", ] diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index 566938b98..9da7bc8e2 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -74,8 +74,13 @@ class ResourcePermissions(BaseModel): model_config = {"populate_by_name": True, "extra": "allow"} -class ToolUI(BaseModel): - """Typed ``_meta.ui`` for tools β€” links a tool to its UI resource. +class AppConfig(BaseModel): + """Configuration for MCP App tools and resources. + + Controls how a tool or resource participates in the MCP Apps extension. + On tools, ``resource_uri`` and ``visibility`` specify which UI resource + to render and where the tool appears. On resources, those fields must + be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format @@ -85,11 +90,11 @@ class ToolUI(BaseModel): resource_uri: str | None = Field( default=None, alias="resourceUri", - description="URI of the UI resource (typically ui:// scheme)", + description="URI of the UI resource (typically ui:// scheme). Tools only.", ) visibility: list[str] | None = Field( default=None, - description="Where this tool is visible: 'app', 'model', or both", + description="Where this tool is visible: 'app', 'model', or both. Tools only.", ) csp: ResourceCSP | None = Field( default=None, description="Content Security Policy for the app iframe" @@ -104,33 +109,14 @@ class ToolUI(BaseModel): description="Whether the UI prefers a visible border", ) - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "allow"} -class ResourceUI(BaseModel): - """Typed ``_meta.ui`` for resources β€” rendering hints for UI-capable clients.""" - - csp: ResourceCSP | None = Field( - default=None, description="Content Security Policy for the app iframe" - ) - permissions: ResourcePermissions | None = Field( - default=None, description="Iframe sandbox permissions" - ) - domain: str | None = Field(default=None, description="Domain for the iframe") - prefers_border: bool | None = Field( - default=None, - alias="prefersBorder", - description="Whether the UI prefers a visible border", - ) - - model_config = {"populate_by_name": True} - - -def ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any]: - """Convert a UI model or dict to the wire-format dict for ``meta["ui"]``.""" - if isinstance(ui, (ToolUI, ResourceUI)): - return ui.model_dump(by_alias=True, exclude_none=True) - return ui +def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: + """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" + if isinstance(app, AppConfig): + return app.model_dump(by_alias=True, exclude_none=True) + return app def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None: diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index cc0d2544e..b5974d887 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,7 +6,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from key_value.aio.protocols import AsyncKeyValue @@ -16,6 +16,7 @@ from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: + from azure.identity.aio import OnBehalfOfCredential from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull @@ -161,6 +162,10 @@ class AzureProvider(OAuthProxy): if "offline_access" not in parsed_additional_scopes: parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] + # Store Azure-specific config for OBO credential creation + self._tenant_id = tenant_id + self._base_authority = base_authority + # Apply defaults self.identifier_uri = identifier_uri or f"api://{client_id}" self.additional_authorize_scopes: list[str] = parsed_additional_scopes @@ -453,6 +458,33 @@ class AzureProvider(OAuthProxy): logger.debug("Failed to extract Azure claims: %s", e) return None + def create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: + """Create an OnBehalfOfCredential for OBO token exchange. + + Uses the AzureProvider's configuration (client_id, client_secret, + tenant_id, authority) to create a credential that can exchange the + user's token for downstream API tokens. + + Args: + user_assertion: The user's access token to exchange via OBO. + + Returns: + A configured OnBehalfOfCredential ready for get_token() calls. + + Raises: + ImportError: If azure-identity is not installed (requires fastmcp[azure]). + """ + _require_azure_identity("OBO token exchange") + from azure.identity.aio import OnBehalfOfCredential + + return OnBehalfOfCredential( + tenant_id=self._tenant_id, + client_id=self._upstream_client_id, + client_secret=self._upstream_client_secret.get_secret_value(), + user_assertion=user_assertion, + authority=f"https://{self._base_authority}", + ) + class AzureJWTVerifier(JWTVerifier): """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -552,3 +584,117 @@ class AzureJWTVerifier(JWTVerifier): else: prefixed.append(f"{self._identifier_uri}/{scope}") return prefixed + + +# --- Dependency injection support --- +# These require fastmcp[azure] extra for azure-identity + +# Check if DI engine is available +try: + from docket.dependencies import Dependency +except ImportError: + from fastmcp._vendor.docket_di import Dependency + + +def _require_azure_identity(feature: str) -> None: + """Raise ImportError with install instructions if azure-identity is not available.""" + try: + import azure.identity # noqa: F401 + except ImportError as e: + raise ImportError( + f"{feature} requires the `azure` extra. " + "Install with: pip install 'fastmcp[azure]'" + ) from e + + +class _EntraOBOToken(Dependency): # type: ignore[misc] + """Dependency that performs OBO token exchange for Microsoft Entra. + + Uses azure.identity's OnBehalfOfCredential for async-native OBO, + with automatic token caching and refresh. + """ + + def __init__(self, scopes: list[str]): + self.scopes = scopes + self._credential: OnBehalfOfCredential | None = None + + async def __aenter__(self) -> str: + _require_azure_identity("EntraOBOToken") + + from fastmcp.server.dependencies import get_access_token, get_server + + access_token = get_access_token() + if access_token is None: + raise RuntimeError( + "No access token available. Cannot perform OBO exchange." + ) + + server = get_server() + if not isinstance(server.auth, AzureProvider): + raise RuntimeError( + "EntraOBOToken requires an AzureProvider as the auth provider. " + f"Current provider: {type(server.auth).__name__}" + ) + + self._credential = server.auth.create_obo_credential( + user_assertion=access_token.token, + ) + + try: + result = await self._credential.get_token(*self.scopes) + except BaseException: + await self._credential.close() + self._credential = None + raise + + return result.token + + async def __aexit__(self, *args: object) -> None: + if self._credential is not None: + await self._credential.close() + self._credential = None + + +def EntraOBOToken(scopes: list[str]) -> str: + """Exchange the user's Entra token for a downstream API token via OBO. + + This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, + allowing your MCP server to call downstream APIs (like Microsoft Graph) on + behalf of the authenticated user. + + Args: + scopes: The scopes to request for the downstream API. For Microsoft Graph, + use scopes like ["https://graph.microsoft.com/Mail.Read"] or + ["https://graph.microsoft.com/.default"]. + + Returns: + A dependency that resolves to the downstream API access token string + + Raises: + ImportError: If fastmcp[azure] is not installed + RuntimeError: If no access token is available, provider is not Azure, + or OBO exchange fails + + Example: + ```python + from fastmcp.server.auth.providers.azure import EntraOBOToken + import httpx + + @mcp.tool() + async def get_my_emails( + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) + ): + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://graph.microsoft.com/v1.0/me/messages", + headers={"Authorization": f"Bearer {graph_token}"} + ) + return resp.json() + ``` + + Note: + For OBO to work, ensure the scopes are included in the AzureProvider's + `additional_authorize_scopes` parameter, and that admin consent has been + granted for those scopes in your Entra app registration. + """ + return cast(str, _EntraOBOToken(scopes)) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index b1f0ae227..a39817e58 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -790,6 +790,7 @@ class Context: tool_choice: ToolChoiceOption | str | None = None, execute_tools: bool = True, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SampleStep: """ Make a single LLM sampling call. @@ -813,6 +814,12 @@ class Context: mask_error_details: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. Returns: SampleStep containing: @@ -846,6 +853,7 @@ class Context: tool_choice=tool_choice, auto_execute_tools=execute_tools, mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, ) @overload @@ -860,6 +868,7 @@ class Context: tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT], mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[ResultT]: """Overload: With result_type, returns SamplingResult[ResultT].""" @@ -875,6 +884,7 @@ class Context: tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: None = None, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[str]: """Overload: Without result_type, returns SamplingResult[str].""" @@ -889,6 +899,7 @@ class Context: tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT] | None = None, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[ResultT] | SamplingResult[str]: """ Send a sampling request to the client and await the response. @@ -919,6 +930,12 @@ class Context: mask_error_details: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. Returns: SamplingResult[T] containing: @@ -942,6 +959,7 @@ class Context: tools=tools, result_type=result_type, mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, ) @overload diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index ffef2561b..97cf4fbef 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -53,6 +53,7 @@ __all__ = [ "CurrentWorker", "Progress", "TaskContextInfo", + "TokenClaim", "get_access_token", "get_context", "get_http_headers", @@ -991,47 +992,6 @@ def CurrentHeaders() -> dict[str, str]: return cast(dict[str, str], _CurrentHeaders()) -class _CurrentAccessToken(Dependency): # type: ignore[misc] - """Async context manager for AccessToken dependency.""" - - async def __aenter__(self) -> AccessToken: - token = get_access_token() - if token is None: - raise RuntimeError( - "No access token found. Ensure authentication is configured " - "and the request is authenticated." - ) - return token - - async def __aexit__(self, *args: object) -> None: - pass - - -def CurrentAccessToken() -> AccessToken: - """Get the current access token for the authenticated user. - - This dependency provides access to the AccessToken for the current - authenticated request. Raises an error if no authentication is present. - - Returns: - A dependency that resolves to the active AccessToken - - Raises: - RuntimeError: If no authenticated user (use get_access_token() for optional) - - Example: - ```python - from fastmcp.server.dependencies import CurrentAccessToken - from fastmcp.server.auth import AccessToken - - @mcp.tool() - async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: - return token.claims.get("sub", "unknown") - ``` - """ - return cast(AccessToken, _CurrentAccessToken()) - - # --- Progress dependency --- @@ -1162,3 +1122,106 @@ class Progress(Dependency): # type: ignore[misc] async def __aexit__(self, *args: object) -> None: pass + + +# --- Access Token dependency --- + + +class _CurrentAccessToken(Dependency): # type: ignore[misc] + """Async context manager for AccessToken dependency.""" + + async def __aenter__(self) -> AccessToken: + token = get_access_token() + if token is None: + raise RuntimeError( + "No access token found. Ensure authentication is configured " + "and the request is authenticated." + ) + return token + + async def __aexit__(self, *args: object) -> None: + pass + + +def CurrentAccessToken() -> AccessToken: + """Get the current access token for the authenticated user. + + This dependency provides access to the AccessToken for the current + authenticated request. Raises an error if no authentication is present. + + Returns: + A dependency that resolves to the active AccessToken + + Raises: + RuntimeError: If no authenticated user (use get_access_token() for optional) + + Example: + ```python + from fastmcp.server.dependencies import CurrentAccessToken + from fastmcp.server.auth import AccessToken + + @mcp.tool() + async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: + return token.claims.get("sub", "unknown") + ``` + """ + return cast(AccessToken, _CurrentAccessToken()) + + +# --- Token Claim dependency --- + + +class _TokenClaim(Dependency): # type: ignore[misc] + """Dependency that extracts a specific claim from the access token.""" + + def __init__(self, claim_name: str): + self.claim_name = claim_name + + async def __aenter__(self) -> str: + token = get_access_token() + if token is None: + raise RuntimeError( + f"No access token available. Cannot extract claim '{self.claim_name}'." + ) + value = token.claims.get(self.claim_name) + if value is None: + raise RuntimeError( + f"Claim '{self.claim_name}' not found in access token. " + f"Available claims: {list(token.claims.keys())}" + ) + return str(value) + + async def __aexit__(self, *args: object) -> None: + pass + + +def TokenClaim(name: str) -> str: + """Get a specific claim from the access token. + + This dependency extracts a single claim value from the current access token. + It's useful for getting user identifiers, roles, or other token claims + without needing the full token object. + + Args: + name: The name of the claim to extract (e.g., "oid", "sub", "email") + + Returns: + A dependency that resolves to the claim value as a string + + Raises: + RuntimeError: If no access token is available or claim is missing + + Example: + ```python + from fastmcp.server.dependencies import TokenClaim + + @mcp.tool() + async def add_expense( + user_id: str = TokenClaim("oid"), # Azure object ID + amount: float, + ): + # user_id is automatically injected from the token + await db.insert({"user_id": user_id, "amount": amount}) + ``` + """ + return cast(str, _TokenClaim(name)) diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index bff0443a5..7527f1b76 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -46,26 +46,38 @@ class ToolDecoratorMixin: from fastmcp.decorators import get_fastmcp_meta from fastmcp.tools.function_tool import ToolMeta - meta = get_fastmcp_meta(tool) - if meta is not None and isinstance(meta, ToolMeta): - resolved_task = meta.task if meta.task is not None else False - enabled = meta.enabled + fmeta = get_fastmcp_meta(tool) + if fmeta is not None and isinstance(fmeta, ToolMeta): + resolved_task = fmeta.task if fmeta.task is not None else False + enabled = fmeta.enabled + + # Merge ToolMeta.app into the meta dict + tool_meta = fmeta.meta + if fmeta.app is not None: + from fastmcp.server.apps import app_config_to_meta_dict + + tool_meta = dict(tool_meta) if tool_meta else {} + if fmeta.app is True: + tool_meta["ui"] = True + else: + tool_meta["ui"] = app_config_to_meta_dict(fmeta.app) + tool = Tool.from_function( tool, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - tags=meta.tags, - output_schema=meta.output_schema, - annotations=meta.annotations, - meta=meta.meta, + name=fmeta.name, + version=fmeta.version, + title=fmeta.title, + description=fmeta.description, + icons=fmeta.icons, + tags=fmeta.tags, + output_schema=fmeta.output_schema, + annotations=fmeta.annotations, + meta=tool_meta, task=resolved_task, - exclude_args=meta.exclude_args, - serializer=meta.serializer, - timeout=meta.timeout, - auth=meta.auth, + exclude_args=fmeta.exclude_args, + serializer=fmeta.serializer, + timeout=fmeta.timeout, + auth=fmeta.auth, ) else: tool = Tool.from_function(tool) diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 729968916..c9aa94a76 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, Literal, cast +import anyio from mcp.types import ( ClientCapabilities, CreateMessageResult, @@ -31,6 +32,7 @@ from typing_extensions import TypeVar from fastmcp import settings from fastmcp.exceptions import ToolError from fastmcp.server.sampling.sampling_tool import SamplingTool +from fastmcp.utilities.async_utils import gather from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter @@ -239,6 +241,7 @@ async def execute_tools( tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, + tool_concurrency: int | None = None, ) -> list[ToolResultContent]: """Execute tool calls and return results. @@ -249,66 +252,96 @@ async def execute_tools( When masked, only generic error messages are returned to the LLM. Tools can explicitly raise ToolError to bypass masking when they want to provide specific error messages to the LLM. + tool_concurrency: Controls parallel execution of tools: + - None (default): Sequential execution (one at a time) + - 0: Unlimited parallel execution + - N > 0: Execute at most N tools concurrently + If any tool has sequential=True, all tools execute sequentially + regardless of this setting. Returns: - List of tool result content blocks. + List of tool result content blocks in the same order as tool_calls. """ - tool_results: list[ToolResultContent] = [] + if tool_concurrency is not None and tool_concurrency < 0: + raise ValueError( + f"tool_concurrency must be None, 0 (unlimited), or a positive integer, " + f"got {tool_concurrency}" + ) - for tool_use in tool_calls: + async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent: + """Execute a single tool and return its result.""" tool = tool_map.get(tool_use.name) if tool is None: - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[ - TextContent( - type="text", - text=f"Error: Unknown tool '{tool_use.name}'", - ) - ], - isError=True, - ) + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[ + TextContent( + type="text", + text=f"Error: Unknown tool '{tool_use.name}'", + ) + ], + isError=True, ) - else: - try: - result_value = await tool.run(tool_use.input) - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=str(result_value))], - ) - ) - except ToolError as e: - # ToolError is the escape hatch - always pass message through - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=str(e))], - isError=True, - ) - ) - except Exception as e: - # Generic exceptions - mask based on setting - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - if mask_error_details: - error_text = f"Error executing tool '{tool_use.name}'" - else: - error_text = f"Error executing tool '{tool_use.name}': {e}" - tool_results.append( - ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=error_text)], - isError=True, - ) - ) - return tool_results + try: + result_value = await tool.run(tool_use.input) + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[TextContent(type="text", text=str(result_value))], + ) + except ToolError as e: + # ToolError is the escape hatch - always pass message through + logger.exception(f"Error calling sampling tool '{tool_use.name}'") + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[TextContent(type="text", text=str(e))], + isError=True, + ) + except Exception as e: + # Generic exceptions - mask based on setting + logger.exception(f"Error calling sampling tool '{tool_use.name}'") + if mask_error_details: + error_text = f"Error executing tool '{tool_use.name}'" + else: + error_text = f"Error executing tool '{tool_use.name}': {e}" + return ToolResultContent( + type="tool_result", + toolUseId=tool_use.id, + content=[TextContent(type="text", text=error_text)], + isError=True, + ) + + # Check if any tool requires sequential execution + requires_sequential = any( + tool.sequential + for tool_use in tool_calls + if (tool := tool_map.get(tool_use.name)) is not None + ) + + # Execute sequentially if required or if concurrency is None (default) + if tool_concurrency is None or requires_sequential: + tool_results: list[ToolResultContent] = [] + for tool_use in tool_calls: + result = await _execute_single_tool(tool_use) + tool_results.append(result) + return tool_results + + # Execute in parallel + if tool_concurrency == 0: + # Unlimited parallel execution + return await gather(*[_execute_single_tool(tc) for tc in tool_calls]) + else: + # Bounded parallel execution with semaphore + semaphore = anyio.Semaphore(tool_concurrency) + + async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent: + async with semaphore: + return await _execute_single_tool(tool_use) + + return await gather(*[bounded_execute(tc) for tc in tool_calls]) # --- Helper functions for sampling --- @@ -412,6 +445,7 @@ async def sample_step_impl( tool_choice: ToolChoiceOption | str | None = None, auto_execute_tools: bool = True, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SampleStep: """Implementation of Context.sample_step(). @@ -498,7 +532,10 @@ async def sample_step_impl( else settings.mask_error_details ) tool_results: list[ToolResultContent] = await execute_tools( - step_tool_calls, tool_map, mask_error_details=effective_mask + step_tool_calls, + tool_map, + mask_error_details=effective_mask, + tool_concurrency=tool_concurrency, ) if tool_results: @@ -523,6 +560,7 @@ async def sample_impl( tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT] | None = None, mask_error_details: bool | None = None, + tool_concurrency: int | None = None, ) -> SamplingResult[ResultT]: """Implementation of Context.sample(). @@ -561,6 +599,7 @@ async def sample_impl( tools=sampling_tools, tool_choice=tool_choice, mask_error_details=mask_error_details, + tool_concurrency=tool_concurrency, ) # Check for final_response tool call for structured output diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 106c55fc6..877be71c5 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -40,6 +40,7 @@ class SamplingTool(FastMCPBaseModel): description: str | None = None parameters: dict[str, Any] fn: Callable[..., Any] + sequential: bool = False model_config = ConfigDict(arbitrary_types_allowed=True) @@ -79,6 +80,7 @@ class SamplingTool(FastMCPBaseModel): *, name: str | None = None, description: str | None = None, + sequential: bool = False, ) -> SamplingTool: """Create a SamplingTool from a function. @@ -89,6 +91,10 @@ class SamplingTool(FastMCPBaseModel): fn: The function to create a tool from. name: Optional name override. Defaults to the function's name. description: Optional description override. Defaults to the function's docstring. + sequential: If True, this tool requires sequential execution and prevents + parallel execution of all tools in the batch. Set to True for tools + with shared state, file writes, or other operations that cannot run + concurrently. Defaults to False. Returns: A SamplingTool wrapping the function. @@ -106,4 +112,5 @@ class SamplingTool(FastMCPBaseModel): description=description or parsed.description, parameters=parsed.input_schema, fn=parsed.fn, + sequential=sequential, ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 27494f706..7dc747821 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -59,10 +59,9 @@ from fastmcp.prompts.prompt import PromptResult from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.apps import ( - ResourceUI, - ToolUI, + AppConfig, + app_config_to_meta_dict, resolve_ui_mime_type, - ui_to_meta_dict, ) from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks from fastmcp.server.dependencies import get_access_token @@ -1410,7 +1409,7 @@ class FastMCP( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, - ui: ToolUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1431,7 +1430,7 @@ class FastMCP( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, - ui: ToolUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1451,7 +1450,7 @@ class FastMCP( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, - ui: ToolUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1508,10 +1507,13 @@ class FastMCP( server.tool(my_function, name="custom_name") ``` """ - # Merge UI metadata into meta["ui"] before passing to provider - if ui is not None: + # Merge app config into meta["ui"] (wire format) before passing to provider + if app is not None and app is not False: meta = dict(meta) if meta else {} - meta["ui"] = ui_to_meta_dict(ui) + if app is True: + meta["ui"] = True + else: + meta["ui"] = app_config_to_meta_dict(app) # Delegate to LocalProvider with server-level defaults result = self._local_provider.tool( @@ -1571,7 +1573,7 @@ class FastMCP( tags: set[str] | None = None, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, - ui: ResourceUI | dict[str, Any] | None = None, + app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, ) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]: @@ -1637,10 +1639,27 @@ class FastMCP( # Apply default MIME type for ui:// scheme resources mime_type = resolve_ui_mime_type(uri, mime_type) - # Merge UI metadata into meta["ui"] before passing to provider - if ui is not None: + # Validate app config for resources β€” resource_uri and visibility + # don't apply since the resource itself is the UI + if isinstance(app, AppConfig): + if app.resource_uri is not None: + raise ValueError( + "resource_uri cannot be set on resources β€” " + "the resource itself is the UI. " + "Use resource_uri on tools to point to a UI resource." + ) + if app.visibility is not None: + raise ValueError( + "visibility cannot be set on resources β€” it only applies to tools." + ) + + # Merge app config into meta["ui"] (wire format) before passing to provider + if app is not None and app is not False: meta = dict(meta) if meta else {} - meta["ui"] = ui_to_meta_dict(ui) + if app is True: + meta["ui"] = True + else: + meta["ui"] = app_config_to_meta_dict(app) # Delegate to LocalProvider with server-level defaults inner_decorator = self._local_provider.resource( diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 7ef6df398..812f5108c 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -73,6 +73,7 @@ class ToolMeta: output_schema: dict[str, Any] | NotSetT | None = NotSet annotations: ToolAnnotations | None = None meta: dict[str, Any] | None = None + app: Any = None task: bool | TaskConfig | None = None exclude_args: list[str] | None = None serializer: Any | None = None diff --git a/tests/cli/test_generate_cli.py b/tests/cli/test_generate_cli.py index f513338d7..8f567c846 100644 --- a/tests/cli/test_generate_cli.py +++ b/tests/cli/test_generate_cli.py @@ -13,11 +13,14 @@ from fastmcp.cli import generate as generate_module from fastmcp.cli.client import Client from fastmcp.cli.generate import ( _derive_server_name, + _param_to_cli_flag, _schema_to_python_type, + _schema_type_label, _to_python_identifier, _tool_function_source, generate_cli_command, generate_cli_script, + generate_skill_content, serialize_transport, ) from fastmcp.client.transports.stdio import StdioTransport @@ -636,3 +639,280 @@ class TestGenerateCliCommand: output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) assert output.stat().st_mode & 0o111 + + @pytest.mark.usefixtures("_patch_client") + async def test_writes_skill_file(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + skill_path = tmp_path / "SKILL.md" + assert skill_path.exists() + content = skill_path.read_text() + assert "---" in content + assert "name:" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_contains_tools(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "### greet" in content + assert "### add" in content + assert "--name" in content + assert "call-tool greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_no_skill_flag(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output), no_skill=True) + assert not (tmp_path / "SKILL.md").exists() + + @pytest.mark.usefixtures("_patch_client") + async def test_error_if_skill_exists(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + with pytest.raises(SystemExit): + await generate_cli_command("test-server", str(output)) + + @pytest.mark.usefixtures("_patch_client") + async def test_force_overwrites_skill(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + await generate_cli_command("test-server", str(output), force=True) + content = (tmp_path / "SKILL.md").read_text() + assert content != "existing" + assert "### greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_references_cli_filename(self, tmp_path: Path): + output = tmp_path / "my_weather.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "uv run --with fastmcp python my_weather.py" in content + + +# --------------------------------------------------------------------------- +# _param_to_cli_flag +# --------------------------------------------------------------------------- + + +class TestParamToCliFlag: + def test_simple_name(self): + assert _param_to_cli_flag("city") == "--city" + + def test_underscore_name(self): + assert _param_to_cli_flag("max_days") == "--max-days" + + def test_hyphenated_name(self): + # content-type β†’ _to_python_identifier β†’ content_type β†’ --content-type + assert _param_to_cli_flag("content-type") == "--content-type" + + def test_digit_prefix(self): + # 3d_mode β†’ _3d_mode β†’ --3d-mode (leading underscore stripped) + assert _param_to_cli_flag("3d_mode") == "--3d-mode" + + def test_trailing_underscore(self): + # from β†’ from_ after identifier sanitization; Cyclopts strips trailing "-" + assert _param_to_cli_flag("from") == "--from" + + def test_camel_case(self): + # camelCase β†’ camel-case (cyclopts default_name_transform) + assert _param_to_cli_flag("myParam") == "--my-param" + + def test_pascal_case(self): + assert _param_to_cli_flag("MyParam") == "--my-param" + + +# --------------------------------------------------------------------------- +# _schema_type_label +# --------------------------------------------------------------------------- + + +class TestSchemaTypeLabel: + def test_simple_string(self): + assert _schema_type_label({"type": "string"}) == "string" + + def test_integer(self): + assert _schema_type_label({"type": "integer"}) == "integer" + + def test_array_of_strings(self): + assert ( + _schema_type_label({"type": "array", "items": {"type": "string"}}) + == "array[string]" + ) + + def test_union_types(self): + result = _schema_type_label({"type": ["string", "null"]}) + assert "string" in result + assert "null" in result + + def test_object(self): + assert _schema_type_label({"type": "object"}) == "object" + + def test_missing_type(self): + assert _schema_type_label({}) == "string" + + +# --------------------------------------------------------------------------- +# generate_skill_content +# --------------------------------------------------------------------------- + + +class TestGenerateSkillContent: + def test_frontmatter(self): + content = generate_skill_content("weather", "cli.py", []) + assert content.startswith("---\n") + assert 'name: "weather-cli"' in content + assert "description:" in content + + def test_no_tools(self): + content = generate_skill_content("weather", "cli.py", []) + assert "## Utility Commands" in content + assert "## Tool Commands" not in content + + def test_tool_sections(self): + tools = [ + mcp.types.Tool( + name="greet", + description="Say hello", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Who to greet"} + }, + "required": ["name"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "## Tool Commands" in content + assert "### greet" in content + assert "Say hello" in content + assert "call-tool greet" in content + assert "`--name`" in content + assert "| string |" in content + assert "| yes |" in content + + def test_frontmatter_with_tools_starts_at_column_zero(self): + tools = [ + mcp.types.Tool( + name="greet", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("weather", "cli.py", tools) + assert content.splitlines()[0] == "---" + + def test_optional_param(self): + tools = [ + mcp.types.Tool( + name="search", + description="Search things", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # query is required, limit is not + assert "| `--query` | string | yes |" in content + assert "| `--limit` | integer | no |" in content + + def test_complex_json_param(self): + tools = [ + mcp.types.Tool( + name="create", + description="Create item", + inputSchema={ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": {"x": {"type": "integer"}}, + }, + }, + "required": ["data"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "JSON string" in content + + def test_no_params_tool(self): + tools = [ + mcp.types.Tool( + name="ping", + description="Ping the server", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "### ping" in content + assert "call-tool ping" in content + # No parameter table + assert "| Flag |" not in content + + def test_cli_filename_in_utility_commands(self): + content = generate_skill_content("test", "my_cli.py", []) + assert "uv run --with fastmcp python my_cli.py list-tools" in content + assert "uv run --with fastmcp python my_cli.py list-resources" in content + + def test_pipe_in_description_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "mode": {"type": "string", "description": "a|b|c"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "a\\|b\\|c" in content + + def test_union_type_pipes_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "val": {"type": ["string", "null"]}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # Pipes in type label must be escaped so markdown table renders correctly + assert "string \\| null" in content + + def test_boolean_param_no_value_placeholder(self): + tools = [ + mcp.types.Tool( + name="run", + description="Run something", + inputSchema={ + "type": "object", + "properties": { + "verbose": {"type": "boolean", "description": "Verbose output"}, + "name": {"type": "string"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "--verbose " not in content + assert "--name " in content + + def test_server_name_in_header(self): + content = generate_skill_content("My Weather API", "cli.py", []) + assert "# My Weather API CLI" in content + assert 'name: "my-weather-api-cli"' in content diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py new file mode 100644 index 000000000..c9f17cdbe --- /dev/null +++ b/tests/client/auth/test_oauth_static_client.py @@ -0,0 +1,274 @@ +"""Tests for OAuth static client registration (pre-registered client_id/client_secret).""" + +from unittest.mock import patch + +import httpx +import pytest +from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl + +from fastmcp.client import Client +from fastmcp.client.auth import OAuth +from fastmcp.client.auth.oauth import ClientNotFoundError +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.server.auth.auth import ClientRegistrationOptions +from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider +from fastmcp.server.server import FastMCP +from fastmcp.utilities.http import find_available_port +from fastmcp.utilities.tests import HeadlessOAuth, run_server_async + + +class TestStaticClientInfoConstruction: + """Static client info should include full metadata from client_metadata.""" + + def test_static_client_info_includes_metadata(self): + """Static client info should include redirect_uris, grant_types, etc.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client-id", + client_secret="my-secret", + scopes=["read", "write"], + ) + + info = oauth._static_client_info + assert info is not None + assert info.client_id == "my-client-id" + assert info.client_secret == "my-secret" + # Metadata fields should be populated from client_metadata + assert info.redirect_uris is not None + assert len(info.redirect_uris) == 1 + assert info.grant_types is not None + assert "authorization_code" in info.grant_types + assert "refresh_token" in info.grant_types + assert info.response_types is not None + assert "code" in info.response_types + assert info.scope == "read write" + assert info.token_endpoint_auth_method == "client_secret_post" + + def test_static_client_info_without_secret(self): + """Public clients can provide client_id without client_secret.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="public-client", + ) + + info = oauth._static_client_info + assert info is not None + assert info.client_id == "public-client" + assert info.client_secret is None + assert info.token_endpoint_auth_method == "none" + # Metadata should still be present + assert info.redirect_uris is not None + assert info.grant_types is not None + + def test_no_static_client_info_without_client_id(self): + """When no client_id is provided, _static_client_info should be None.""" + oauth = OAuth(mcp_url="https://example.com/mcp") + assert oauth._static_client_info is None + + def test_static_client_info_includes_additional_metadata(self): + """Additional client metadata should be included in static client info.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client", + additional_client_metadata={ + "token_endpoint_auth_method": "client_secret_post" + }, + ) + + info = oauth._static_client_info + assert info is not None + assert info.token_endpoint_auth_method == "client_secret_post" + + +class TestStaticClientInitialize: + """_initialize should set context.client_info and persist to storage.""" + + async def test_initialize_sets_context_client_info(self): + """_initialize should inject static client info into the auth context.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client", + client_secret="my-secret", + ) + + # Mock the parent _initialize since it needs a real server + with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): + await oauth._initialize() + + assert oauth.context.client_info is not None + assert oauth.context.client_info.client_id == "my-client" + assert oauth.context.client_info.client_secret == "my-secret" + + async def test_initialize_persists_static_client_to_storage(self): + """Static client info should be persisted to token storage.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="my-client", + client_secret="my-secret", + ) + + with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): + await oauth._initialize() + + # Verify it was persisted to storage + stored = await oauth.token_storage_adapter.get_client_info() + assert stored is not None + assert stored.client_id == "my-client" + + async def test_initialize_without_static_creds_works(self): + """_initialize should not error when no static credentials are provided.""" + oauth = OAuth(mcp_url="https://example.com/mcp") + + with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): + # This should not raise AttributeError + await oauth._initialize() + + # context.client_info should be whatever the parent set (None by default) + + +class TestStaticClientRetryBehavior: + """Retry-on-stale-credentials should short-circuit for static creds.""" + + async def test_retry_skipped_with_static_creds(self): + """When static creds are rejected, should raise immediately, not retry.""" + oauth = OAuth( + mcp_url="https://example.com/mcp", + client_id="bad-client-id", + client_secret="bad-secret", + ) + + # Make the parent auth flow raise ClientNotFoundError + async def failing_auth_flow(request): + raise ClientNotFoundError("client not found") + yield # make it a generator # noqa: E275 + + with patch.object( + OAuth.__bases__[0], "async_auth_flow", side_effect=failing_auth_flow + ): + flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) + with pytest.raises(ClientNotFoundError, match="static client credentials"): + await flow.__anext__() + + async def test_retry_still_works_without_static_creds(self): + """Without static creds, the retry behavior should be preserved.""" + oauth = OAuth(mcp_url="https://example.com/mcp") + + call_count = 0 + + async def auth_flow_with_retry(request): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise ClientNotFoundError("client not found") + # Second attempt succeeds + yield httpx.Request("GET", "https://example.com") + + with patch.object( + OAuth.__bases__[0], "async_auth_flow", side_effect=auth_flow_with_retry + ): + flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) + request = await flow.__anext__() + assert request is not None + assert call_count == 2 + + +class TestStaticClientE2E: + """End-to-end tests with a real OAuth server using pre-registered clients.""" + + async def test_static_client_with_dcr_disabled(self): + """Static client_id should work when the server has DCR disabled.""" + port = find_available_port() + callback_port = find_available_port() + issuer_url = f"http://127.0.0.1:{port}" + + provider = InMemoryOAuthProvider( + base_url=issuer_url, + client_registration_options=ClientRegistrationOptions( + enabled=False, # DCR disabled + valid_scopes=["read", "write"], + ), + ) + + server = FastMCP("TestServer", auth=provider) + + @server.tool + def greet(name: str) -> str: + return f"Hello, {name}!" + + # Pre-register a client directly in the provider. + # The redirect_uri must match what the OAuth client will use. + pre_registered = OAuthClientInformationFull( + client_id="pre-registered-client", + client_secret="pre-registered-secret", + redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="client_secret_post", + scope="read write", + ) + await provider.register_client(pre_registered) + + async with run_server_async(server, port=port, transport="http") as url: + oauth = HeadlessOAuth( + mcp_url=url, + client_id="pre-registered-client", + client_secret="pre-registered-secret", + scopes=["read", "write"], + callback_port=callback_port, + ) + + async with Client( + transport=StreamableHttpTransport(url), + auth=oauth, + ) as client: + assert await client.ping() + tools = await client.list_tools() + assert any(t.name == "greet" for t in tools) + + async def test_static_client_with_dcr_enabled(self): + """Static client_id should also work when DCR is enabled (skips DCR).""" + port = find_available_port() + callback_port = find_available_port() + issuer_url = f"http://127.0.0.1:{port}" + + provider = InMemoryOAuthProvider( + base_url=issuer_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=["read"], + ), + ) + + server = FastMCP("TestServer", auth=provider) + + @server.tool + def add(a: int, b: int) -> int: + return a + b + + pre_registered = OAuthClientInformationFull( + client_id="my-app", + client_secret="my-secret", + redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="client_secret_post", + scope="read", + ) + await provider.register_client(pre_registered) + + async with run_server_async(server, port=port, transport="http") as url: + oauth = HeadlessOAuth( + mcp_url=url, + client_id="my-app", + client_secret="my-secret", + scopes=["read"], + callback_port=callback_port, + ) + + async with Client( + transport=StreamableHttpTransport(url), + auth=oauth, + ) as client: + result = await client.call_tool("add", {"a": 3, "b": 4}) + assert result.data == 7 diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py index e379d6707..e5a45adc7 100644 --- a/tests/client/test_sampling.py +++ b/tests/client/test_sampling.py @@ -563,6 +563,492 @@ class TestAutomaticToolLoop: assert "Tool failed intentionally" in error_text assert result.data == "Handled error" + async def test_concurrent_tool_execution_default_sequential(self): + """Test that tools execute sequentially by default.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def slow_tool_a(x: int) -> int: + """Slow tool A.""" + start = time.time() + execution_order.append(("tool_a_start", start)) + await asyncio.sleep(0.1) + execution_order.append(("tool_a_end", time.time())) + return x * 2 + + async def slow_tool_b(y: int) -> int: + """Slow tool B.""" + start = time.time() + execution_order.append(("tool_b_start", start)) + await asyncio.sleep(0.1) + execution_order.append(("tool_b_end", time.time())) + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_a", + name="slow_tool_a", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_b", + name="slow_tool_b", + input={"y": 3}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool_a, slow_tool_b], + # Default: tool_concurrency=None (sequential) + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify sequential execution: tool_a must complete before tool_b starts + events = [e[0] for e in execution_order] + assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"] + + async def test_concurrent_tool_execution_unlimited(self): + """Test unlimited parallel tool execution with tool_concurrency=0.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_times: dict[str, dict[str, float]] = {} + + async def slow_tool_a(x: int) -> int: + """Slow tool A.""" + execution_times["tool_a"] = {"start": time.time()} + await asyncio.sleep(0.1) + execution_times["tool_a"]["end"] = time.time() + return x * 2 + + async def slow_tool_b(y: int) -> int: + """Slow tool B.""" + execution_times["tool_b"] = {"start": time.time()} + await asyncio.sleep(0.1) + execution_times["tool_b"]["end"] = time.time() + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_a", + name="slow_tool_a", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_b", + name="slow_tool_b", + input={"y": 3}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool_a, slow_tool_b], + tool_concurrency=0, # Unlimited parallel + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify parallel execution: both tools should overlap in time + assert "tool_a" in execution_times + assert "tool_b" in execution_times + # tool_b should start before tool_a finishes (overlap) + assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"] + + async def test_concurrent_tool_execution_bounded(self): + """Test bounded parallel execution with tool_concurrency=2.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def slow_tool(name: str, duration: float = 0.1) -> str: + """Generic slow tool.""" + execution_order.append((f"{name}_start", time.time())) + await asyncio.sleep(duration) + execution_order.append((f"{name}_end", time.time())) + return f"{name} done" + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + # Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd) + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="slow_tool", + input={"name": "tool_1", "duration": 0.1}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="slow_tool", + input={"name": "tool_2", "duration": 0.1}, + ), + ToolUseContent( + type="tool_use", + id="call_3", + name="slow_tool", + input={"name": "tool_3", "duration": 0.05}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[slow_tool], + tool_concurrency=2, # Max 2 concurrent + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify that at most 2 tools run concurrently + events = [e[0] for e in execution_order] + # First 2 tools should start before either ends + assert events[0] in ["tool_1_start", "tool_2_start"] + assert events[1] in ["tool_1_start", "tool_2_start"] + # Third tool should start after at least one of the first two finishes + tool_3_start_idx = events.index("tool_3_start") + assert ( + "tool_1_end" in events[:tool_3_start_idx] + or "tool_2_end" in events[:tool_3_start_idx] + ) + + async def test_sequential_tool_forces_sequential_execution(self): + """Test that sequential=True forces all tools to execute sequentially.""" + import asyncio + import time + + from mcp.types import CreateMessageResultWithTools, ToolUseContent + + execution_order: list[tuple[str, float]] = [] + + async def normal_tool(x: int) -> int: + """Normal tool.""" + execution_order.append(("normal_start", time.time())) + await asyncio.sleep(0.05) + execution_order.append(("normal_end", time.time())) + return x * 2 + + async def sequential_tool(y: int) -> int: + """Sequential tool.""" + execution_order.append(("sequential_start", time.time())) + await asyncio.sleep(0.05) + execution_order.append(("sequential_end", time.time())) + return y + 10 + + call_count = 0 + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + nonlocal call_count + call_count += 1 + + if call_count == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="normal_tool", + input={"x": 5}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="sequential_tool", + input={"y": 3}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + # Create tools with sequential=True for one of them + normal = SamplingTool.from_function(normal_tool, sequential=False) + sequential = SamplingTool.from_function(sequential_tool, sequential=True) + + result = await context.sample( + messages="Run tools", + tools=[normal, sequential], + tool_concurrency=0, # Request unlimited, but sequential tool forces sequential + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Verify sequential execution: first tool must complete before second starts + events = [e[0] for e in execution_order] + assert events[0] in ["normal_start", "sequential_start"] + assert events[1] in ["normal_end", "sequential_end"] + # Ensure the second tool starts after the first ends + if events[0] == "normal_start": + assert events[1] == "normal_end" + assert events[2] == "sequential_start" + else: + assert events[1] == "sequential_end" + assert events[2] == "normal_start" + + async def test_concurrent_tool_execution_error_handling(self): + """Test that errors are captured per-tool in parallel execution.""" + from mcp.types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + def good_tool() -> str: + return "success" + + def bad_tool() -> str: + raise ValueError("Tool error") + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", id="call_1", name="good_tool", input={} + ), + ToolUseContent( + type="tool_use", id="call_2", name="bad_tool", input={} + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Handled errors")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[good_tool, bad_tool], + tool_concurrency=0, # Parallel execution + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Handled errors" + # Check that tool results include both success and error + tool_result_message = messages_received[1][-1] + assert tool_result_message.role == "user" + tool_results = cast(list[ToolResultContent], tool_result_message.content) + assert len(tool_results) == 2 + # One should be success, one should be error + assert any(not r.isError for r in tool_results) + assert any(r.isError for r in tool_results) + + async def test_concurrent_tool_result_order_preserved(self): + """Test that tool results maintain the same order as tool calls.""" + import asyncio + + from mcp.types import ( + CreateMessageResultWithTools, + ToolResultContent, + ToolUseContent, + ) + + async def tool_with_delay(value: int, delay: float) -> int: + """Tool that takes variable time.""" + await asyncio.sleep(delay) + return value + + messages_received: list[list[SamplingMessage]] = [] + + def sampling_handler( + messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext + ) -> CreateMessageResultWithTools: + messages_received.append(list(messages)) + + if len(messages_received) == 1: + # Tools with different delays - later tools finish first + return CreateMessageResultWithTools( + role="assistant", + content=[ + ToolUseContent( + type="tool_use", + id="call_1", + name="tool_with_delay", + input={"value": 1, "delay": 0.15}, + ), + ToolUseContent( + type="tool_use", + id="call_2", + name="tool_with_delay", + input={"value": 2, "delay": 0.05}, + ), + ToolUseContent( + type="tool_use", + id="call_3", + name="tool_with_delay", + input={"value": 3, "delay": 0.1}, + ), + ], + model="test-model", + stopReason="toolUse", + ) + else: + return CreateMessageResultWithTools( + role="assistant", + content=[TextContent(type="text", text="Done!")], + model="test-model", + stopReason="endTurn", + ) + + mcp = FastMCP(sampling_handler=sampling_handler) + + @mcp.tool + async def test_tool(context: Context) -> str: + result = await context.sample( + messages="Run tools", + tools=[tool_with_delay], + tool_concurrency=0, # Parallel execution + ) + return result.text or "" + + async with Client(mcp) as client: + result = await client.call_tool("test_tool", {}) + + assert result.data == "Done!" + # Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1) + tool_result_message = messages_received[1][-1] + tool_results = cast(list[ToolResultContent], tool_result_message.content) + assert len(tool_results) == 3 + assert tool_results[0].toolUseId == "call_1" + assert tool_results[1].toolUseId == "call_2" + assert tool_results[2].toolUseId == "call_3" + # Check values are correct + result_texts = [cast(TextContent, r.content[0]).text for r in tool_results] + assert result_texts == ["1", "2", "3"] + class TestSamplingResultType: """Tests for result_type parameter (structured output).""" diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 6bf25a50d..0ea6166bf 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -2,6 +2,8 @@ from urllib.parse import parse_qs, urlparse +import pytest +from key_value.aio.stores.memory import MemoryStore from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl @@ -14,10 +16,16 @@ from fastmcp.server.auth.providers.azure import ( from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestAzureProvider: """Test Azure OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test AzureProvider initialization with explicit parameters.""" provider = AzureProvider( client_id="12345678-1234-1234-1234-123456789012", @@ -26,6 +34,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read", "write"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "12345678-1234-1234-1234-123456789012" @@ -37,7 +46,7 @@ class TestAzureProvider: parsed_token = urlparse(provider._upstream_token_endpoint) assert "87654321-4321-4321-4321-210987654321" in parsed_token.path - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = AzureProvider( client_id="test_client", @@ -46,13 +55,14 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" # Azure provider defaults are set but we can't easily verify them without accessing internals - def test_offline_access_automatically_included(self): + def test_offline_access_automatically_included(self, memory_storage: MemoryStore): """Test that offline_access is automatically added to get refresh tokens.""" # Without specifying offline_access provider = AzureProvider( @@ -62,11 +72,12 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert "offline_access" in provider.additional_authorize_scopes - def test_offline_access_not_duplicated(self): + def test_offline_access_not_duplicated(self, memory_storage: MemoryStore): """Test that offline_access is not duplicated if already specified.""" provider = AzureProvider( client_id="test_client", @@ -76,13 +87,14 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["User.Read", "offline_access"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Should appear exactly once assert provider.additional_authorize_scopes.count("offline_access") == 1 assert "User.Read" in provider.additional_authorize_scopes - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = AzureProvider( client_id="test_client", @@ -91,6 +103,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test_secret", + client_storage=memory_storage, ) # Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant @@ -106,7 +119,7 @@ class TestAzureProvider: provider._upstream_revocation_endpoint is None ) # Azure doesn't support revocation - def test_special_tenant_values(self): + def test_special_tenant_values(self, memory_storage: MemoryStore): """Test that special tenant values are accepted.""" # Test with "organizations" provider1 = AzureProvider( @@ -116,6 +129,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider1._upstream_authorization_endpoint) assert "/organizations/" in parsed.path @@ -128,11 +142,12 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider2._upstream_authorization_endpoint) assert "/consumers/" in parsed.path - def test_azure_specific_scopes(self): + def test_azure_specific_scopes(self, memory_storage: MemoryStore): """Test handling of custom API scope formats.""" # Test that the provider accepts custom API scopes without error provider = AzureProvider( @@ -146,6 +161,7 @@ class TestAzureProvider: "admin", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Provider should initialize successfully with these scopes @@ -158,7 +174,9 @@ class TestAzureProvider: "admin", ] - def test_init_does_not_require_api_client_id_anymore(self): + def test_init_does_not_require_api_client_id_anymore( + self, memory_storage: MemoryStore + ): """API client ID is no longer required; audience is client_id.""" provider = AzureProvider( client_id="test_client", @@ -167,10 +185,13 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider is not None - def test_init_with_custom_audience_uses_jwt_verifier(self): + def test_init_with_custom_audience_uses_jwt_verifier( + self, memory_storage: MemoryStore + ): """When audience is provided, JWTVerifier is configured with JWKS and issuer.""" from fastmcp.server.auth.providers.jwt import JWTVerifier @@ -182,6 +203,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=[".default"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._token_validator is not None @@ -197,7 +219,9 @@ class TestAzureProvider: # (Azure returns unprefixed scopes like ".default" in JWT tokens) assert verifier.required_scopes == [".default"] - async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self): + async def test_authorize_filters_resource_and_stores_unprefixed_scopes( + self, memory_storage: MemoryStore + ): """authorize() should drop resource parameter and store unprefixed scopes for MCP clients.""" provider = AzureProvider( client_id="test_client", @@ -207,6 +231,7 @@ class TestAzureProvider: required_scopes=["read", "write"], base_url="https://srv.example", jwt_signing_key="test-secret", + client_storage=memory_storage, ) await provider.register_client( @@ -264,7 +289,9 @@ class TestAzureProvider: or "api://my-api/write" in upstream_url ) - async def test_authorize_appends_additional_scopes(self): + async def test_authorize_appends_additional_scopes( + self, memory_storage: MemoryStore + ): """authorize() should append additional_authorize_scopes to the authorization request.""" provider = AzureProvider( client_id="test_client", @@ -275,6 +302,7 @@ class TestAzureProvider: base_url="https://srv.example", additional_authorize_scopes=["Mail.Read", "User.Read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) await provider.register_client( @@ -326,7 +354,7 @@ class TestAzureProvider: assert "Mail.Read" in upstream_url assert "User.Read" in upstream_url - def test_base_authority_defaults_to_public_cloud(self): + def test_base_authority_defaults_to_public_cloud(self, memory_storage: MemoryStore): """Test that base_authority defaults to login.microsoftonline.com.""" provider = AzureProvider( client_id="test_client", @@ -335,6 +363,7 @@ class TestAzureProvider: base_url="https://myserver.com", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert ( @@ -355,7 +384,7 @@ class TestAzureProvider: == "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys" ) - def test_base_authority_azure_government(self): + def test_base_authority_azure_government(self, memory_storage: MemoryStore): """Test Azure Government endpoints with login.microsoftonline.us.""" provider = AzureProvider( client_id="test_client", @@ -365,6 +394,7 @@ class TestAzureProvider: required_scopes=["read"], base_authority="login.microsoftonline.us", jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert ( @@ -385,7 +415,7 @@ class TestAzureProvider: == "https://login.microsoftonline.us/gov-tenant-id/discovery/v2.0/keys" ) - def test_base_authority_from_parameter(self): + def test_base_authority_from_parameter(self, memory_storage: MemoryStore): """Test that base_authority can be set via parameter.""" provider = AzureProvider( client_id="env-client-id", @@ -395,6 +425,7 @@ class TestAzureProvider: required_scopes=["read"], base_authority="login.microsoftonline.us", jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert ( @@ -415,7 +446,9 @@ class TestAzureProvider: == "https://login.microsoftonline.us/env-tenant-id/discovery/v2.0/keys" ) - def test_base_authority_with_special_tenant_values(self): + def test_base_authority_with_special_tenant_values( + self, memory_storage: MemoryStore + ): """Test that base_authority works with special tenant values like 'organizations'.""" provider = AzureProvider( client_id="test_client", @@ -425,13 +458,16 @@ class TestAzureProvider: required_scopes=["read"], base_authority="login.microsoftonline.us", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider._upstream_authorization_endpoint) assert parsed.netloc == "login.microsoftonline.us" assert "/organizations/" in parsed.path - def test_prepare_scopes_for_upstream_refresh_basic_prefixing(self): + def test_prepare_scopes_for_upstream_refresh_basic_prefixing( + self, memory_storage: MemoryStore + ): """Test that unprefixed scopes are correctly prefixed for Azure token refresh.""" provider = AzureProvider( client_id="test_client", @@ -441,6 +477,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read", "write"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Unprefixed scopes from storage should be prefixed @@ -451,7 +488,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included for refresh tokens assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_already_prefixed(self): + def test_prepare_scopes_for_upstream_refresh_already_prefixed( + self, memory_storage: MemoryStore + ): """Test that already-prefixed scopes remain unchanged.""" provider = AzureProvider( client_id="test_client", @@ -461,6 +500,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Already prefixed scopes should pass through unchanged @@ -473,7 +513,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included for refresh tokens assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_with_additional_scopes(self): + def test_prepare_scopes_for_upstream_refresh_with_additional_scopes( + self, memory_storage: MemoryStore + ): """Test that only OIDC scopes from additional_authorize_scopes are added. Azure only allows ONE resource per token request (AADSTS28000), so @@ -493,6 +535,7 @@ class TestAzureProvider: "offline_access", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Base scopes should be prefixed, only OIDC scopes appended @@ -508,6 +551,7 @@ class TestAzureProvider: def test_prepare_scopes_for_upstream_refresh_filters_duplicate_additional_scopes( self, + memory_storage: MemoryStore, ): """Test that accidentally stored additional_authorize_scopes are filtered out.""" provider = AzureProvider( @@ -519,6 +563,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["User.Read", "openid"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # If additional scopes were accidentally stored, they should be filtered @@ -535,7 +580,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included and is OIDC assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_mixed_scopes(self): + def test_prepare_scopes_for_upstream_refresh_mixed_scopes( + self, memory_storage: MemoryStore + ): """Test mixed scenario with both prefixed and unprefixed scopes.""" provider = AzureProvider( client_id="test_client", @@ -546,6 +593,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["openid"], # OIDC scope jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Mix of prefixed and unprefixed scopes @@ -560,7 +608,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included assert len(result) == 5 - def test_prepare_scopes_for_upstream_refresh_scope_with_slash(self): + def test_prepare_scopes_for_upstream_refresh_scope_with_slash( + self, memory_storage: MemoryStore + ): """Test that scopes containing '/' are not prefixed.""" provider = AzureProvider( client_id="test_client", @@ -570,6 +620,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Scopes with "/" should not be prefixed (already fully qualified) @@ -582,7 +633,9 @@ class TestAzureProvider: "https://graph.microsoft.com/.default" in result ) # Not prefixed (contains ://) - def test_prepare_scopes_for_upstream_refresh_empty_scopes(self): + def test_prepare_scopes_for_upstream_refresh_empty_scopes( + self, memory_storage: MemoryStore + ): """Test behavior with empty scopes list.""" provider = AzureProvider( client_id="test_client", @@ -593,6 +646,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["User.Read", "openid"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Empty scopes should still add OIDC scopes (not User.Read) @@ -603,7 +657,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included assert len(result) == 2 # Only OIDC scopes: openid + offline_access - def test_prepare_scopes_for_upstream_refresh_no_additional_scopes(self): + def test_prepare_scopes_for_upstream_refresh_no_additional_scopes( + self, memory_storage: MemoryStore + ): """Test behavior when no additional_authorize_scopes are configured.""" provider = AzureProvider( client_id="test_client", @@ -613,6 +669,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Should prefix base scopes, plus auto-added offline_access @@ -623,7 +680,9 @@ class TestAzureProvider: assert "offline_access" in result # Auto-included assert len(result) == 3 - def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes(self): + def test_prepare_scopes_for_upstream_refresh_deduplicates_scopes( + self, memory_storage: MemoryStore + ): """Test that duplicate scopes are deduplicated while preserving order.""" provider = AzureProvider( client_id="test_client", @@ -634,6 +693,7 @@ class TestAzureProvider: required_scopes=["read"], additional_authorize_scopes=["openid", "profile"], # OIDC scopes only jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Test with duplicate base scopes @@ -651,7 +711,9 @@ class TestAzureProvider: ] assert len(result) == 5 - def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(self): + def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants( + self, memory_storage: MemoryStore + ): """Test that both prefixed and unprefixed variants are deduplicated.""" provider = AzureProvider( client_id="test_client", @@ -661,6 +723,7 @@ class TestAzureProvider: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Test with both prefixed and unprefixed variants of same scope @@ -688,11 +751,13 @@ class TestOIDCScopeHandling: 3. OIDC scopes are still advertised to clients via valid_scopes """ - def test_oidc_scopes_constant(self): + def test_oidc_scopes_constant(self, memory_storage: MemoryStore): """Verify OIDC_SCOPES contains the standard OIDC scopes.""" assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"} - def test_prefix_scopes_does_not_prefix_oidc_scopes(self): + def test_prefix_scopes_does_not_prefix_oidc_scopes( + self, memory_storage: MemoryStore + ): """Test that _prefix_scopes_for_azure never prefixes OIDC scopes.""" provider = AzureProvider( client_id="test_client", @@ -702,6 +767,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # All OIDC scopes should pass through unchanged @@ -711,7 +777,7 @@ class TestOIDCScopeHandling: assert result == ["openid", "profile", "email", "offline_access"] - def test_prefix_scopes_mixed_oidc_and_custom(self): + def test_prefix_scopes_mixed_oidc_and_custom(self, memory_storage: MemoryStore): """Test prefixing with a mix of OIDC and custom scopes.""" provider = AzureProvider( client_id="test_client", @@ -721,6 +787,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) result = provider._prefix_scopes_for_azure( @@ -736,7 +803,9 @@ class TestOIDCScopeHandling: assert "api://my-api/openid" not in result assert "api://my-api/profile" not in result - def test_prefix_scopes_dot_notation_gets_prefixed(self): + def test_prefix_scopes_dot_notation_gets_prefixed( + self, memory_storage: MemoryStore + ): """Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph).""" provider = AzureProvider( client_id="test_client", @@ -746,6 +815,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph @@ -754,7 +824,9 @@ class TestOIDCScopeHandling: assert result == ["api://my-api/my.scope", "api://my-api/admin.read"] - def test_prefix_scopes_fully_qualified_graph_not_prefixed(self): + def test_prefix_scopes_fully_qualified_graph_not_prefixed( + self, memory_storage: MemoryStore + ): """Test that fully-qualified Graph scopes are not prefixed.""" provider = AzureProvider( client_id="test_client", @@ -764,6 +836,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) result = provider._prefix_scopes_for_azure( @@ -779,7 +852,9 @@ class TestOIDCScopeHandling: "https://graph.microsoft.com/Mail.Send", ] - def test_required_scopes_with_oidc_filters_validation(self): + def test_required_scopes_with_oidc_filters_validation( + self, memory_storage: MemoryStore + ): """Test that OIDC scopes in required_scopes are filtered from token validation.""" provider = AzureProvider( client_id="test_client", @@ -789,12 +864,15 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read", "openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Token validator should only require non-OIDC scopes assert provider._token_validator.required_scopes == ["read"] - def test_required_scopes_all_oidc_results_in_no_validation(self): + def test_required_scopes_all_oidc_results_in_no_validation( + self, memory_storage: MemoryStore + ): """Test that if all required_scopes are OIDC, no scope validation occurs.""" provider = AzureProvider( client_id="test_client", @@ -804,12 +882,13 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Token validator should have empty required scopes (all were OIDC) assert provider._token_validator.required_scopes == [] - def test_valid_scopes_includes_oidc_scopes(self): + def test_valid_scopes_includes_oidc_scopes(self, memory_storage: MemoryStore): """Test that valid_scopes advertises OIDC scopes to clients.""" provider = AzureProvider( client_id="test_client", @@ -819,6 +898,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read", "openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # required_scopes (used for validation) excludes OIDC scopes @@ -831,7 +911,9 @@ class TestOIDCScopeHandling: "profile", ] - def test_prepare_scopes_for_refresh_handles_oidc_scopes(self): + def test_prepare_scopes_for_refresh_handles_oidc_scopes( + self, memory_storage: MemoryStore + ): """Test that token refresh correctly handles OIDC scopes.""" provider = AzureProvider( client_id="test_client", @@ -841,6 +923,7 @@ class TestOIDCScopeHandling: identifier_uri="api://my-api", required_scopes=["read"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Simulate stored scopes that include OIDC scopes @@ -864,7 +947,7 @@ class TestAzureTokenExchangeScopes: properly prefixed scopes. """ - def test_prepare_scopes_returns_prefixed_scopes(self): + def test_prepare_scopes_returns_prefixed_scopes(self, memory_storage: MemoryStore): """Test that _prepare_scopes_for_token_exchange returns prefixed scopes.""" provider = AzureProvider( client_id="test_client", @@ -874,6 +957,7 @@ class TestAzureTokenExchangeScopes: identifier_uri="api://my-api", required_scopes=["read", "write"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) scopes = provider._prepare_scopes_for_token_exchange(["read", "write"]) @@ -881,7 +965,9 @@ class TestAzureTokenExchangeScopes: assert "api://my-api/read" in scopes assert "api://my-api/write" in scopes - def test_prepare_scopes_includes_additional_oidc_scopes(self): + def test_prepare_scopes_includes_additional_oidc_scopes( + self, memory_storage: MemoryStore + ): """Test that _prepare_scopes_for_token_exchange includes OIDC scopes.""" provider = AzureProvider( client_id="test_client", @@ -892,6 +978,7 @@ class TestAzureTokenExchangeScopes: required_scopes=["read"], additional_authorize_scopes=["openid", "profile", "offline_access"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) scopes = provider._prepare_scopes_for_token_exchange(["read"]) @@ -901,7 +988,9 @@ class TestAzureTokenExchangeScopes: assert "profile" in scopes assert "offline_access" in scopes - def test_prepare_scopes_excludes_other_api_scopes(self): + def test_prepare_scopes_excludes_other_api_scopes( + self, memory_storage: MemoryStore + ): """Test token exchange excludes other API scopes (Azure AADSTS28000). Azure only allows ONE resource per token exchange. Other API scopes @@ -921,6 +1010,7 @@ class TestAzureTokenExchangeScopes: "api://11111111-2222-3333-4444-555555555555/user_impersonation", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) scopes = provider._prepare_scopes_for_token_exchange(["user_impersonation"]) @@ -935,7 +1025,7 @@ class TestAzureTokenExchangeScopes: assert not any("api://aaaaaaaa" in s for s in scopes) assert not any("api://11111111" in s for s in scopes) - def test_prepare_scopes_deduplicates_scopes(self): + def test_prepare_scopes_deduplicates_scopes(self, memory_storage: MemoryStore): """Test that duplicate scopes are deduplicated.""" provider = AzureProvider( client_id="test_client", @@ -946,6 +1036,7 @@ class TestAzureTokenExchangeScopes: required_scopes=["read"], additional_authorize_scopes=["api://my-api/read", "openid"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Pass a scope that will be prefixed to match one in additional_authorize_scopes @@ -955,7 +1046,9 @@ class TestAzureTokenExchangeScopes: assert scopes.count("api://my-api/read") == 1 assert "openid" in scopes - def test_extra_token_params_does_not_contain_scope(self): + def test_extra_token_params_does_not_contain_scope( + self, memory_storage: MemoryStore + ): """Test that extra_token_params doesn't contain scope to avoid TypeError. Previously, Azure provider set extra_token_params={"scope": ...} during init. @@ -974,6 +1067,7 @@ class TestAzureTokenExchangeScopes: required_scopes=["read", "write"], additional_authorize_scopes=["openid", "profile", "offline_access"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # extra_token_params should NOT contain "scope" to avoid TypeError during refresh @@ -1122,3 +1216,99 @@ class TestAzureJWTVerifier: verifier.issuer == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0" ) + + +class TestAzureOBOIntegration: + """Tests for azure.identity OBO integration (create_obo_credential, EntraOBOToken).""" + + def test_create_obo_credential_returns_configured_credential(self): + """Test that create_obo_credential returns a properly configured credential.""" + from unittest.mock import MagicMock, patch + + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="test-tenant-id", + base_url="https://myserver.com", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + mock_credential = MagicMock() + with patch( + "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential + ) as mock_class: + credential = provider.create_obo_credential(user_assertion="user-token-123") + + mock_class.assert_called_once_with( + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + user_assertion="user-token-123", + authority="https://login.microsoftonline.com", + ) + assert credential is mock_credential + + def test_create_obo_credential_with_custom_authority(self): + """Test that create_obo_credential uses custom base_authority.""" + from unittest.mock import MagicMock, patch + + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="gov-tenant-id", + base_url="https://myserver.com", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + mock_credential = MagicMock() + with patch( + "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential + ) as mock_class: + provider.create_obo_credential(user_assertion="user-token") + + call_kwargs = mock_class.call_args[1] + assert call_kwargs["authority"] == "https://login.microsoftonline.us" + + def test_tenant_and_authority_stored_as_attributes(self): + """Test that tenant_id and base_authority are stored for OBO credential creation.""" + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="my-tenant", + base_url="https://myserver.com", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + assert provider._tenant_id == "my-tenant" + assert provider._base_authority == "login.microsoftonline.us" + + def test_entra_obo_token_is_importable(self): + """Test that EntraOBOToken can be imported.""" + from fastmcp.server.auth.providers.azure import EntraOBOToken + + assert EntraOBOToken is not None + + def test_entra_obo_token_creates_dependency(self): + """Test that EntraOBOToken creates a dependency with scopes.""" + from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken + + dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"]) + assert isinstance(dep, _EntraOBOToken) + assert dep.scopes == ["https://graph.microsoft.com/User.Read"] + + def test_entra_obo_token_is_dependency_instance(self): + """Test that EntraOBOToken is a Dependency instance.""" + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.auth.providers.azure import _EntraOBOToken + + dep = _EntraOBOToken(["scope"]) + assert isinstance(dep, Dependency) diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py index 8d79265e6..509eb0826 100644 --- a/tests/server/auth/providers/test_discord.py +++ b/tests/server/auth/providers/test_discord.py @@ -1,12 +1,21 @@ """Tests for Discord OAuth provider.""" +import pytest +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.providers.discord import DiscordProvider +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestDiscordProvider: """Test Discord OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test DiscordProvider initialization with explicit parameters.""" provider = DiscordProvider( client_id="env_client_id", @@ -14,31 +23,34 @@ class TestDiscordProvider: base_url="https://myserver.com", required_scopes=["email", "identify"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "env_client_id" assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = DiscordProvider( client_id="env_client_id", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = DiscordProvider( client_id="env_client_id", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that endpoints use Discord's OAuth2 endpoints @@ -52,7 +64,7 @@ class TestDiscordProvider: # Discord provider doesn't currently set a revocation endpoint assert provider._upstream_revocation_endpoint is None - def test_discord_specific_scopes(self): + def test_discord_specific_scopes(self, memory_storage: MemoryStore): """Test handling of Discord-specific scope formats.""" # Just test that the provider accepts Discord-specific scopes without error provider = DiscordProvider( @@ -64,6 +76,7 @@ class TestDiscordProvider: "email", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Provider should initialize successfully with these scopes diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index e2fcdaa25..fe2bbf031 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -2,16 +2,25 @@ from unittest.mock import MagicMock, patch +import pytest +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.providers.github import ( GitHubProvider, GitHubTokenVerifier, ) +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestGitHubProvider: """Test GitHubProvider initialization.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test initialization with explicit parameters.""" provider = GitHubProvider( client_id="test_client", @@ -21,6 +30,7 @@ class TestGitHubProvider: required_scopes=["user", "repo"], timeout_seconds=30, jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that the provider was initialized correctly @@ -31,13 +41,14 @@ class TestGitHubProvider: ) # URLs get normalized with trailing slash assert provider._redirect_path == "/custom/callback" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = GitHubProvider( client_id="test_client", client_secret="test_secret", base_url="https://example.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults @@ -49,7 +60,7 @@ class TestGitHubProvider: class TestGitHubTokenVerifier: """Test GitHubTokenVerifier.""" - def test_init_with_custom_scopes(self): + def test_init_with_custom_scopes(self, memory_storage: MemoryStore): """Test initialization with custom required scopes.""" verifier = GitHubTokenVerifier( required_scopes=["user", "repo"], @@ -59,7 +70,7 @@ class TestGitHubTokenVerifier: assert verifier.required_scopes == ["user", "repo"] assert verifier.timeout_seconds == 30 - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test initialization with defaults.""" verifier = GitHubTokenVerifier() diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index d578c7056..0f6bd6c89 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -1,12 +1,21 @@ """Tests for Google OAuth provider.""" +import pytest +from key_value.aio.stores.memory import MemoryStore + from fastmcp.server.auth.providers.google import GoogleProvider +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestGoogleProvider: """Test Google OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test GoogleProvider initialization with explicit parameters.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", @@ -14,32 +23,35 @@ class TestGoogleProvider: base_url="https://myserver.com", required_scopes=["openid", "email", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "123456789.apps.googleusercontent.com" assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" # Google provider has ["openid"] as default but we can't easily verify without accessing internals - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that endpoints use Google's OAuth2 endpoints @@ -53,7 +65,7 @@ class TestGoogleProvider: # Google provider doesn't currently set a revocation endpoint assert provider._upstream_revocation_endpoint is None - def test_google_specific_scopes(self): + def test_google_specific_scopes(self, memory_storage: MemoryStore): """Test handling of Google-specific scope formats.""" # Just test that the provider accepts Google-specific scopes without error provider = GoogleProvider( @@ -66,18 +78,20 @@ class TestGoogleProvider: "https://www.googleapis.com/auth/userinfo.profile", ], jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Provider should initialize successfully with these scopes assert provider is not None - def test_extra_authorize_params_defaults(self): + def test_extra_authorize_params_defaults(self, memory_storage: MemoryStore): """Test that Google-specific defaults are set for refresh token support.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-test123", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Should have Google-specific defaults for refresh token support @@ -86,7 +100,9 @@ class TestGoogleProvider: "prompt": "consent", } - def test_extra_authorize_params_override_defaults(self): + def test_extra_authorize_params_override_defaults( + self, memory_storage: MemoryStore + ): """Test that user can override default extra authorize params.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", @@ -94,6 +110,7 @@ class TestGoogleProvider: base_url="https://myserver.com", jwt_signing_key="test-secret", extra_authorize_params={"prompt": "select_account"}, + client_storage=memory_storage, ) # User override should replace the default @@ -101,7 +118,7 @@ class TestGoogleProvider: # But other defaults should remain assert provider._extra_authorize_params["access_type"] == "offline" - def test_extra_authorize_params_add_new_params(self): + def test_extra_authorize_params_add_new_params(self, memory_storage: MemoryStore): """Test that user can add additional authorize params.""" provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", @@ -109,6 +126,7 @@ class TestGoogleProvider: base_url="https://myserver.com", jwt_signing_key="test-secret", extra_authorize_params={"login_hint": "user@example.com"}, + client_storage=memory_storage, ) # New param should be added diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 69ee18012..594f2e5b5 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse import httpx import pytest +from key_value.aio.stores.memory import MemoryStore from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport @@ -11,10 +12,16 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async +@pytest.fixture +def memory_storage() -> MemoryStore: + """Provide a MemoryStore for tests to avoid SQLite initialization on Windows.""" + return MemoryStore() + + class TestWorkOSProvider: """Test WorkOS OAuth provider functionality.""" - def test_init_with_explicit_params(self): + def test_init_with_explicit_params(self, memory_storage: MemoryStore): """Test WorkOSProvider initialization with explicit parameters.""" provider = WorkOSProvider( client_id="client_test123", @@ -23,13 +30,14 @@ class TestWorkOSProvider: base_url="https://myserver.com", required_scopes=["openid", "profile"], jwt_signing_key="test-secret", + client_storage=memory_storage, ) assert provider._upstream_client_id == "client_test123" assert provider._upstream_client_secret.get_secret_value() == "secret_test456" assert str(provider.base_url) == "https://myserver.com/" - def test_authkit_domain_https_prefix_handling(self): + def test_authkit_domain_https_prefix_handling(self, memory_storage: MemoryStore): """Test that authkit_domain handles missing https:// prefix.""" # Without https:// - should add it provider1 = WorkOSProvider( @@ -38,6 +46,7 @@ class TestWorkOSProvider: authkit_domain="test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider1._upstream_authorization_endpoint) assert parsed.scheme == "https" @@ -51,6 +60,7 @@ class TestWorkOSProvider: authkit_domain="https://test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider2._upstream_authorization_endpoint) assert parsed.scheme == "https" @@ -64,13 +74,14 @@ class TestWorkOSProvider: authkit_domain="http://localhost:8080", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) parsed = urlparse(provider3._upstream_authorization_endpoint) assert parsed.scheme == "http" assert parsed.netloc == "localhost:8080" assert parsed.path == "/oauth2/authorize" - def test_init_defaults(self): + def test_init_defaults(self, memory_storage: MemoryStore): """Test that default values are applied correctly.""" provider = WorkOSProvider( client_id="test_client", @@ -78,13 +89,14 @@ class TestWorkOSProvider: authkit_domain="https://test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check defaults assert provider._redirect_path == "/auth/callback" # WorkOS provider has no default scopes but we can't easily verify without accessing internals - def test_oauth_endpoints_configured_correctly(self): + def test_oauth_endpoints_configured_correctly(self, memory_storage: MemoryStore): """Test that OAuth endpoints are configured correctly.""" provider = WorkOSProvider( client_id="test_client", @@ -92,6 +104,7 @@ class TestWorkOSProvider: authkit_domain="https://test.authkit.app", base_url="https://myserver.com", jwt_signing_key="test-secret", + client_storage=memory_storage, ) # Check that endpoints use the authkit domain @@ -135,7 +148,9 @@ def client_with_headless_oauth(mcp_server_url: str) -> Client: class TestAuthKitProvider: - async def test_unauthorized_access(self, mcp_server_url: str): + async def test_unauthorized_access( + self, memory_storage: MemoryStore, mcp_server_url: str + ): with pytest.raises(httpx.HTTPStatusError) as exc_info: async with Client(mcp_server_url) as client: tools = await client.list_tools() # noqa: F841 diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 7d4119c8e..106babecc 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -1045,3 +1045,115 @@ class TestVendoredDI: db_dep = deps["db"] assert isinstance(db_dep, _Depends) assert db_dep.dependency is get_db + + +class TestAuthDependencies: + """Tests for authentication dependencies (CurrentAccessToken, TokenClaim).""" + + def test_current_access_token_is_importable(self): + """Test that CurrentAccessToken can be imported.""" + from fastmcp.server.dependencies import CurrentAccessToken + + assert CurrentAccessToken is not None + + def test_token_claim_is_importable(self): + """Test that TokenClaim can be imported.""" + from fastmcp.server.dependencies import TokenClaim + + assert TokenClaim is not None + + def test_current_access_token_is_dependency(self): + """Test that CurrentAccessToken is a Dependency instance.""" + # Import the Dependency class the same way the code does + # (docket if available, vendored otherwise) + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.dependencies import _CurrentAccessToken + + dep = _CurrentAccessToken() + assert isinstance(dep, Dependency) + + def test_token_claim_creates_dependency(self): + """Test that TokenClaim creates a Dependency instance.""" + # Import the Dependency class the same way the code does + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.dependencies import TokenClaim, _TokenClaim + + dep = TokenClaim("oid") + assert isinstance(dep, _TokenClaim) + assert isinstance(dep, Dependency) + assert dep.claim_name == "oid" + + async def test_current_access_token_raises_without_token(self): + """Test that CurrentAccessToken raises when no token is available.""" + from fastmcp.server.dependencies import _CurrentAccessToken + + dep = _CurrentAccessToken() + with pytest.raises(RuntimeError, match="No access token found"): + await dep.__aenter__() + + async def test_token_claim_raises_without_token(self): + """Test that TokenClaim raises when no token is available.""" + from fastmcp.server.dependencies import _TokenClaim + + dep = _TokenClaim("oid") + with pytest.raises(RuntimeError, match="No access token available"): + await dep.__aenter__() + + async def test_current_access_token_excluded_from_tool_schema(self, mcp: FastMCP): + """Test that CurrentAccessToken dependency is excluded from tool schema.""" + import mcp.types as mcp_types + + from fastmcp.server.auth import AccessToken + from fastmcp.server.dependencies import CurrentAccessToken + + @mcp.tool() + async def tool_with_token( + name: str, + token: AccessToken = CurrentAccessToken(), + ) -> str: + return name + + result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) + tool = next(t for t in result.tools if t.name == "tool_with_token") + + assert "name" in tool.inputSchema["properties"] + assert "token" not in tool.inputSchema["properties"] + + async def test_token_claim_excluded_from_tool_schema(self, mcp: FastMCP): + """Test that TokenClaim dependency is excluded from tool schema.""" + import mcp.types as mcp_types + + from fastmcp.server.dependencies import TokenClaim + + @mcp.tool() + async def tool_with_claim( + name: str, + user_id: str = TokenClaim("oid"), + ) -> str: + return name + + result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) + tool = next(t for t in result.tools if t.name == "tool_with_claim") + + assert "name" in tool.inputSchema["properties"] + assert "user_id" not in tool.inputSchema["properties"] + + def test_current_access_token_exported_from_all(self): + """Test that CurrentAccessToken is exported from __all__.""" + from fastmcp.server import dependencies + + assert "CurrentAccessToken" in dependencies.__all__ + + def test_token_claim_exported_from_all(self): + """Test that TokenClaim is exported from __all__.""" + from fastmcp.server import dependencies + + assert "TokenClaim" in dependencies.__all__ diff --git a/tests/test_apps.py b/tests/test_apps.py index 348eab36b..5d41897a2 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -1,6 +1,6 @@ """Tests for MCP Apps Phase 1 β€” SDK compatibility. -Covers UI metadata models, tool/resource registration with ``ui=``, +Covers app config models, tool/resource registration with ``app=``, extension negotiation, and the ``Context.client_supports_extension`` method. """ @@ -8,15 +8,16 @@ from __future__ import annotations from typing import Any +import pytest + from fastmcp import Client, FastMCP from fastmcp.server.apps import ( UI_EXTENSION_ID, UI_MIME_TYPE, + AppConfig, ResourceCSP, ResourcePermissions, - ResourceUI, - ToolUI, - ui_to_meta_dict, + app_config_to_meta_dict, ) from fastmcp.server.context import Context @@ -25,19 +26,19 @@ from fastmcp.server.context import Context # --------------------------------------------------------------------------- -class TestToolUI: +class TestAppConfig: def test_serializes_with_aliases(self): - ui = ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"]) - d = ui.model_dump(by_alias=True, exclude_none=True) + cfg = AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"]) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {"resourceUri": "ui://my-app/view.html", "visibility": ["app"]} def test_excludes_none_fields(self): - ui = ToolUI(resource_uri="ui://foo") - d = ui.model_dump(by_alias=True, exclude_none=True) + cfg = AppConfig(resource_uri="ui://foo") + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {"resourceUri": "ui://foo"} def test_all_fields(self): - ui = ToolUI( + cfg = AppConfig( resource_uri="ui://app", visibility=["app", "model"], csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), @@ -45,7 +46,7 @@ class TestToolUI: domain="example.com", prefers_border=True, ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "resourceUri": "ui://app", "visibility": ["app", "model"], @@ -56,8 +57,8 @@ class TestToolUI: } def test_populate_by_name(self): - ui = ToolUI(resource_uri="ui://app") - assert ui.resource_uri == "ui://app" + cfg = AppConfig(resource_uri="ui://app") + assert cfg.resource_uri == "ui://app" class TestResourceCSP: @@ -152,61 +153,63 @@ class TestResourcePermissions: assert d == {} -class TestResourceUI: +class TestAppConfigForResources: + """AppConfig without resource_uri/visibility β€” for use on resources.""" + def test_serializes_with_aliases(self): - ui = ResourceUI( + cfg = AppConfig( prefers_border=True, csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "prefersBorder": True, "csp": {"resourceDomains": ["https://cdn.example.com"]}, } def test_excludes_none_fields(self): - ui = ResourceUI() - d = ui.model_dump(by_alias=True, exclude_none=True) + cfg = AppConfig() + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {} def test_with_permissions(self): - ui = ResourceUI( + cfg = AppConfig( permissions=ResourcePermissions(microphone={}, clipboard_write={}), ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "permissions": {"microphone": {}, "clipboardWrite": {}}, } -class TestUIToMetaDict: - def test_from_tool_ui(self): - ui = ToolUI(resource_uri="ui://app", visibility=["app"]) - result = ui_to_meta_dict(ui) +class TestAppConfigToMetaDict: + def test_from_app_config_with_tool_fields(self): + cfg = AppConfig(resource_uri="ui://app", visibility=["app"]) + result = app_config_to_meta_dict(cfg) assert result["resourceUri"] == "ui://app" assert result["visibility"] == ["app"] - def test_from_resource_ui(self): - ui = ResourceUI(prefers_border=False) - result = ui_to_meta_dict(ui) + def test_from_app_config_resource_fields_only(self): + cfg = AppConfig(prefers_border=False) + result = app_config_to_meta_dict(cfg) assert result == {"prefersBorder": False} def test_passthrough_for_dict(self): raw: dict[str, Any] = {"resourceUri": "ui://app", "custom": "value"} - result = ui_to_meta_dict(raw) + result = app_config_to_meta_dict(raw) assert result is raw # --------------------------------------------------------------------------- -# Tool registration with ui= +# Tool registration with app= # --------------------------------------------------------------------------- -class TestToolRegistrationWithUI: - async def test_tool_ui_model(self): +class TestToolRegistrationWithApp: + async def test_app_config_model(self): server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) + @server.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) def my_tool() -> str: return "hello" @@ -215,10 +218,10 @@ class TestToolRegistrationWithUI: assert tools[0].meta is not None assert tools[0].meta["ui"]["resourceUri"] == "ui://my-app/view.html" - async def test_tool_ui_dict(self): + async def test_app_dict(self): server = FastMCP("test") - @server.tool(ui={"resourceUri": "ui://foo", "visibility": ["app"]}) + @server.tool(app={"resourceUri": "ui://foo", "visibility": ["app"]}) def my_tool() -> str: return "hello" @@ -227,10 +230,10 @@ class TestToolRegistrationWithUI: assert tools[0].meta["ui"]["resourceUri"] == "ui://foo" assert tools[0].meta["ui"]["visibility"] == ["app"] - async def test_ui_merges_with_existing_meta(self): + async def test_app_merges_with_existing_meta(self): server = FastMCP("test") - @server.tool(meta={"custom": "data"}, ui=ToolUI(resource_uri="ui://app")) + @server.tool(meta={"custom": "data"}, app=AppConfig(resource_uri="ui://app")) def my_tool() -> str: return "hello" @@ -240,10 +243,10 @@ class TestToolRegistrationWithUI: assert meta["custom"] == "data" assert meta["ui"]["resourceUri"] == "ui://app" - async def test_ui_in_mcp_wire_format(self): + async def test_app_in_mcp_wire_format(self): server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app", visibility=["app"])) + @server.tool(app=AppConfig(resource_uri="ui://app", visibility=["app"])) def my_tool() -> str: return "hello" @@ -253,7 +256,7 @@ class TestToolRegistrationWithUI: assert mcp_tool.meta["ui"]["resourceUri"] == "ui://app" assert mcp_tool.meta["ui"]["visibility"] == ["app"] - async def test_tool_without_ui_has_no_ui_meta(self): + async def test_tool_without_app_has_no_ui_meta(self): server = FastMCP("test") @server.tool @@ -266,11 +269,11 @@ class TestToolRegistrationWithUI: # --------------------------------------------------------------------------- -# Resource registration with ui:// and ui= +# Resource registration with ui:// and app= # --------------------------------------------------------------------------- -class TestResourceWithUI: +class TestResourceWithApp: async def test_ui_scheme_defaults_mime_type(self): server = FastMCP("test") @@ -292,12 +295,12 @@ class TestResourceWithUI: resources = list(await server.list_resources()) assert resources[0].mime_type == "text/html" - async def test_resource_ui_metadata(self): + async def test_resource_app_metadata(self): server = FastMCP("test") @server.resource( "ui://my-app/view.html", - ui=ResourceUI(prefers_border=True), + app=AppConfig(prefers_border=True), ) def app_html() -> str: return "hello" @@ -317,7 +320,7 @@ class TestResourceWithUI: assert resources[0].mime_type != UI_MIME_TYPE async def test_standalone_decorator_ui_scheme_defaults_mime_type(self): - """Test that the standalone @resource decorator also applies ui:// MIME default.""" + """The standalone @resource decorator also applies ui:// MIME default.""" from fastmcp.resources import resource @resource("ui://standalone-app/view.html") @@ -332,7 +335,7 @@ class TestResourceWithUI: assert resources[0].mime_type == UI_MIME_TYPE async def test_resource_template_ui_scheme_defaults_mime_type(self): - """Test that resource templates also apply ui:// MIME default.""" + """Resource templates also apply ui:// MIME default.""" server = FastMCP("test") @server.resource("ui://template-app/{view}") @@ -343,6 +346,30 @@ class TestResourceWithUI: assert len(templates) == 1 assert templates[0].mime_type == UI_MIME_TYPE + async def test_resource_rejects_resource_uri(self): + """AppConfig with resource_uri raises ValueError on resources.""" + server = FastMCP("test") + with pytest.raises(ValueError, match="resource_uri cannot be set on resources"): + + @server.resource( + "ui://my-app/view.html", + app=AppConfig(resource_uri="ui://other"), + ) + def app_html() -> str: + return "hello" + + async def test_resource_rejects_visibility(self): + """AppConfig with visibility raises ValueError on resources.""" + server = FastMCP("test") + with pytest.raises(ValueError, match="visibility cannot be set on resources"): + + @server.resource( + "ui://my-app/view.html", + app=AppConfig(visibility=["app"]), + ) + def app_html() -> str: + return "hello" + # --------------------------------------------------------------------------- # Extension advertisement @@ -382,11 +409,13 @@ class TestContextClientSupportsExtension: class TestIntegration: - async def test_tool_with_ui_roundtrip(self): - """UI metadata flows through to clients β€” no server-side stripping.""" + async def test_tool_with_app_roundtrip(self): + """App metadata flows through to clients β€” no server-side stripping.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app/view.html", visibility=["app"])) + @server.tool( + app=AppConfig(resource_uri="ui://app/view.html", visibility=["app"]) + ) async def my_tool() -> dict[str, str]: return {"result": "ok"} @@ -425,11 +454,11 @@ class TestIntegration: assert len(result.contents) == 1 assert result.contents[0].mimeType == UI_MIME_TYPE - async def test_ui_tool_callable(self): - """A tool registered with ui= is still callable normally.""" + async def test_app_tool_callable(self): + """A tool registered with app= is still callable normally.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app")) + @server.tool(app=AppConfig(resource_uri="ui://app")) async def greet(name: str) -> str: return f"Hello, {name}!" @@ -438,19 +467,17 @@ class TestIntegration: assert any("Hello, Alice!" in str(c) for c in result.content) async def test_extension_and_tool_together(self): - """Server advertises extension AND tool has UI meta (stored on FastMCP Tool).""" + """Server advertises extension AND tool has app meta.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://dashboard", visibility=["app"])) + @server.tool(app=AppConfig(resource_uri="ui://dashboard", visibility=["app"])) def dashboard() -> str: return "data" - # Verify the stored FastMCP Tool still has full metadata tools = list(await server.list_tools()) assert tools[0].meta is not None assert tools[0].meta["ui"]["resourceUri"] == "ui://dashboard" - # Verify the server advertises the extension async with Client(server) as client: extras = client.initialize_result.capabilities.model_extra or {} assert UI_EXTENSION_ID in extras.get("extensions", {}) @@ -461,7 +488,7 @@ class TestIntegration: @server.resource( "ui://secure-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP( resource_domains=["https://unpkg.com"], connect_domains=["https://api.example.com"], @@ -473,7 +500,7 @@ class TestIntegration: return "secure" @server.tool( - ui=ToolUI( + app=AppConfig( resource_uri="ui://secure-app/view.html", csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), permissions=ResourcePermissions(camera={}), @@ -509,7 +536,7 @@ class TestIntegration: @server.resource( "ui://csp-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP(resource_domains=["https://unpkg.com"]), ), ) diff --git a/uv.lock b/uv.lock index c158b7fb6..3f329e1f3 100644 --- a/uv.lock +++ b/uv.lock @@ -97,6 +97,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, ] +[[package]] +name = "azure-core" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -707,6 +736,9 @@ dependencies = [ anthropic = [ { name = "anthropic" }, ] +azure = [ + { name = "azure-identity" }, +] openai = [ { name = "openai" }, ] @@ -718,7 +750,7 @@ tasks = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["anthropic", "openai", "tasks"] }, + { name = "fastmcp", extra = ["anthropic", "azure", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -748,6 +780,7 @@ dev = [ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" }, { name = "authlib", specifier = ">=1.6.5" }, + { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" }, { name = "cyclopts", specifier = ">=4.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1,<1.0" }, @@ -770,13 +803,13 @@ requires-dist = [ { name = "watchfiles", specifier = ">=1.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["anthropic", "openai", "tasks"] +provides-extras = ["anthropic", "azure", "openai", "tasks"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", extras = ["anthropic", "openai", "tasks"] }, + { name = "fastmcp", extras = ["anthropic", "azure", "openai", "tasks"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "loq", specifier = ">=0.1.0a3" }, @@ -1413,6 +1446,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "msal" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "openai" version = "2.16.0"