diff --git a/docs/docs.json b/docs/docs.json index 1054a0289..9687185dc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -376,10 +376,31 @@ "group": "fastmcp.utilities", "pages": [ "python-sdk/fastmcp-utilities-__init__", + "python-sdk/fastmcp-utilities-auth", "python-sdk/fastmcp-utilities-cli", "python-sdk/fastmcp-utilities-components", "python-sdk/fastmcp-utilities-exceptions", - "python-sdk/fastmcp-utilities-fastmcp_config", + { + "group": "fastmcp_config", + "pages": [ + "python-sdk/fastmcp-utilities-fastmcp_config-__init__", + { + "group": "v1", + "pages": [ + "python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__", + "python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config", + { + "group": "sources", + "pages": [ + "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__", + "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base", + "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem" + ] + } + ] + } + ] + }, "python-sdk/fastmcp-utilities-http", "python-sdk/fastmcp-utilities-inspect", "python-sdk/fastmcp-utilities-json_schema", diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index ad8fc31a9..890e648e3 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -212,7 +212,7 @@ Redirect path configured in your Azure App registration -Comma-separated list of required Microsoft Graph scopes +Comma-, space-, or JSON-separated list of required Microsoft Graph scopes diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index b849e1bd4..0d05126f7 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -169,7 +169,7 @@ Redirect path configured in your GitHub OAuth App -Comma-separated list of required GitHub scopes (e.g., `user,repo`) +Comma-, space-, or JSON-separated list of required GitHub scopes (e.g., `user repo` or `["user","repo"]`) diff --git a/docs/integrations/google.mdx b/docs/integrations/google.mdx index 14a79a80c..c3ff3cc7b 100644 --- a/docs/integrations/google.mdx +++ b/docs/integrations/google.mdx @@ -179,7 +179,7 @@ Redirect path configured in your Google OAuth Client -Comma-separated list of required Google scopes (e.g., `openid`) +Comma-, space-, or JSON-separated list of required Google scopes (e.g., `openid,profile` or `["openid","profile"]`) diff --git a/docs/integrations/workos-oauth.mdx b/docs/integrations/workos-oauth.mdx index 34e9198f4..fee2b1c46 100644 --- a/docs/integrations/workos-oauth.mdx +++ b/docs/integrations/workos-oauth.mdx @@ -166,7 +166,7 @@ Redirect path configured in your WorkOS OAuth App -List of required OAuth scopes (e.g., `["openid", "profile", "email"]`) +Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid profile email` or `["openid","profile","email"]`) diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx index 70443e616..66aeac978 100644 --- a/docs/patterns/cli.mdx +++ b/docs/patterns/cli.mdx @@ -436,17 +436,45 @@ To use this configuration with your MCP client, you'll typically need to add it -Generate a detailed JSON report about a FastMCP server, including information about its tools, prompts, resources, and capabilities. +Inspect a FastMCP server to view summary information or generate a detailed JSON report. ```bash +# Show text summary fastmcp inspect server.py + +# Output FastMCP JSON to stdout +fastmcp inspect server.py --format fastmcp + +# Save MCP JSON to file (format required with -o) +fastmcp inspect server.py --format mcp -o manifest.json ``` ### Options | Option | Flag | Description | | ------ | ---- | ----------- | -| Output File | `--output`, `-o` | Output file path for the JSON report (default: server-info.json) | +| Format | `--format`, `-f` | Output format: `fastmcp` (FastMCP-specific) or `mcp` (MCP protocol). Required when using `-o` | +| Output File | `--output`, `-o` | Save JSON report to file instead of stdout. Requires `--format` | + +### Output Formats + +#### FastMCP Format (`--format fastmcp`) +The default and most comprehensive format, includes all FastMCP-specific metadata: +- Server name, instructions, and version +- FastMCP version and MCP version +- Tool tags and enabled status +- Output schemas for tools +- Annotations and custom metadata +- Uses snake_case field names +- **Use this for**: Complete server introspection and debugging FastMCP servers + +#### MCP Protocol Format (`--format mcp`) +Shows exactly what MCP clients will see via the protocol: +- Only includes standard MCP protocol fields +- Matches output from `client.list_tools()`, `client.list_prompts()`, etc. +- Uses camelCase field names (e.g., `inputSchema`) +- Excludes FastMCP-specific fields like tags and enabled status +- **Use this for**: Debugging client visibility and ensuring MCP compatibility ### Entrypoints @@ -461,17 +489,46 @@ The `inspect` command supports local FastMCP server files and configuration: The `inspect` command **only supports local files and fastmcp.json** - no URLs, remote servers, or standard MCP configuration files. -**Examples** +### Examples ```bash -# Auto-detect server entrypoint +# Show text summary (no JSON output) fastmcp inspect server.py +# Output: +# Server: MyServer +# Instructions: A helpful MCP server +# Version: 1.0.0 +# +# Components: +# Tools: 5 +# Prompts: 2 +# Resources: 3 +# Templates: 1 +# +# Environment: +# FastMCP: 2.0.0 +# MCP: 1.0.0 +# +# Use --format [fastmcp|mcp] for complete JSON output + +# Output FastMCP format to stdout +fastmcp inspect server.py --format fastmcp # Specify server entrypoint fastmcp inspect server.py:my_server -# Custom output location -fastmcp inspect server.py --output analysis.json +# Output MCP protocol format to stdout +fastmcp inspect server.py --format mcp + +# Save to file (format required) +fastmcp inspect server.py --format fastmcp -o server-manifest.json + +# Save MCP format with custom server object +fastmcp inspect server.py:my_server --format mcp -o mcp-manifest.json + +# Error: format required with output file +fastmcp inspect server.py -o output.json +# Error: --format is required when using -o/--output ``` ## `fastmcp project prepare` diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json index 81d0ad754..c28a50452 100644 --- a/docs/public/schemas/fastmcp.json/latest.json +++ b/docs/public/schemas/fastmcp.json/latest.json @@ -243,17 +243,25 @@ "editable": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Directory to install in editable mode", + "description": "Directories to install in editable mode", "examples": [ - ".", - "../my-package" + [ + ".", + "../my-package" + ], + [ + "/path/to/package" + ] ], "title": "Editable" } diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json index 81d0ad754..c28a50452 100644 --- a/docs/public/schemas/fastmcp.json/v1.json +++ b/docs/public/schemas/fastmcp.json/v1.json @@ -243,17 +243,25 @@ "editable": { "anyOf": [ { - "type": "string" + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Directory to install in editable mode", + "description": "Directories to install in editable mode", "examples": [ - ".", - "../my-package" + [ + ".", + "../my-package" + ], + [ + "/path/to/package" + ] ], "title": "Editable" } diff --git a/docs/python-sdk/fastmcp-cli-claude.mdx b/docs/python-sdk/fastmcp-cli-claude.mdx index 6841a69dc..82eb6e881 100644 --- a/docs/python-sdk/fastmcp-cli-claude.mdx +++ b/docs/python-sdk/fastmcp-cli-claude.mdx @@ -32,7 +32,7 @@ Add or update a FastMCP server in Claude's configuration. **Args:** - `file_spec`: Path to the server file, optionally with \:object suffix - `server_name`: Name for the server in Claude's config -- `with_editable`: Optional directory to install in editable mode +- `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables. These are merged with any existing variables, with new values taking precedence. diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index f9d027f40..60838c769 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,24 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `version` +### `with_argv` + +```python +with_argv(args: list[str] | None) +``` + + +Temporarily replace sys.argv if args provided. + +This context manager is used at the CLI boundary to inject +server arguments when needed, without mutating sys.argv deep +in the source loading logic. + +Args are provided without the script name, so we preserve sys.argv[0] +and replace the rest. + + +### `version` ```python version() @@ -20,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -33,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -57,28 +74,56 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None ``` -Inspect an MCP server and generate a JSON report. +Inspect an MCP server and display information or generate a JSON report. -This command analyzes an MCP server and generates a comprehensive JSON report -containing information about the server's name, instructions, version, tools, -prompts, resources, templates, and capabilities. +This command analyzes an MCP server. Without flags, it displays a text summary. +Use --format to output complete JSON data. **Examples:** +# Show text summary fastmcp inspect server.py -fastmcp inspect server.py -o report.json -fastmcp inspect server.py:mcp -o analysis.json -fastmcp inspect path/to/server.py:app -o /tmp/server-info.json + +# Output FastMCP format JSON to stdout +fastmcp inspect server.py --format fastmcp + +# Save MCP protocol format to file (format required with -o) +fastmcp inspect server.py --format mcp -o manifest.json + +# Inspect from fastmcp.json configuration fastmcp inspect fastmcp.json fastmcp inspect # auto-detect fastmcp.json **Args:** - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json + +### `prepare` + +```python +prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None +``` + + +Prepare a FastMCP project by creating a persistent uv environment. + +This command creates a persistent uv project with all dependencies installed: +- Creates a pyproject.toml with dependencies from the config +- Installs all Python packages into a .venv +- Prepares the source (git clone, download, etc.) unless --skip-source + +After running this command, you can use: +fastmcp run <config> --project <output-dir> + +This is useful for: +- CI/CD pipelines with separate build and run stages +- Docker images where you prepare during build +- Production deployments where you want fast startup times + diff --git a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx index d85264f05..fd1d888ff 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx @@ -46,7 +46,7 @@ Install FastMCP server in Claude Code. - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Claude Code -- `with_editable`: Optional directory to install in editable mode +- `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables - `python_version`: Optional Python version to use diff --git a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx index fd14e02bd..e31704d32 100644 --- a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx +++ b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx @@ -33,7 +33,7 @@ Install FastMCP server in Claude Desktop. - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Claude's config -- `with_editable`: Optional directory to install in editable mode +- `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables - `python_version`: Optional Python version to use diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx index c8d91ad7c..218575f86 100644 --- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx +++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx @@ -57,7 +57,7 @@ Install FastMCP server to workspace-specific Cursor configuration. - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Cursor - `workspace_path`: Path to the workspace directory -- `with_editable`: Optional directory to install in editable mode +- `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables - `python_version`: Optional Python version to use @@ -81,7 +81,7 @@ Install FastMCP server in Cursor. - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Cursor -- `with_editable`: Optional directory to install in editable mode +- `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables - `python_version`: Optional Python version to use diff --git a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx index fb6f2a326..0d218623e 100644 --- a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx +++ b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx @@ -23,7 +23,7 @@ Generate MCP configuration JSON for manual installation. - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in MCP config -- `with_editable`: Optional directory to install in editable mode +- `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables - `copy`: If True, copy to clipboard instead of printing to stdout diff --git a/docs/python-sdk/fastmcp-cli-install-shared.mdx b/docs/python-sdk/fastmcp-cli-install-shared.mdx index 8e959d21e..337179267 100644 --- a/docs/python-sdk/fastmcp-cli-install-shared.mdx +++ b/docs/python-sdk/fastmcp-cli-install-shared.mdx @@ -23,7 +23,7 @@ Parse environment variable string in format KEY=VALUE. ### `process_common_args` ```python -process_common_args(server_spec: str, server_name: str | None, with_packages: list[str], env_vars: list[str], env_file: Path | None) -> tuple[Path, str | None, str, list[str], dict[str, str] | None] +process_common_args(server_spec: str, server_name: str | None, with_packages: list[str] | None, env_vars: list[str] | None, env_file: Path | None) -> tuple[Path, str | None, str, list[str], dict[str, str] | None] ``` diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index 74bb0b1a1..9f352a1e1 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints. ## Functions -### `is_url` +### `is_url` ```python is_url(path: str) -> bool @@ -20,43 +20,10 @@ is_url(path: str) -> bool Check if a string is a URL. -### `parse_file_path` +### `run_with_uv` ```python -parse_file_path(server_spec: str) -> tuple[Path, str | None] -``` - - -Parse a file path that may include a server object specification. - -**Args:** -- `server_spec`: Path to file, optionally with \:object suffix - -**Returns:** -- Tuple of (file_path, server_object) - - -### `import_server` - -```python -import_server(file: Path, server_or_factory: str | None = None) -> Any -``` - - -Import a MCP server from a file. - -**Args:** -- `file`: Path to the file -- `server_or_factory`: Optional object name in format "module\:object" or just "object" - -**Returns:** -- The server object (or result of calling a factory function) - - -### `run_with_uv` - -```python -run_with_uv(server_spec: str, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, project: Path | None = None, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, editable: str | None = None) -> None +run_with_uv(server_spec: str, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, project: Path | None = None, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, editable: str | list[str] | None = None) -> None ``` @@ -76,7 +43,7 @@ Run a MCP server using uv run subprocess. - `show_banner`: Whether to show the server banner -### `create_client_server` +### `create_client_server` ```python create_client_server(url: str) -> Any @@ -92,7 +59,7 @@ Create a FastMCP server from a client URL. - A FastMCP server instance -### `create_mcp_config_server` +### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] @@ -102,7 +69,7 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] Create a FastMCP server from a MCPConfig. -### `load_fastmcp_config` +### `load_fastmcp_config` ```python load_fastmcp_config(config_path: Path) -> FastMCPConfig @@ -118,28 +85,10 @@ Load a FastMCP configuration from a fastmcp.json file. - FastMCPConfig object -### `import_server_with_args` +### `run_command` ```python -import_server_with_args(file: Path, server_or_factory: str | None = None, server_args: list[str] | None = None) -> Any -``` - - -Import a server with optional command line arguments. - -**Args:** -- `file`: Path to the server file -- `server_or_factory`: Optional server object or factory function name -- `server_args`: Optional command line arguments to inject - -**Returns:** -- The imported server object - - -### `run_command` - -```python -run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False) -> None +run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False) -> None ``` @@ -155,9 +104,10 @@ Run a MCP server or connect to a remote one. - `server_args`: Additional arguments to pass to the server - `show_banner`: Whether to show the server banner - `use_direct_import`: Whether to use direct import instead of subprocess +- `skip_source`: Whether to skip source preparation step -### `run_v1_server` +### `run_v1_server` ```python run_v1_server(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index 84b2d4123..b592bdc8c 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -226,7 +226,7 @@ The flow: 4. When client reconnects with a different port, ProxyDCRClient accepts it -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -240,7 +240,7 @@ This implements the DCR-compliant proxy pattern: 3. Redirect to IdP with our fixed callback URL -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -252,7 +252,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -264,7 +264,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained during the IdP callback exchange. PKCE validation is handled by the MCP framework. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -273,7 +273,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -282,7 +282,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: Exchange refresh token for new access token using authlib. -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -294,7 +294,7 @@ Delegates to the JWT verifier which handles signature validation, expiration checking, and claims validation using the upstream JWKS. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -306,7 +306,7 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 559f1238a..ed66062f9 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,13 +14,13 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Classes -### `AzureProviderSettings` +### `AzureProviderSettings` Settings for Azure OAuth provider. -### `AzureTokenVerifier` +### `AzureTokenVerifier` Token verifier for Azure OAuth tokens. @@ -31,7 +31,7 @@ to get user information and validate the token. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -40,7 +40,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Azure OAuth token by calling Microsoft Graph API. -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index 72273c01a..e6f23ab15 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -29,13 +29,13 @@ Example: ## Classes -### `GitHubProviderSettings` +### `GitHubProviderSettings` Settings for GitHub OAuth provider. -### `GitHubTokenVerifier` +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -46,7 +46,7 @@ by calling GitHub's API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index f7bbd5ee0..4f4d4688b 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -29,13 +29,13 @@ Example: ## Classes -### `GoogleProviderSettings` +### `GoogleProviderSettings` Settings for Google OAuth provider. -### `GoogleTokenVerifier` +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. @@ -46,7 +46,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Google OAuth token by calling Google's tokeninfo API. -### `GoogleProvider` +### `GoogleProvider` Complete Google OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index b8af2f930..1c7224bd0 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -10,19 +10,19 @@ TokenVerifier implementations for FastMCP. ## Classes -### `JWKData` +### `JWKData` JSON Web Key data structure. -### `JWKSData` +### `JWKSData` JSON Web Key Set data structure. -### `RSAKeyPair` +### `RSAKeyPair` RSA key pair for JWT testing. @@ -30,7 +30,7 @@ RSA key pair for JWT testing. **Methods:** -#### `generate` +#### `generate` ```python generate(cls) -> RSAKeyPair @@ -42,7 +42,7 @@ Generate an RSA key pair for testing. - Generated key pair -#### `create_token` +#### `create_token` ```python create_token(self, subject: str = 'fastmcp-user', issuer: str = 'https://fastmcp.example.com', audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, kid: str | None = None) -> str @@ -60,13 +60,13 @@ Generate a test JWT token for testing purposes. - `kid`: Key ID to include in header -### `JWTVerifierSettings` +### `JWTVerifierSettings` Settings for JWT token verification. -### `JWTVerifier` +### `JWTVerifier` JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -88,7 +88,7 @@ Use this when: **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -103,7 +103,7 @@ Validates the provided JWT bearer token. - AccessToken object if valid, None if invalid or expired -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -121,7 +121,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -142,7 +142,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index f391c4307..60781bf24 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -18,13 +18,13 @@ Choose based on your WorkOS setup and authentication requirements. ## Classes -### `WorkOSProviderSettings` +### `WorkOSProviderSettings` Settings for WorkOS OAuth provider. -### `WorkOSTokenVerifier` +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -44,7 +44,7 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. @@ -65,9 +65,9 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `AuthKitProviderSettings` -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-elicitation.mdx b/docs/python-sdk/fastmcp-server-elicitation.mdx index f3878efd7..b836cf49a 100644 --- a/docs/python-sdk/fastmcp-server-elicitation.mdx +++ b/docs/python-sdk/fastmcp-server-elicitation.mdx @@ -7,7 +7,7 @@ sidebarTitle: elicitation ## Functions -### `get_elicitation_schema` +### `get_elicitation_schema` ```python get_elicitation_schema(response_type: type[T]) -> dict[str, Any] @@ -20,7 +20,7 @@ Get the schema for an elicitation response. - `response_type`: The type of the response -### `validate_elicitation_json_schema` +### `validate_elicitation_json_schema` ```python validate_elicitation_json_schema(schema: dict[str, Any]) -> None @@ -45,10 +45,43 @@ This ensures the schema is compatible with MCP elicitation requirements: ## Classes -### `AcceptedElicitation` +### `ElicitationJsonSchema` + + +Custom JSON schema generator for MCP elicitation that always inlines enums. + +MCP elicitation requires inline enum schemas without $ref/$defs references. +This generator ensures enums are always generated inline for compatibility. +Optionally adds enumNames for better UI display when available. + + +**Methods:** + +#### `generate_inner` + +```python +generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue +``` + +Override to prevent ref generation for enums. + + +#### `enum_schema` + +```python +enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue +``` + +Generate inline enum schema with optional enumNames for better UI. + +If enum members have a _display_name_ attribute or custom __str__, +we'll include enumNames for better UI representation. + + +### `AcceptedElicitation` Result when user accepts the elicitation. -### `ScalarElicitationType` +### `ScalarElicitationType` diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index fe16ae521..9ad99ae30 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -62,7 +62,7 @@ y=2)` will call the parent tool with `x=1` and `y=2`. - `RuntimeError`: If called outside a transformed tool context. -### `apply_transformations_to_tools` +### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] @@ -272,7 +272,7 @@ async def custom_output(**kwargs) -> ToolResult: ``` -### `ToolTransformConfig` +### `ToolTransformConfig` Provides a way to transform a tool. @@ -280,7 +280,7 @@ Provides a way to transform a tool. **Methods:** -#### `apply` +#### `apply` ```python apply(self, tool: Tool) -> TransformedTool diff --git a/docs/python-sdk/fastmcp-utilities-auth.mdx b/docs/python-sdk/fastmcp-utilities-auth.mdx new file mode 100644 index 000000000..f30966e96 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-auth.mdx @@ -0,0 +1,25 @@ +--- +title: auth +sidebarTitle: auth +--- + +# `fastmcp.utilities.auth` + + +Authentication utility helpers. + +## Functions + +### `parse_scopes` + +```python +parse_scopes(value: Any) -> list[str] | None +``` + + +Parse scopes from environment variables or settings values. + +Accepts either a JSON array string, a comma- or space-separated string, +a list of strings, or ``None``. Returns a list of scopes or ``None`` if +no value is provided. + diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx similarity index 81% rename from docs/python-sdk/fastmcp-utilities-fastmcp_config.mdx rename to docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx index 1377f6eeb..f804aa19f 100644 --- a/docs/python-sdk/fastmcp-utilities-fastmcp_config.mdx +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx @@ -1,6 +1,6 @@ --- -title: fastmcp_config -sidebarTitle: fastmcp_config +title: __init__ +sidebarTitle: __init__ --- # `fastmcp.utilities.fastmcp_config` diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx new file mode 100644 index 000000000..4195f2dc2 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx @@ -0,0 +1,8 @@ +--- +title: __init__ +sidebarTitle: __init__ +--- + +# `fastmcp.utilities.fastmcp_config.v1` + +*This module is empty or contains only private/internal implementations.* diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx new file mode 100644 index 000000000..d66382bc1 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx @@ -0,0 +1,297 @@ +--- +title: fastmcp_config +sidebarTitle: fastmcp_config +--- + +# `fastmcp.utilities.fastmcp_config.v1.fastmcp_config` + + +FastMCP Configuration File Support. + +This module provides support for fastmcp.json configuration files that allow +users to specify server settings in a declarative format instead of using +command-line arguments. + + +## Functions + +### `generate_schema` + +```python +generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None +``` + + +Generate JSON schema for fastmcp.json files. + +This is used to create the schema file that IDEs can use for +validation and auto-completion. + +**Args:** +- `output_path`: Optional path to write the schema to. If provided, + writes the schema and returns None. If not provided, + returns the schema as a dictionary. + +**Returns:** +- JSON schema as a dictionary if output_path is None, otherwise None + + +## Classes + +### `Environment` + + +Configuration for Python environment setup. + + +**Methods:** + +#### `build_uv_args` + +```python +build_uv_args(self, command: str | list[str] | None = None) -> list[str] +``` + +Build uv run arguments from this environment configuration. + +**Args:** +- `command`: Optional command to append (string or list of args) + +**Returns:** +- List of arguments for uv run command + + +#### `run_with_uv` + +```python +run_with_uv(self, command: list[str]) -> None +``` + +Execute a command using uv run with this environment configuration. + +**Args:** +- `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"]) + + +#### `needs_uv` + +```python +needs_uv(self) -> bool +``` + +Check if this environment config requires uv to set up. + +**Returns:** +- True if any environment settings require uv run + + +#### `prepare` + +```python +prepare(self, output_dir: Path | None = None) -> None +``` + +Prepare the Python environment using uv. + +**Args:** +- `output_dir`: Directory where the persistent uv project will be created. + If None, creates a temporary directory for ephemeral use. + + +### `Deployment` + + +Configuration for server deployment and runtime settings. + + +**Methods:** + +#### `apply_runtime_settings` + +```python +apply_runtime_settings(self, config_path: Path | None = None) -> None +``` + +Apply runtime settings like environment variables and working directory. + +**Args:** +- `config_path`: Path to config file for resolving relative paths + +Environment variables support interpolation with ${VAR_NAME} syntax. +For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" +will substitute the value of the ENVIRONMENT variable at runtime. + + +### `FastMCPConfig` + + +Configuration for a FastMCP server. + +This configuration file allows you to specify all settings needed to run +a FastMCP server in a declarative format. + + +**Methods:** + +#### `validate_source` + +```python +validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource +``` + +Validate and convert source to proper format. + +Supports: +- Dict format: {"path": "server.py", "entrypoint": "app"} +- FileSystemSource instance (passed through) + +No string parsing happens here - that's only at CLI boundaries. +FastMCPConfig works only with properly typed objects. + + +#### `validate_environment` + +```python +validate_environment(cls, v: dict | Environment) -> Environment +``` + +Validate and convert environment to Environment. + +Accepts: +- Environment instance +- dict that can be converted to Environment + + +#### `validate_deployment` + +```python +validate_deployment(cls, v: dict | Deployment) -> Deployment +``` + +Validate and convert deployment to Deployment. + +Accepts: +- Deployment instance +- dict that can be converted to Deployment + + +#### `from_file` + +```python +from_file(cls, file_path: Path) -> FastMCPConfig +``` + +Load configuration from a JSON file. + +**Args:** +- `file_path`: Path to the configuration file + +**Returns:** +- FastMCPConfig instance + +**Raises:** +- `FileNotFoundError`: If the file doesn't exist +- `json.JSONDecodeError`: If the file is not valid JSON +- `pydantic.ValidationError`: If the configuration is invalid + + +#### `from_cli_args` + +```python +from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> FastMCPConfig +``` + +Create a config from CLI arguments. + +This allows us to have a single code path where everything +goes through a config object. + +**Args:** +- `source`: Server source (FileSystemSource instance) +- `transport`: Transport protocol +- `host`: Host for HTTP transport +- `port`: Port for HTTP transport +- `path`: URL path for server +- `log_level`: Logging level +- `python`: Python version +- `dependencies`: Python packages to install +- `requirements`: Path to requirements file +- `project`: Path to project directory +- `editable`: Path to install in editable mode +- `env`: Environment variables +- `cwd`: Working directory +- `args`: Server arguments + +**Returns:** +- FastMCPConfig instance + + +#### `find_config` + +```python +find_config(cls, start_path: Path | None = None) -> Path | None +``` + +Find a fastmcp.json file in the specified directory. + +**Args:** +- `start_path`: Directory to look in (defaults to current directory) + +**Returns:** +- Path to the configuration file, or None if not found + + +#### `prepare` + +```python +prepare(self, skip_source: bool = False, output_dir: Path | None = None) -> None +``` + +Prepare environment and source for execution. + +When output_dir is provided, creates a persistent uv project. +When output_dir is None, does ephemeral caching (for backwards compatibility). + +**Args:** +- `skip_source`: Skip source preparation if True +- `output_dir`: Directory to create the persistent uv project in (optional) + + +#### `prepare_environment` + +```python +prepare_environment(self, output_dir: Path | None = None) -> None +``` + +Prepare the Python environment. + +**Args:** +- `output_dir`: If provided, creates a persistent uv project in this directory. + If None, just populates uv's cache for ephemeral use. + +Delegates to the environment's prepare() method + + +#### `prepare_source` + +```python +prepare_source(self) -> None +``` + +Prepare the source for loading. + +Delegates to the source's prepare() method. + + +#### `run_server` + +```python +run_server(self, **kwargs: Any) -> None +``` + +Load and run the server with this configuration. + +**Args:** +- `**kwargs`: Additional arguments to pass to server.run_async() + These override config settings + diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx new file mode 100644 index 000000000..c7259c5a1 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx @@ -0,0 +1,8 @@ +--- +title: __init__ +sidebarTitle: __init__ +--- + +# `fastmcp.utilities.fastmcp_config.v1.sources` + +*This module is empty or contains only private/internal implementations.* diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx new file mode 100644 index 000000000..15d2876f7 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx @@ -0,0 +1,42 @@ +--- +title: base +sidebarTitle: base +--- + +# `fastmcp.utilities.fastmcp_config.v1.sources.base` + +## Classes + +### `BaseSource` + + +Abstract base class for all source types. + + +**Methods:** + +#### `prepare` + +```python +prepare(self) -> None +``` + +Prepare the source (download, clone, install, etc). + +For sources that need preparation (e.g., git clone, download), +this method performs that preparation. For sources that don't +need preparation (e.g., local files), this is a no-op. + + +#### `load_server` + +```python +load_server(self) -> Any +``` + +Load and return the FastMCP server instance. + +Must be called after prepare() if the source requires preparation. +All information needed to load the server should be available +as attributes on the source instance. + diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx new file mode 100644 index 000000000..68f3bd6fb --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx @@ -0,0 +1,37 @@ +--- +title: filesystem +sidebarTitle: filesystem +--- + +# `fastmcp.utilities.fastmcp_config.v1.sources.filesystem` + +## Classes + +### `FileSystemSource` + + +Source for local Python files. + + +**Methods:** + +#### `parse_path_with_object` + +```python +parse_path_with_object(cls, v: str) -> str +``` + +Parse path:object syntax and extract the object name. + +This validator runs before the model is created, allowing us to +handle the "file.py:object" syntax at the model boundary. + + +#### `load_server` + +```python +load_server(self) -> Any +``` + +Load server from filesystem. + diff --git a/docs/python-sdk/fastmcp-utilities-inspect.mdx b/docs/python-sdk/fastmcp-utilities-inspect.mdx index d05ddcff5..b48e52a5c 100644 --- a/docs/python-sdk/fastmcp-utilities-inspect.mdx +++ b/docs/python-sdk/fastmcp-utilities-inspect.mdx @@ -10,7 +10,7 @@ Utilities for inspecting FastMCP instances. ## Functions -### `inspect_fastmcp_v2` +### `inspect_fastmcp_v2` ```python inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo @@ -26,7 +26,7 @@ Extract information from a FastMCP v2.x instance. - FastMCPInfo dataclass containing the extracted information -### `inspect_fastmcp_v1` +### `inspect_fastmcp_v1` ```python inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo @@ -42,7 +42,7 @@ Extract information from a FastMCP v1.x instance using a Client. - FastMCPInfo dataclass containing the extracted information -### `inspect_fastmcp` +### `inspect_fastmcp` ```python inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo @@ -61,34 +61,83 @@ and uses the appropriate extraction method. - FastMCPInfo dataclass containing the extracted information +### `format_fastmcp_info` + +```python +format_fastmcp_info(info: FastMCPInfo) -> bytes +``` + + +Format FastMCPInfo as FastMCP-specific JSON. + +This includes FastMCP-specific fields like tags, enabled, annotations, etc. + + +### `format_mcp_info` + +```python +format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes +``` + + +Format server info as standard MCP protocol JSON. + +Uses Client to get the standard MCP protocol format with camelCase fields. +Includes version metadata at the top level. + + +### `format_info` + +```python +format_info(mcp: FastMCP[Any] | FastMCP1x, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes +``` + + +Format server information according to the specified format. + +**Args:** +- `mcp`: The FastMCP instance +- `format`: Output format ("fastmcp" or "mcp") +- `info`: Pre-extracted FastMCPInfo (optional, will be extracted if not provided) + +**Returns:** +- JSON bytes in the requested format + + ## Classes -### `ToolInfo` +### `ToolInfo` Information about a tool. -### `PromptInfo` +### `PromptInfo` Information about a prompt. -### `ResourceInfo` +### `ResourceInfo` Information about a resource. -### `TemplateInfo` +### `TemplateInfo` Information about a resource template. -### `FastMCPInfo` +### `FastMCPInfo` Information extracted from a FastMCP instance. + +### `InspectFormat` + + +Output format for inspect command. + diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index fa4d529de..8873a3a7a 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -34,7 +34,7 @@ def update_claude_config( file_spec: str, server_name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, ) -> bool: @@ -43,7 +43,7 @@ def update_claude_config( Args: file_spec: Path to the server file, optionally with :object suffix server_name: Name for the server in Claude's config - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables. These are merged with any existing variables, with new values taking precedence. @@ -101,7 +101,7 @@ def update_claude_config( # Build uv run command using Environment.build_uv_args() env_config = Environment( dependencies=deduplicated_packages, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index aba034e20..34e1a908c 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -13,7 +13,7 @@ from typing import Annotated, Literal import cyclopts import pyperclip -from pydantic import TypeAdapter, ValidationError +from pydantic import ValidationError from rich.console import Console from rich.table import Table @@ -21,7 +21,13 @@ import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP -from fastmcp.utilities.inspect import FastMCPInfo, inspect_fastmcp +from fastmcp.utilities.fastmcp_config import Environment, FastMCPConfig +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.inspect import ( + InspectFormat, + format_info, + inspect_fastmcp, +) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter @@ -126,20 +132,21 @@ async def dev( server_spec: str | None = None, *, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory containing pyproject.toml to install in editable mode", + "--with-editable", + help="Directory containing pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, inspector_version: Annotated[ str | None, cyclopts.Parameter( @@ -188,6 +195,9 @@ async def dev( Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] from pathlib import Path from fastmcp.utilities.fastmcp_config import FastMCPConfig @@ -229,13 +239,9 @@ async def dev( if config.environment.requirements else None ) - # Note: config.environment.editable is a list, but CLI only supports single path - # Take the first editable path if available - with_editable = with_editable or ( - Path(config.environment.editable[0]) - if config.environment.editable and config.environment.editable[0] - else None - ) + # Merge editable paths from config with CLI args + if config.environment.editable and not with_editable: + with_editable = [Path(p) for p in config.environment.editable] # Merge packages from both sources if config.environment.dependencies: @@ -256,7 +262,7 @@ async def dev( "Starting dev server", extra={ "server_spec": server_spec, - "with_editable": str(with_editable) if with_editable else None, + "with_editable": [str(p) for p in with_editable] if with_editable else None, "with_packages": with_packages, "ui_port": ui_port, "server_port": server_port, @@ -298,14 +304,12 @@ async def dev( inspector_cmd += f"@{inspector_version}" # Create Environment object from CLI args - from fastmcp.utilities.fastmcp_config import Environment - env_config = Environment( python=python, dependencies=with_packages if with_packages else None, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec]) @@ -396,13 +400,13 @@ async def run( ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, project: Annotated[ Path | None, cyclopts.Parameter( @@ -450,6 +454,8 @@ async def run( Args: server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ + # Convert None to empty lists for list parameters + with_packages = with_packages or [] # Load configuration if needed from pathlib import Path @@ -616,13 +622,20 @@ async def run( async def inspect( server_spec: str | None = None, *, + format: Annotated[ + InspectFormat | None, + cyclopts.Parameter( + name=["--format", "-f"], + help="Output format: fastmcp (FastMCP-specific) or mcp (MCP protocol). Required when using -o.", + ), + ] = None, output: Annotated[ - Path, + Path | None, cyclopts.Parameter( name=["--output", "-o"], - help="Output file path for the JSON report (default: server-info.json)", + help="Output file path for the JSON report. If not specified, outputs to stdout when format is provided.", ), - ] = Path("server-info.json"), + ] = None, python: Annotated[ str | None, cyclopts.Parameter( @@ -631,13 +644,13 @@ async def inspect( ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, project: Annotated[ Path | None, cyclopts.Parameter( @@ -652,29 +665,39 @@ async def inspect( help="Requirements file to install dependencies from", ), ] = None, + skip_env: Annotated[ + bool, + cyclopts.Parameter( + "--skip-env", + help="Skip environment configuration (for internal use when already in a uv environment)", + negative="", + ), + ] = False, ) -> None: - """Inspect an MCP server and generate a JSON report. + """Inspect an MCP server and display information or generate a JSON report. - This command analyzes an MCP server and generates a comprehensive JSON report - containing information about the server's name, instructions, version, tools, - prompts, resources, templates, and capabilities. + This command analyzes an MCP server. Without flags, it displays a text summary. + Use --format to output complete JSON data. Examples: + # Show text summary fastmcp inspect server.py - fastmcp inspect server.py -o report.json - fastmcp inspect server.py:mcp -o analysis.json - fastmcp inspect path/to/server.py:app -o /tmp/server-info.json + + # Output FastMCP format JSON to stdout + fastmcp inspect server.py --format fastmcp + + # Save MCP protocol format to file (format required with -o) + fastmcp inspect server.py --format mcp -o manifest.json + + # Inspect from fastmcp.json configuration fastmcp inspect fastmcp.json fastmcp inspect # auto-detect fastmcp.json Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ - from pathlib import Path - - from fastmcp.utilities.fastmcp_config import FastMCPConfig - from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource - + # Convert None to empty lists for list parameters + with_packages = with_packages or [] config = None config_path = None @@ -744,14 +767,15 @@ async def inspect( source = FileSystemSource(path=server_spec) config = FastMCPConfig(source=source) - # Check if we need to use uv run - needs_uv = python or with_packages or with_requirements or project - if not needs_uv and config and config.environment: - needs_uv = config.environment.needs_uv() + # Check if we need to use uv run (skip if --skip-env is set) + needs_uv = False + if not skip_env: + needs_uv = python or with_packages or with_requirements or project + if not needs_uv and config and config.environment: + needs_uv = config.environment.needs_uv() if needs_uv: # Build and run uv command - from fastmcp.utilities.fastmcp_config import Environment # Create or update environment config env_config = Environment( @@ -765,9 +789,14 @@ async def inspect( "fastmcp", "inspect", server_spec, - "--output", - str(output), + "--skip-env", # Prevent infinite loop when calling through uv ] + + # Add format and output flags if specified + if format: + inspect_command.extend(["--format", format.value]) + if output: + inspect_command.extend(["--output", str(output)]) env_config.run_with_uv(inspect_command) return # run_with_uv exits the process @@ -775,7 +804,8 @@ async def inspect( "Inspecting server", extra={ "server_spec": server_spec, - "output": str(output), + "format": format, + "output": str(output) if output else None, }, ) @@ -783,29 +813,76 @@ async def inspect( # Load the server using the config server = await config.source.load_server() - # Get server information - using native async support + # Get basic server information info = await inspect_fastmcp(server) - info_json = TypeAdapter(FastMCPInfo).dump_json(info, indent=2) + # Check for invalid combination + if output and not format: + console.print( + "[bold red]Error:[/bold red] --format is required when using -o/--output" + ) + console.print( + "[dim]Use --format fastmcp or --format mcp to specify the output format[/dim]" + ) + sys.exit(1) - # Ensure output directory exists - output.parent.mkdir(parents=True, exist_ok=True) + # If no format specified, show text summary + if format is None: + # Display text summary + console.print() - # Write JSON report (always pretty-printed) - with output.open("w", encoding="utf-8") as f: - f.write(info_json.decode("utf-8")) + # Server section + console.print("[bold]Server[/bold]") + console.print(f" Name: {info.name}") + if info.version: + console.print(f" Version: {info.version}") + console.print(f" Generation: {info.server_generation}") + if info.instructions: + console.print(f" Instructions: {info.instructions}") + console.print() - logger.info(f"Server inspection complete. Report saved to {output}") + # Components section + console.print("[bold]Components[/bold]") + console.print(f" Tools: {len(info.tools)}") + console.print(f" Prompts: {len(info.prompts)}") + console.print(f" Resources: {len(info.resources)}") + console.print(f" Templates: {len(info.templates)}") + console.print() - # Print summary to console - console.print( - f"[bold green]✓[/bold green] Inspected server: [bold]{info.name}[/bold]" - ) - console.print(f" Tools: {len(info.tools)}") - console.print(f" Prompts: {len(info.prompts)}") - console.print(f" Resources: {len(info.resources)}") - console.print(f" Templates: {len(info.templates)}") - console.print(f" Report saved to: [cyan]{output}[/cyan]") + # Environment section + console.print("[bold]Environment[/bold]") + console.print(f" FastMCP: {info.fastmcp_version}") + console.print(f" MCP: {info.mcp_version}") + console.print() + + console.print( + "[dim]Use --format \\[fastmcp|mcp] for complete JSON output[/dim]" + ) + return + + # Generate formatted JSON output + formatted_json = await format_info(server, format, info) + + # Output to file or stdout + if output: + # Ensure output directory exists + output.parent.mkdir(parents=True, exist_ok=True) + + # Write JSON report + with output.open("wb") as f: + f.write(formatted_json) + + logger.info(f"Server inspection complete. Report saved to {output}") + + # Print confirmation to console + console.print( + f"[bold green]✓[/bold green] Server inspection saved to: [cyan]{output}[/cyan]" + ) + console.print(f" Server: [bold]{info.name}[/bold]") + console.print(f" Format: {format.value}") + else: + # Output JSON to stdout + console.print(formatted_json.decode("utf-8")) except Exception as e: logger.error( diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index 472a34c54..aaa07503c 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -75,7 +75,7 @@ def install_claude_code( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -88,7 +88,7 @@ def install_claude_code( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Claude Code - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -121,7 +121,7 @@ def install_claude_code( dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -171,28 +171,29 @@ async def claude_code_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -227,6 +228,10 @@ async def claude_code_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 69e543415..28ce20eee 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -40,7 +40,7 @@ def install_claude_desktop( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -53,7 +53,7 @@ def install_claude_desktop( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Claude's config - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -86,7 +86,7 @@ def install_claude_desktop( dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -143,28 +143,29 @@ async def claude_desktop_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -199,6 +200,10 @@ async def claude_desktop_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, with_packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index 472dd610f..bbb94a872 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -71,7 +71,7 @@ def install_cursor_workspace( name: str, workspace_path: Path, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -85,7 +85,7 @@ def install_cursor_workspace( server_object: Optional server object name (for :object suffix) name: Name for the server in Cursor workspace_path: Path to the workspace directory - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -120,7 +120,7 @@ def install_cursor_workspace( dependencies=deduplicated_packages, requirements=str(with_requirements.resolve()) if with_requirements else None, project=str(project.resolve()) if project else None, - editable=[str(with_editable.resolve())] if with_editable else None, + editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -161,7 +161,7 @@ def install_cursor( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, @@ -175,7 +175,7 @@ def install_cursor( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Cursor - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables python_version: Optional Python version to use @@ -200,7 +200,7 @@ def install_cursor( dependencies=deduplicated_packages, requirements=str(with_requirements.resolve()) if with_requirements else None, project=str(project.resolve()) if project else None, - editable=[str(with_editable.resolve())] if with_editable else None, + editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -262,28 +262,29 @@ async def cursor_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -325,6 +326,10 @@ async def cursor_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, with_packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 9315c40ef..42696ae6c 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -22,7 +22,7 @@ def install_mcp_json( server_object: str | None, name: str, *, - with_editable: Path | None = None, + with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, copy: bool = False, @@ -36,7 +36,7 @@ def install_mcp_json( file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in MCP config - with_editable: Optional directory to install in editable mode + with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables copy: If True, copy to clipboard instead of printing to stdout @@ -61,7 +61,7 @@ def install_mcp_json( dependencies=deduplicated_packages, requirements=str(with_requirements) if with_requirements else None, project=str(project) if project else None, - editable=[str(with_editable)] if with_editable else None, + editable=[str(p) for p in with_editable] if with_editable else None, ) args = env_config.build_uv_args() @@ -116,28 +116,29 @@ async def mcp_json_command( ), ] = None, with_editable: Annotated[ - Path | None, + list[Path] | None, cyclopts.Parameter( - name=["--with-editable", "-e"], - help="Directory with pyproject.toml to install in editable mode", + "--with-editable", + help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", + negative="", ), ] = None, with_packages: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--with", - help="Additional packages to install", + help="Additional packages to install (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_vars: Annotated[ - list[str], + list[str] | None, cyclopts.Parameter( "--env", - help="Environment variables in KEY=VALUE format", + help="Environment variables in KEY=VALUE format (can be used multiple times)", negative="", ), - ] = [], + ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( @@ -180,6 +181,10 @@ async def mcp_json_command( Args: server_spec: Python file to install, optionally with :object suffix """ + # Convert None to empty lists for list parameters + with_editable = with_editable or [] + with_packages = with_packages or [] + env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 2f92fbd89..6db83403f 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -29,14 +29,17 @@ def parse_env_var(env_var: str) -> tuple[str, str]: async def process_common_args( server_spec: str, server_name: str | None, - with_packages: list[str], - env_vars: list[str], + with_packages: list[str] | None, + env_vars: list[str] | None, env_file: Path | None, ) -> tuple[Path, str | None, str, list[str], dict[str, str] | None]: """Process common arguments shared by all install commands. Handles both fastmcp.json config files and traditional file.py:object syntax. """ + # Convert None to empty lists for list parameters + with_packages = with_packages or [] + env_vars = env_vars or [] # Create FastMCPConfig from server_spec config = None if server_spec.endswith(".json"): diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 2bb0b924c..a4de21021 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -7,12 +7,13 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. from __future__ import annotations import httpx -from pydantic import SecretStr +from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.registry import register_provider +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -36,6 +37,11 @@ class AzureProviderSettings(BaseSettings): required_scopes: list[str] | None = None timeout_seconds: int | None = None + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + class AzureTokenVerifier(TokenVerifier): """Token verifier for Azure OAuth tokens. diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index cddf83fdf..4f3fcc862 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -22,13 +22,14 @@ Example: from __future__ import annotations import httpx -from pydantic import AnyHttpUrl, SecretStr +from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.registry import register_provider +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -51,6 +52,11 @@ class GitHubProviderSettings(BaseSettings): required_scopes: list[str] | None = None timeout_seconds: int | None = None + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + class GitHubTokenVerifier(TokenVerifier): """Token verifier for GitHub OAuth tokens. diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 48f61bf8a..920034395 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -24,13 +24,14 @@ from __future__ import annotations import time import httpx -from pydantic import AnyHttpUrl, SecretStr +from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.registry import register_provider +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -53,6 +54,11 @@ class GoogleProviderSettings(BaseSettings): required_scopes: list[str] | None = None timeout_seconds: int | None = None + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + class GoogleTokenVerifier(TokenVerifier): """Token verifier for Google OAuth tokens. diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index dee1bb478..19eac1bb2 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -11,12 +11,13 @@ from authlib.jose import JsonWebKey, JsonWebToken from authlib.jose.errors import JoseError from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from pydantic import AnyHttpUrl, SecretStr +from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import TypedDict from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.server.auth.registry import register_provider +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -155,6 +156,11 @@ class JWTVerifierSettings(BaseSettings): required_scopes: list[str] | None = None resource_server_url: AnyHttpUrl | str | None = None + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + @register_provider("JWT") class JWTVerifier(TokenVerifier): diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index a1d77fd08..2fc138141 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -11,7 +11,7 @@ Choose based on your WorkOS setup and authentication requirements. from __future__ import annotations import httpx -from pydantic import AnyHttpUrl, SecretStr +from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.responses import JSONResponse from starlette.routing import Route @@ -20,6 +20,7 @@ from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.server.auth.registry import register_provider +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -43,6 +44,11 @@ class WorkOSProviderSettings(BaseSettings): required_scopes: list[str] | None = None timeout_seconds: int | None = None + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + class WorkOSTokenVerifier(TokenVerifier): """Token verifier for WorkOS OAuth tokens. @@ -255,6 +261,11 @@ class AuthKitProviderSettings(BaseSettings): base_url: AnyHttpUrl required_scopes: list[str] | None = None + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + @register_provider("AUTHKIT") class AuthKitProvider(RemoteAuthProvider): diff --git a/src/fastmcp/utilities/auth.py b/src/fastmcp/utilities/auth.py new file mode 100644 index 000000000..bec370ac7 --- /dev/null +++ b/src/fastmcp/utilities/auth.py @@ -0,0 +1,34 @@ +"""Authentication utility helpers.""" + +from __future__ import annotations + +import json +from typing import Any + + +def parse_scopes(value: Any) -> list[str] | None: + """Parse scopes from environment variables or settings values. + + Accepts either a JSON array string, a comma- or space-separated string, + a list of strings, or ``None``. Returns a list of scopes or ``None`` if + no value is provided. + """ + if value is None or value == "": + return None if value is None else [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + if isinstance(value, str): + value = value.strip() + if not value: + return [] + # Try JSON array first + if value.startswith("["): + try: + data = json.loads(value) + if isinstance(data, list): + return [str(v).strip() for v in data if str(v).strip()] + except Exception: + pass + # Fallback to comma/space separated list + return [s.strip() for s in value.replace(",", " ").split() if s.strip()] + return value diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index df38d679e..f3303a8f4 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -4,8 +4,10 @@ from __future__ import annotations import importlib.metadata from dataclasses import dataclass -from typing import Any, cast +from enum import Enum +from typing import Any, Literal, cast +import pydantic_core from mcp.server.fastmcp import FastMCP as FastMCP1x import fastmcp @@ -21,9 +23,12 @@ class ToolInfo: name: str description: str | None input_schema: dict[str, Any] + output_schema: dict[str, Any] | None = None annotations: dict[str, Any] | None = None tags: list[str] | None = None enabled: bool | None = None + title: str | None = None + meta: dict[str, Any] | None = None @dataclass @@ -36,6 +41,8 @@ class PromptInfo: arguments: list[dict[str, Any]] | None = None tags: list[str] | None = None enabled: bool | None = None + title: str | None = None + meta: dict[str, Any] | None = None @dataclass @@ -47,8 +54,11 @@ class ResourceInfo: name: str | None description: str | None mime_type: str | None = None + annotations: dict[str, Any] | None = None tags: list[str] | None = None enabled: bool | None = None + title: str | None = None + meta: dict[str, Any] | None = None @dataclass @@ -60,8 +70,12 @@ class TemplateInfo: name: str | None description: str | None mime_type: str | None = None + parameters: dict[str, Any] | None = None + annotations: dict[str, Any] | None = None tags: list[str] | None = None enabled: bool | None = None + title: str | None = None + meta: dict[str, Any] | None = None @dataclass @@ -70,9 +84,10 @@ class FastMCPInfo: name: str instructions: str | None - fastmcp_version: str - mcp_version: str - server_version: str | None + version: str | None # The server's own version string (if specified) + fastmcp_version: str # Version of FastMCP generating this manifest + mcp_version: str # Version of MCP protocol library + server_generation: int # Server generation: 1 (mcp package) or 2 (fastmcp) tools: list[ToolInfo] prompts: list[PromptInfo] resources: list[ResourceInfo] @@ -106,9 +121,12 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: name=tool.name or key, description=tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, + output_schema=tool.output_schema, annotations=tool.annotations.model_dump() if tool.annotations else None, tags=list(tool.tags) if tool.tags else None, enabled=tool.enabled, + title=tool.title, + meta=tool.meta, ) ) @@ -125,6 +143,8 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: else None, tags=list(prompt.tags) if prompt.tags else None, enabled=prompt.enabled, + title=prompt.title, + meta=prompt.meta, ) ) @@ -138,8 +158,13 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: name=resource.name, description=resource.description, mime_type=resource.mime_type, + annotations=resource.annotations.model_dump() + if resource.annotations + else None, tags=list(resource.tags) if resource.tags else None, enabled=resource.enabled, + title=resource.title, + meta=resource.meta, ) ) @@ -153,8 +178,14 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: name=template.name, description=template.description, mime_type=template.mime_type, + parameters=template.parameters, + annotations=template.annotations.model_dump() + if template.annotations + else None, tags=list(template.tags) if template.tags else None, enabled=template.enabled, + title=template.title, + meta=template.meta, ) ) @@ -171,9 +202,8 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: instructions=mcp.instructions, fastmcp_version=fastmcp.__version__, mcp_version=importlib.metadata.version("mcp"), - server_version=( - mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version - ), + server_generation=2, # FastMCP v2 + version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version), tools=tool_infos, prompts=prompt_infos, resources=resource_infos, @@ -191,7 +221,6 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: Returns: FastMCPInfo dataclass containing the extracted information """ - # Use a client to interact with the FastMCP1x server async with Client(mcp) as client: # Get components via client calls (these return MCP objects) @@ -208,25 +237,18 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: # Extract detailed tool information from MCP Tool objects tool_infos = [] for mcp_tool in mcp_tools: - # Extract annotations if they exist - annotations = None - if hasattr(mcp_tool, "annotations") and mcp_tool.annotations: - if hasattr(mcp_tool.annotations, "model_dump"): - annotations = mcp_tool.annotations.model_dump() - elif isinstance(mcp_tool.annotations, dict): - annotations = mcp_tool.annotations - else: - annotations = None - tool_infos.append( ToolInfo( - key=mcp_tool.name, # For 1.x, key and name are the same + key=mcp_tool.name, name=mcp_tool.name, description=mcp_tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, - annotations=annotations, - tags=None, # 1.x doesn't have tags - enabled=None, # 1.x doesn't have enabled field + output_schema=None, # v1 doesn't have output_schema + annotations=None, # v1 doesn't have annotations + tags=None, # v1 doesn't have tags + enabled=None, # v1 doesn't have enabled field + title=None, # v1 doesn't have title + meta=None, # v1 doesn't have meta field ) ) @@ -240,12 +262,14 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: prompt_infos.append( PromptInfo( - key=mcp_prompt.name, # For 1.x, key and name are the same + key=mcp_prompt.name, name=mcp_prompt.name, description=mcp_prompt.description, arguments=arguments, - tags=None, # 1.x doesn't have tags - enabled=None, # 1.x doesn't have enabled field + tags=None, # v1 doesn't have tags + enabled=None, # v1 doesn't have enabled field + title=None, # v1 doesn't have title + meta=None, # v1 doesn't have meta field ) ) @@ -254,13 +278,16 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: for mcp_resource in mcp_resources: resource_infos.append( ResourceInfo( - key=str(mcp_resource.uri), # For 1.x, key and uri are the same + key=str(mcp_resource.uri), uri=str(mcp_resource.uri), name=mcp_resource.name, description=mcp_resource.description, mime_type=mcp_resource.mimeType, - tags=None, # 1.x doesn't have tags - enabled=None, # 1.x doesn't have enabled field + annotations=None, # v1 doesn't have annotations + tags=None, # v1 doesn't have tags + enabled=None, # v1 doesn't have enabled field + title=None, # v1 doesn't have title + meta=None, # v1 doesn't have meta field ) ) @@ -269,15 +296,17 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: for mcp_template in mcp_templates: template_infos.append( TemplateInfo( - key=str( - mcp_template.uriTemplate - ), # For 1.x, key and uriTemplate are the same + key=str(mcp_template.uriTemplate), uri_template=str(mcp_template.uriTemplate), name=mcp_template.name, description=mcp_template.description, mime_type=mcp_template.mimeType, - tags=None, # 1.x doesn't have tags - enabled=None, # 1.x doesn't have enabled field + parameters=None, # v1 doesn't expose template parameters + annotations=None, # v1 doesn't have annotations + tags=None, # v1 doesn't have tags + enabled=None, # v1 doesn't have enabled field + title=None, # v1 doesn't have title + meta=None, # v1 doesn't have meta field ) ) @@ -292,13 +321,14 @@ async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: return FastMCPInfo( name=mcp._mcp_server.name, instructions=mcp._mcp_server.instructions, - fastmcp_version=importlib.metadata.version("mcp"), + fastmcp_version=fastmcp.__version__, # Version generating this manifest mcp_version=importlib.metadata.version("mcp"), - server_version=mcp._mcp_server.version, + server_generation=1, # MCP v1 + version=mcp._mcp_server.version, tools=tool_infos, prompts=prompt_infos, resources=resource_infos, - templates=template_infos, # FastMCP1x does have templates + templates=template_infos, capabilities=capabilities, ) @@ -319,3 +349,102 @@ async def inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo: return await inspect_fastmcp_v1(mcp) else: return await inspect_fastmcp_v2(cast(FastMCP[Any], mcp)) + + +class InspectFormat(str, Enum): + """Output format for inspect command.""" + + FASTMCP = "fastmcp" + MCP = "mcp" + + +async def format_fastmcp_info(info: FastMCPInfo) -> bytes: + """Format FastMCPInfo as FastMCP-specific JSON. + + This includes FastMCP-specific fields like tags, enabled, annotations, etc. + """ + # Build the output dict with nested structure + result = { + "server": { + "name": info.name, + "instructions": info.instructions, + "version": info.version, + "generation": info.server_generation, + "capabilities": info.capabilities, + }, + "environment": { + "fastmcp": info.fastmcp_version, + "mcp": info.mcp_version, + }, + "tools": info.tools, + "prompts": info.prompts, + "resources": info.resources, + "templates": info.templates, + } + + return pydantic_core.to_json(result, indent=2) + + +async def format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes: + """Format server info as standard MCP protocol JSON. + + Uses Client to get the standard MCP protocol format with camelCase fields. + Includes version metadata at the top level. + """ + async with Client(mcp) as client: + # Get all the MCP protocol objects + tools_result = await client.list_tools_mcp() + prompts_result = await client.list_prompts_mcp() + resources_result = await client.list_resources_mcp() + templates_result = await client.list_resource_templates_mcp() + + # Get server info from the initialize result + server_info = client.initialize_result.serverInfo + + # Combine into MCP protocol structure with environment metadata + result = { + "environment": { + "fastmcp": fastmcp.__version__, # Version generating this manifest + "mcp": importlib.metadata.version("mcp"), # MCP protocol version + }, + "serverInfo": server_info, + "capabilities": {}, # MCP format doesn't include capabilities at top level + "tools": tools_result.tools, + "prompts": prompts_result.prompts, + "resources": resources_result.resources, + "resourceTemplates": templates_result.resourceTemplates, + } + + return pydantic_core.to_json(result, indent=2) + + +async def format_info( + mcp: FastMCP[Any] | FastMCP1x, + format: InspectFormat | Literal["fastmcp", "mcp"], + info: FastMCPInfo | None = None, +) -> bytes: + """Format server information according to the specified format. + + Args: + mcp: The FastMCP instance + format: Output format ("fastmcp" or "mcp") + info: Pre-extracted FastMCPInfo (optional, will be extracted if not provided) + + Returns: + JSON bytes in the requested format + """ + # Convert string to enum if needed + if isinstance(format, str): + format = InspectFormat(format) + + if format == InspectFormat.MCP: + # MCP format doesn't need FastMCPInfo, it uses Client directly + return await format_mcp_info(mcp) + elif format == InspectFormat.FASTMCP: + # For FastMCP format, we need the FastMCPInfo + # This works for both v1 and v2 servers + if info is None: + info = await inspect_fastmcp(mcp) + return await format_fastmcp_info(info) + else: + raise ValueError(f"Unknown format: {format}") diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index d4995a515..1d0d078a6 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -509,6 +509,42 @@ class TestInspectCommand: # Output is parsed as a Path object assert bound.arguments["output"] == output_file + async def test_inspect_command_text_summary(self, tmp_path, capsys): + """Test inspect command with no format shows text summary.""" + # Create a real server file + server_file = tmp_path / "test_server.py" + server_file.write_text(""" +import fastmcp + +mcp = fastmcp.FastMCP("InspectTestServer", instructions="Test instructions", version="1.0.0") + +@mcp.tool +def test_tool(x: int) -> int: + return x * 2 +""") + + # Parse and execute the command without format or output + command, bound, _ = app.parse_args( + [ + "inspect", + str(server_file), + ] + ) + + await command(**bound.arguments) + + # Check the console output + captured = capsys.readouterr() + # Check for the table format output + assert "InspectTestServer" in captured.out + assert "Test instructions" in captured.out + assert "1.0.0" in captured.out + assert "Tools" in captured.out + assert "1" in captured.out # number of tools + assert "FastMCP" in captured.out + assert "MCP" in captured.out + assert "Use --format [fastmcp|mcp] for complete JSON output" in captured.out + async def test_inspect_command_with_real_server(self, tmp_path): """Test inspect command with a real server file.""" # Create a real server file @@ -529,11 +565,13 @@ def test_prompt(name: str) -> str: output_file = tmp_path / "inspect_output.json" - # Parse and execute the command + # Parse and execute the command with format and output file command, bound, _ = app.parse_args( [ "inspect", str(server_file), + "--format", + "fastmcp", "--output", str(output_file), ] @@ -545,7 +583,10 @@ def test_prompt(name: str) -> str: assert output_file.exists() content = output_file.read_text() - # Basic checks that the inspection worked - assert "InspectTestServer" in content - assert "test_tool" in content - assert "test_prompt" in content + # Basic checks that the fastmcp format worked + import json + + data = json.loads(content) + assert data["server"]["name"] == "InspectTestServer" + assert len(data["tools"]) == 1 + assert len(data["prompts"]) == 1 diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 008aff843..c5531bd8d 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -246,7 +246,7 @@ class TestInstallCursor: file=Path("/path/to/server.py"), server_object="custom_app", name="test-server", - with_editable=editable_path, + with_editable=[editable_path], ) assert result is True @@ -328,7 +328,7 @@ class TestCursorCommand: file=Path("server.py"), server_object=None, name="test-server", - with_editable=None, + with_editable=[], with_packages=[], env_vars={}, python_version=None, diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index b47c0e837..7f76502be 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -31,7 +31,14 @@ class TestAzureProvider: parsed_token = urlparse(provider._upstream_token_endpoint) assert "87654321-4321-4321-4321-210987654321" in parsed_token.path - def test_init_with_env_vars(self): + @pytest.mark.parametrize( + "scopes_env", + [ + "User.Read,Calendar.Read", + '["User.Read", "Calendar.Read"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): """Test AzureProvider initialization from environment variables.""" with patch.dict( os.environ, @@ -40,7 +47,7 @@ class TestAzureProvider: "FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET": "env-secret", "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID": "env-tenant-id", "FASTMCP_SERVER_AUTH_AZURE_BASE_URL": "https://envserver.com", - "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": '["User.Read", "Calendar.Read"]', + "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES": scopes_env, }, ): provider = AzureProvider() @@ -48,6 +55,10 @@ class TestAzureProvider: assert provider._upstream_client_id == "env-client-id" assert provider._upstream_client_secret.get_secret_value() == "env-secret" assert str(provider.base_url) == "https://envserver.com/" + assert provider._token_validator.required_scopes == [ + "User.Read", + "Calendar.Read", + ] # Check tenant is in the endpoints parsed_auth = urlparse(provider._upstream_authorization_endpoint) assert "env-tenant-id" in parsed_auth.path diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index 7c3725f31..198105774 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -83,7 +83,14 @@ class TestGitHubProvider: ) # URLs get normalized with trailing slash assert provider._redirect_path == "/custom/callback" - def test_init_with_env_vars(self): + @pytest.mark.parametrize( + "scopes_env", + [ + "user,repo", + '["user", "repo"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): """Test initialization with environment variables.""" with patch.dict( os.environ, @@ -91,6 +98,7 @@ class TestGitHubProvider: "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id", "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret", "FASTMCP_SERVER_AUTH_GITHUB_BASE_URL": "https://env-example.com", + "FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES": scopes_env, }, ): provider = GitHubProvider() @@ -98,6 +106,7 @@ class TestGitHubProvider: assert provider._upstream_client_id == "env_client_id" assert provider._upstream_client_secret.get_secret_value() == "env_secret" assert str(provider.base_url) == "https://env-example.com/" + assert provider._token_validator.required_scopes == ["user", "repo"] def test_init_explicit_overrides_env(self): """Test that explicit parameters override environment variables.""" diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 3f68eb152..4402e16fd 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -24,7 +24,14 @@ class TestGoogleProvider: assert provider._upstream_client_secret.get_secret_value() == "GOCSPX-test123" assert str(provider.base_url) == "https://myserver.com/" - def test_init_with_env_vars(self): + @pytest.mark.parametrize( + "scopes_env", + [ + "openid,https://www.googleapis.com/auth/userinfo.email", + '["openid", "https://www.googleapis.com/auth/userinfo.email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): """Test GoogleProvider initialization from environment variables.""" with patch.dict( os.environ, @@ -32,7 +39,7 @@ class TestGoogleProvider: "FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID": "env123.apps.googleusercontent.com", "FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET": "GOCSPX-env456", "FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL": "https://envserver.com", - "FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES": '["openid", "https://www.googleapis.com/auth/userinfo.email"]', + "FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES": scopes_env, }, ): provider = GoogleProvider() @@ -42,6 +49,10 @@ class TestGoogleProvider: provider._upstream_client_secret.get_secret_value() == "GOCSPX-env456" ) assert str(provider.base_url) == "https://envserver.com/" + assert provider._token_validator.required_scopes == [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ] def test_init_missing_client_id_raises_error(self): """Test that missing client_id raises ValueError.""" diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 8f2643bef..38e23239d 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -26,7 +26,14 @@ class TestWorkOSProvider: assert provider._upstream_client_secret.get_secret_value() == "secret_test456" assert str(provider.base_url) == "https://myserver.com/" - def test_init_with_env_vars(self): + @pytest.mark.parametrize( + "scopes_env", + [ + "openid,email", + '["openid", "email"]', + ], + ) + def test_init_with_env_vars(self, scopes_env): """Test WorkOSProvider initialization from environment variables.""" with patch.dict( os.environ, @@ -35,7 +42,7 @@ class TestWorkOSProvider: "FASTMCP_SERVER_AUTH_WORKOS_CLIENT_SECRET": "env_secret", "FASTMCP_SERVER_AUTH_WORKOS_AUTHKIT_DOMAIN": "https://env.authkit.app", "FASTMCP_SERVER_AUTH_WORKOS_BASE_URL": "https://envserver.com", - "FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES": '["openid", "email"]', + "FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES": scopes_env, }, ): provider = WorkOSProvider() @@ -43,6 +50,10 @@ class TestWorkOSProvider: assert provider._upstream_client_id == "env_client" assert provider._upstream_client_secret.get_secret_value() == "env_secret" assert str(provider.base_url) == "https://envserver.com/" + assert provider._token_validator.required_scopes == [ + "openid", + "email", + ] def test_init_missing_client_id_raises_error(self): """Test that missing client_id raises ValueError.""" diff --git a/tests/utilities/test_inspect.py b/tests/utilities/test_inspect.py index 0b734c199..e75439ea9 100644 --- a/tests/utilities/test_inspect.py +++ b/tests/utilities/test_inspect.py @@ -8,7 +8,11 @@ import fastmcp from fastmcp import Client, FastMCP from fastmcp.utilities.inspect import ( FastMCPInfo, + InspectFormat, ToolInfo, + format_fastmcp_info, + format_info, + format_mcp_info, inspect_fastmcp, inspect_fastmcp_v1, ) @@ -20,14 +24,22 @@ class TestFastMCPInfo: def test_fastmcp_info_creation(self): """Test that FastMCPInfo can be created with all required fields.""" tool = ToolInfo( - key="tool1", name="tool1", description="Test tool", input_schema={} + key="tool1", + name="tool1", + description="Test tool", + input_schema={}, + output_schema={ + "type": "object", + "properties": {"result": {"type": "string"}}, + }, ) info = FastMCPInfo( name="TestServer", instructions="Test instructions", fastmcp_version="1.0.0", mcp_version="1.0.0", - server_version="1.0.0", + server_generation=2, + version="1.0.0", tools=[tool], prompts=[], resources=[], @@ -39,7 +51,8 @@ class TestFastMCPInfo: assert info.instructions == "Test instructions" assert info.fastmcp_version == "1.0.0" assert info.mcp_version == "1.0.0" - assert info.server_version == "1.0.0" + assert info.server_generation == 2 + assert info.version == "1.0.0" assert len(info.tools) == 1 assert info.tools[0].name == "tool1" assert info.capabilities == {"tools": {"listChanged": True}} @@ -51,7 +64,8 @@ class TestFastMCPInfo: instructions=None, fastmcp_version="1.0.0", mcp_version="1.0.0", - server_version="1.0.0", + server_generation=2, + version="1.0.0", tools=[], prompts=[], resources=[], @@ -75,7 +89,8 @@ class TestGetFastMCPInfo: assert info.instructions is None assert info.fastmcp_version == fastmcp.__version__ assert info.mcp_version == importlib.metadata.version("mcp") - assert info.server_version is None + assert info.server_generation == 2 # v2 server + assert info.version is None assert info.tools == [] assert info.prompts == [] assert info.resources == [] @@ -95,7 +110,7 @@ class TestGetFastMCPInfo: """Test get_fastmcp_info with a server that has a version.""" mcp = FastMCP("VersionServer", version="1.2.3") info = await inspect_fastmcp(mcp) - assert info.server_version == "1.2.3" + assert info.version == "1.2.3" async def test_server_with_tools(self): """Test get_fastmcp_info with a server that has tools.""" @@ -266,9 +281,10 @@ class TestFastMCP1xCompatibility: assert info.name == "Test1x" assert info.instructions is None - assert info.fastmcp_version == importlib.metadata.version("mcp") + assert info.fastmcp_version == fastmcp.__version__ # CLI version assert info.mcp_version == importlib.metadata.version("mcp") - assert info.server_version is None + assert info.server_generation == 1 # v1 server + assert info.version is None assert info.tools == [] assert info.prompts == [] assert info.resources == [] @@ -310,6 +326,7 @@ class TestFastMCP1xCompatibility: resource_uris = [res.uri for res in info.resources] assert "resource://data" in resource_uris assert len(info.templates) == 0 # No templates added in this test + assert info.server_generation == 1 # v1 server async def test_fastmcp1x_with_prompts(self): """Test get_fastmcp_info_v1 with a FastMCP1x server that has prompts.""" @@ -341,6 +358,7 @@ class TestFastMCP1xCompatibility: tool_names = [tool.name for tool in info.tools] assert "test_tool" in tool_names assert len(info.templates) == 0 # No templates added in this test + assert info.server_generation == 1 # v1 server async def test_dispatcher_with_fastmcp2x(self): """Test that the main get_fastmcp_info function correctly dispatches to v2.""" @@ -384,9 +402,191 @@ class TestFastMCP1xCompatibility: assert "tool2x" in tool2x_names # Check server versions - assert info1x.server_version is None - assert info2x.server_version is None + assert info1x.server_generation == 1 # v1 + assert info2x.server_generation == 2 # v2 + assert info1x.version is None + assert info2x.version is None # No templates added in these tests assert len(info1x.templates) == 0 assert len(info2x.templates) == 0 + + +class TestFormatFunctions: + """Tests for the formatting functions.""" + + async def test_format_fastmcp_info(self): + """Test formatting as FastMCP-specific JSON.""" + mcp = FastMCP("TestServer", instructions="Test instructions", version="1.2.3") + + @mcp.tool + def test_tool(x: int) -> dict: + """A test tool.""" + return {"result": x * 2} + + info = await inspect_fastmcp(mcp) + json_bytes = await format_fastmcp_info(info) + + # Verify it's valid JSON + import json + + data = json.loads(json_bytes) + + # Check FastMCP-specific fields are present + assert "server" in data + assert data["server"]["name"] == "TestServer" + assert data["server"]["instructions"] == "Test instructions" + assert data["server"]["generation"] == 2 # v2 server + assert data["server"]["version"] == "1.2.3" + assert "capabilities" in data["server"] + + # Check environment information + assert "environment" in data + assert data["environment"]["fastmcp"] == fastmcp.__version__ + assert data["environment"]["mcp"] == importlib.metadata.version("mcp") + + # Check tools + assert len(data["tools"]) == 1 + assert data["tools"][0]["name"] == "test_tool" + assert data["tools"][0]["enabled"] is True + assert "tags" in data["tools"][0] + + async def test_format_mcp_info(self): + """Test formatting as MCP protocol JSON.""" + mcp = FastMCP("TestServer", instructions="Test instructions", version="2.0.0") + + @mcp.tool + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + @mcp.prompt + def test_prompt(name: str) -> list: + """Test prompt.""" + return [{"role": "user", "content": f"Hello {name}"}] + + json_bytes = await format_mcp_info(mcp) + + # Verify it's valid JSON + import json + + data = json.loads(json_bytes) + + # Check MCP protocol structure with camelCase + assert "serverInfo" in data + assert data["serverInfo"]["name"] == "TestServer" + + # Check server version in MCP format + assert data["serverInfo"]["version"] == "2.0.0" + + # MCP format SHOULD have environment fields + assert "environment" in data + assert data["environment"]["fastmcp"] == fastmcp.__version__ + assert data["environment"]["mcp"] == importlib.metadata.version("mcp") + assert "capabilities" in data + + assert "tools" in data + assert "prompts" in data + assert "resources" in data + assert "resourceTemplates" in data + + # Check tools have MCP format (camelCase fields) + assert len(data["tools"]) == 1 + assert data["tools"][0]["name"] == "add" + assert "inputSchema" in data["tools"][0] + + # FastMCP-specific fields should not be present + assert "tags" not in data["tools"][0] + assert "enabled" not in data["tools"][0] + + async def test_format_info_with_fastmcp_format(self): + """Test format_info with fastmcp format.""" + mcp = FastMCP("TestServer") + + @mcp.tool + def test() -> str: + return "test" + + # Test with string format + json_bytes = await format_info(mcp, "fastmcp") + import json + + data = json.loads(json_bytes) + assert data["server"]["name"] == "TestServer" + assert "tags" in data["tools"][0] # FastMCP-specific field + + # Test with enum format + json_bytes = await format_info(mcp, InspectFormat.FASTMCP) + data = json.loads(json_bytes) + assert data["server"]["name"] == "TestServer" + + async def test_format_info_with_mcp_format(self): + """Test format_info with mcp format.""" + mcp = FastMCP("TestServer") + + @mcp.tool + def test() -> str: + return "test" + + json_bytes = await format_info(mcp, "mcp") + + import json + + data = json.loads(json_bytes) + assert "serverInfo" in data + assert "tools" in data + assert "inputSchema" in data["tools"][0] # MCP uses camelCase + + async def test_format_info_requires_format(self): + """Test that format_info requires a format parameter.""" + mcp = FastMCP("TestServer") + + @mcp.tool + def test() -> str: + return "test" + + # Should work with valid formats + json_bytes = await format_info(mcp, "fastmcp") + assert json_bytes + + json_bytes = await format_info(mcp, "mcp") + assert json_bytes + + # Should fail with invalid format + import pytest + + with pytest.raises(ValueError, match="not a valid InspectFormat"): + await format_info(mcp, "invalid") # type: ignore + + async def test_tool_with_output_schema(self): + """Test that output_schema is properly extracted and included.""" + mcp = FastMCP("TestServer") + + @mcp.tool( + output_schema={ + "type": "object", + "properties": { + "result": {"type": "number"}, + "message": {"type": "string"}, + }, + } + ) + def compute(x: int) -> dict: + """Compute something.""" + return {"result": x * 2, "message": f"Doubled {x}"} + + info = await inspect_fastmcp(mcp) + + # Check output_schema is captured + assert len(info.tools) == 1 + assert info.tools[0].output_schema is not None + assert info.tools[0].output_schema["type"] == "object" + assert "result" in info.tools[0].output_schema["properties"] + + # Verify it's included in FastMCP format + json_bytes = await format_fastmcp_info(info) + import json + + data = json.loads(json_bytes) + # Tools are at the top level, not nested + assert data["tools"][0]["output_schema"]["type"] == "object"