diff --git a/.github/release.yml b/.github/release.yml
index b490a9afcb237754236480d51789c2f5e6cfb943..9d9a71c33f8226e266ae2e9bc269000aae0d6fd7 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -9,7 +9,7 @@ changelog:
- feature
exclude:
labels:
- - breaking change
+ - contrib
- title: Enhancements š§
labels:
@@ -17,22 +17,31 @@ changelog:
exclude:
labels:
- breaking change
+ - contrib
- title: Fixes š
labels:
- bug
exclude:
labels:
- - breaking change
+ - contrib
- title: Breaking Changes š«
labels:
- breaking change
+ exclude:
+ labels:
+ - contrib
- title: Docs š
labels:
- documentation
+ - title: Examples & Contrib š”
+ labels:
+ - example
+ - contrib
+
- title: Dependencies š¦
labels:
- dependencies
diff --git a/CLAUDE.md b/CLAUDE.md
index 1da05926021b342f6e80cb398b575985b5196372..9d26dfeafbddbc6dde71b3b6230af65018646fda 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,4 +32,5 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
## Development Workflow
- You must always run pre-commit if you open a PR, because it is run as part of a required check.
-- When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
\ No newline at end of file
+- When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
+- NEVER modify files in docs/python-sdk/**, as they are auto-generated.
diff --git a/README.md b/README.md
index 866104c17597118fdc207b0e1beab5fa79843c59..d0bd6a3a1d83c8b56b6cc43fbfeb0d79341ee6a0 100644
--- a/README.md
+++ b/README.md
@@ -349,7 +349,7 @@ mcp.run(transport="stdio") # Default, so transport argument is optional
**Streamable HTTP**: Recommended for web deployments.
```python
-mcp.run(transport="streamable-http", host="127.0.0.1", port=8000, path="/mcp")
+mcp.run(transport="http", host="127.0.0.1", port=8000, path="/mcp")
```
**SSE**: For compatibility with existing SSE clients.
diff --git a/docs/.cursor/rules/mintlify.mdc b/docs/.cursor/rules/mintlify.mdc
new file mode 100644
index 0000000000000000000000000000000000000000..503fe1647d273b64208d8e8ca822d8506e051f14
--- /dev/null
+++ b/docs/.cursor/rules/mintlify.mdc
@@ -0,0 +1,364 @@
+---
+description:
+globs: *.mdx
+alwaysApply: false
+---
+# Mintlify technical writing assistant
+
+You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices.
+
+## Core writing principles
+
+### Language and style requirements
+- Use clear, direct language appropriate for technical audiences
+- Write in second person ("you") for instructions and procedures
+- Use active voice over passive voice
+- Employ present tense for current states, future tense for outcomes
+- Maintain consistent terminology throughout all documentation
+- Keep sentences concise while providing necessary context
+- Use parallel structure in lists, headings, and procedures
+
+### Content organization standards
+- Lead with the most important information (inverted pyramid structure)
+- Use progressive disclosure: basic concepts before advanced ones
+- Break complex procedures into numbered steps
+- Include prerequisites and context before instructions
+- Provide expected outcomes for each major step
+- End sections with next steps or related information
+- Use descriptive, keyword-rich headings for navigation and SEO
+
+### User-centered approach
+- Focus on user goals and outcomes rather than system features
+- Anticipate common questions and address them proactively
+- Include troubleshooting for likely failure points
+- Provide multiple pathways when appropriate (beginner vs advanced), but offer an opinionated path for people to follow to avoid overwhelming with options
+
+## Mintlify component reference
+
+### Callout components
+
+#### Note - Additional helpful information
+
+
+Supplementary information that supports the main content without interrupting flow
+
+
+#### Tip - Best practices and pro tips
+
+
+Expert advice, shortcuts, or best practices that enhance user success
+
+
+#### Warning - Important cautions
+
+
+Critical information about potential issues, breaking changes, or destructive actions
+
+
+#### Info - Neutral contextual information
+
+
+Background information, context, or neutral announcements
+
+
+#### Check - Success confirmations
+
+
+Positive confirmations, successful completions, or achievement indicators
+
+
+### Code components
+
+#### Single code block
+
+```javascript config.js
+const apiConfig = {
+baseURL: 'https://api.example.com',
+timeout: 5000,
+headers: {
+ 'Authorization': `Bearer ${process.env.API_TOKEN}`
+}
+};
+```
+
+#### Code group with multiple languages
+
+
+```javascript Node.js
+const response = await fetch('/api/endpoint', {
+ headers: { Authorization: `Bearer ${apiKey}` }
+});
+```
+
+```python Python
+import requests
+response = requests.get('/api/endpoint',
+ headers={'Authorization': f'Bearer {api_key}'})
+```
+
+```curl cURL
+curl -X GET '/api/endpoint' \
+ -H 'Authorization: Bearer YOUR_API_KEY'
+```
+
+
+#### Request/Response examples
+
+
+```bash cURL
+curl -X POST 'https://api.example.com/users' \
+ -H 'Content-Type: application/json' \
+ -d '{"name": "John Doe", "email": "john@example.com"}'
+```
+
+
+
+```json Success
+{
+ "id": "user_123",
+ "name": "John Doe",
+ "email": "john@example.com",
+ "created_at": "2024-01-15T10:30:00Z"
+}
+```
+
+
+### Structural components
+
+#### Steps for procedures
+
+
+
+ Run `npm install` to install required packages.
+
+
+ Verify installation by running `npm list`.
+
+
+
+
+ Create a `.env` file with your API credentials.
+
+ ```bash
+ API_KEY=your_api_key_here
+ ```
+
+
+ Never commit API keys to version control.
+
+
+
+
+#### Tabs for alternative content
+
+
+
+ ```bash
+ brew install node
+ npm install -g package-name
+ ```
+
+
+
+ ```powershell
+ choco install nodejs
+ npm install -g package-name
+ ```
+
+
+
+ ```bash
+ sudo apt install nodejs npm
+ npm install -g package-name
+ ```
+
+
+
+#### Accordions for collapsible content
+
+
+
+ - **Firewall blocking**: Ensure ports 80 and 443 are open
+ - **Proxy configuration**: Set HTTP_PROXY environment variable
+ - **DNS resolution**: Try using 8.8.8.8 as DNS server
+
+
+
+ ```javascript
+ const config = {
+ performance: { cache: true, timeout: 30000 },
+ security: { encryption: 'AES-256' }
+ };
+ ```
+
+
+
+### API documentation components
+
+#### Parameter fields
+
+
+Unique identifier for the user. Must be a valid UUID v4 format.
+
+
+
+User's email address. Must be valid and unique within the system.
+
+
+
+Maximum number of results to return. Range: 1-100.
+
+
+
+Bearer token for API authentication. Format: `Bearer YOUR_API_KEY`
+
+
+#### Response fields
+
+
+Unique identifier assigned to the newly created user.
+
+
+
+ISO 8601 formatted timestamp of when the user was created.
+
+
+
+List of permission strings assigned to this user.
+
+
+#### Expandable nested fields
+
+
+Complete user object with all associated data.
+
+
+
+ User profile information including personal details.
+
+
+
+ User's first name as entered during registration.
+
+
+
+ URL to user's profile picture. Returns null if no avatar is set.
+
+
+
+
+
+
+### Interactive components
+
+#### Cards for navigation
+
+
+Complete walkthrough from installation to your first API call in under 10 minutes.
+
+
+
+
+ Learn how to authenticate requests using API keys or JWT tokens.
+
+
+
+ Understand rate limits and best practices for high-volume usage.
+
+
+
+### Media and advanced components
+
+#### Frames for images
+
+Wrap all images in frames.
+
+
+
+
+
+
+
+
+
+#### Tooltips and updates
+
+
+API
+
+
+
+## New features
+- Added bulk user import functionality
+- Improved error messages with actionable suggestions
+
+## Bug fixes
+- Fixed pagination issue with large datasets
+- Resolved authentication timeout problems
+
+
+## Required page structure
+
+Every documentation page must begin with YAML frontmatter:
+
+```yaml
+---
+title: "Clear, specific, keyword-rich title"
+description: "Concise description explaining page purpose and value"
+---
+```
+
+## Content quality standards
+
+### Code examples requirements
+- Always include complete, runnable examples that users can copy and execute
+- Show proper error handling and edge case management
+- Use realistic data instead of placeholder values
+- Include expected outputs and results for verification
+- Test all code examples thoroughly before publishing
+- Specify language and include filename when relevant
+- Add explanatory comments for complex logic
+
+### API documentation requirements
+- Document all parameters including optional ones with clear descriptions
+- Show both success and error response examples with realistic data
+- Include rate limiting information with specific limits
+- Provide authentication examples showing proper format
+- Explain all HTTP status codes and error handling
+- Cover complete request/response cycles
+
+### Accessibility requirements
+- Include descriptive alt text for all images and diagrams
+- Use specific, actionable link text instead of "click here"
+- Ensure proper heading hierarchy starting with H2
+- Provide keyboard navigation considerations
+- Use sufficient color contrast in examples and visuals
+- Structure content for easy scanning with headers and lists
+
+## AI assistant instructions
+
+### Component selection logic
+- Use **Steps** for procedures, tutorials, setup guides, and sequential instructions
+- Use **Tabs** for platform-specific content or alternative approaches
+- Use **CodeGroup** when showing the same concept in multiple languages
+- Use **Accordions** for supplementary information that might interrupt flow
+- Use **Cards and CardGroup** for navigation, feature overviews, and related resources
+- Use **RequestExample/ResponseExample** specifically for API endpoint documentation
+- Use **ParamField** for API parameters, **ResponseField** for API responses
+- Use **Expandable** for nested object properties or hierarchical information
+
+### Quality assurance checklist
+- Verify all code examples are syntactically correct and executable
+- Test all links to ensure they are functional and lead to relevant content
+- Validate Mintlify component syntax with all required properties
+- Confirm proper heading hierarchy with H2 for main sections, H3 for subsections
+- Ensure content flows logically from basic concepts to advanced topics
+- Check for consistency in terminology, formatting, and component usage
+
+### Error prevention strategies
+- Always include realistic error handling in code examples
+- Provide dedicated troubleshooting sections for complex procedures
+- Explain prerequisites clearly before beginning instructions
+- Include verification and testing steps with expected outcomes
+- Add appropriate warnings for destructive or security-sensitive actions
+- Validate all technical information through testing before publication
\ No newline at end of file
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index 284b863440d5c6d7cc0e196171576ab6ffa4e4d6..b861618112a3a900c5a65e7384d2f46f1948e7e2 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -2,6 +2,103 @@
icon: "list-check"
---
+
+
+## [v2.9.0: Stuck in the Middleware With You](https://github.com/jlowin/fastmcp/releases/tag/v2.9.0)
+
+FastMCP 2.9 introduces two important features that push beyond the basic MCP protocol: MCP Middleware and server-side type conversion.
+
+### MCP Middleware
+MCP middleware lets you intercept and modify requests and responses at the protocol level, giving you powerful capabilities for logging, authentication, validation, and more. This is particularly useful for building production-ready MCP servers that need sophisticated request handling.
+
+### Server-side Type Conversion
+This release also introduces server-side type conversion for prompt arguments, ensuring that data is properly formatted before being passed to your functions. This reduces the burden on individual tools and prompts to handle type validation and conversion.
+
+## What's Changed
+### New Features š
+* Add File utility for binary data by [@gorocode](https://github.com/gorocode) in [#843](https://github.com/jlowin/fastmcp/pull/843)
+* Consolidate prefix logic into FastMCP methods by [@jlowin](https://github.com/jlowin) in [#861](https://github.com/jlowin/fastmcp/pull/861)
+* Add MCP Middleware by [@jlowin](https://github.com/jlowin) in [#870](https://github.com/jlowin/fastmcp/pull/870)
+* Implement server-side type conversion for prompt arguments by [@jlowin](https://github.com/jlowin) in [#908](https://github.com/jlowin/fastmcp/pull/908)
+### Enhancements š§
+* Fix tool description indentation issue by [@zfflxx](https://github.com/zfflxx) in [#845](https://github.com/jlowin/fastmcp/pull/845)
+* Add version parameter to FastMCP constructor by [@mkyutani](https://github.com/mkyutani) in [#842](https://github.com/jlowin/fastmcp/pull/842)
+* Update version to not be positional by [@jlowin](https://github.com/jlowin) in [#848](https://github.com/jlowin/fastmcp/pull/848)
+* Add key to component by [@jlowin](https://github.com/jlowin) in [#869](https://github.com/jlowin/fastmcp/pull/869)
+* Add session_id property to Context for data sharing by [@jlowin](https://github.com/jlowin) in [#881](https://github.com/jlowin/fastmcp/pull/881)
+* Fix CORS documentation example by [@jlowin](https://github.com/jlowin) in [#895](https://github.com/jlowin/fastmcp/pull/895)
+### Fixes š
+* "report_progress missing passing related_request_id causes notifications not working" by [@alexsee](https://github.com/alexsee) in [#838](https://github.com/jlowin/fastmcp/pull/838)
+* Fix JWT issuer validation to support string values per RFC 7519 by [@jlowin](https://github.com/jlowin) in [#892](https://github.com/jlowin/fastmcp/pull/892)
+* Fix BearerAuthProvider audience type annotations by [@jlowin](https://github.com/jlowin) in [#894](https://github.com/jlowin/fastmcp/pull/894)
+### Docs š
+* Add CLAUDE.md development guidelines by [@jlowin](https://github.com/jlowin) in [#880](https://github.com/jlowin/fastmcp/pull/880)
+* Update context docs for session_id property by [@jlowin](https://github.com/jlowin) in [#882](https://github.com/jlowin/fastmcp/pull/882)
+* Add API reference by [@zzstoatzz](https://github.com/zzstoatzz) in [#893](https://github.com/jlowin/fastmcp/pull/893)
+* Fix API ref rendering by [@zzstoatzz](https://github.com/zzstoatzz) in [#900](https://github.com/jlowin/fastmcp/pull/900)
+* Simplify docs nav by [@jlowin](https://github.com/jlowin) in [#902](https://github.com/jlowin/fastmcp/pull/902)
+* Add fastmcp inspect command by [@jlowin](https://github.com/jlowin) in [#904](https://github.com/jlowin/fastmcp/pull/904)
+* Update client docs by [@jlowin](https://github.com/jlowin) in [#912](https://github.com/jlowin/fastmcp/pull/912)
+* Update docs nav by [@jlowin](https://github.com/jlowin) in [#913](https://github.com/jlowin/fastmcp/pull/913)
+* Update integration documentation for Claude Desktop, ChatGPT, and Claude Code by [@jlowin](https://github.com/jlowin) in [#915](https://github.com/jlowin/fastmcp/pull/915)
+* Add http as an alias for streamable http by [@jlowin](https://github.com/jlowin) in [#917](https://github.com/jlowin/fastmcp/pull/917)
+* Clean up parameter documentation by [@jlowin](https://github.com/jlowin) in [#918](https://github.com/jlowin/fastmcp/pull/918)
+* Add middleware examples for timing, logging, rate limiting, and error handling by [@jlowin](https://github.com/jlowin) in [#919](https://github.com/jlowin/fastmcp/pull/919)
+* ControlFlow ā FastMCP rename by [@jlowin](https://github.com/jlowin) in [#922](https://github.com/jlowin/fastmcp/pull/922)
+### Examples & Contrib š”
+* Add contrib.mcp_mixin support for annotations by [@rsp2k](https://github.com/rsp2k) in [#860](https://github.com/jlowin/fastmcp/pull/860)
+* Add ATProto (Bluesky) MCP Server Example by [@zzstoatzz](https://github.com/zzstoatzz) in [#916](https://github.com/jlowin/fastmcp/pull/916)
+* Fix path in atproto example pyproject by [@zzstoatzz](https://github.com/zzstoatzz) in [#920](https://github.com/jlowin/fastmcp/pull/920)
+* Remove uv source in example by [@zzstoatzz](https://github.com/zzstoatzz) in [#921](https://github.com/jlowin/fastmcp/pull/921)
+
+## New Contributors
+* [@alexsee](https://github.com/alexsee) made their first contribution in [#838](https://github.com/jlowin/fastmcp/pull/838)
+* [@zfflxx](https://github.com/zfflxx) made their first contribution in [#845](https://github.com/jlowin/fastmcp/pull/845)
+* [@mkyutani](https://github.com/mkyutani) made their first contribution in [#842](https://github.com/jlowin/fastmcp/pull/842)
+* [@gorocode](https://github.com/gorocode) made their first contribution in [#843](https://github.com/jlowin/fastmcp/pull/843)
+* [@rsp2k](https://github.com/rsp2k) made their first contribution in [#860](https://github.com/jlowin/fastmcp/pull/860)
+* [@owtaylor](https://github.com/owtaylor) made their first contribution in [#897](https://github.com/jlowin/fastmcp/pull/897)
+* [@Jason-CKY](https://github.com/Jason-CKY) made their first contribution in [#906](https://github.com/jlowin/fastmcp/pull/906)
+
+**Full Changelog**: [v2.8.1...v2.9.0](https://github.com/jlowin/fastmcp/compare/v2.8.1...v2.9.0)
+
+
+
+
+
+## [v2.8.1: Sound Judgement](https://github.com/jlowin/fastmcp/releases/tag/v2.8.1)
+
+2.8.1 introduces audio support, as well as minor fixes and updates for deprecated features.
+
+### Audio Support
+This release adds support for audio content in MCP tools and resources, expanding FastMCP's multimedia capabilities beyond text and images.
+
+## What's Changed
+### New Features š
+* Add audio support by [@jlowin](https://github.com/jlowin) in [#833](https://github.com/jlowin/fastmcp/pull/833)
+### Enhancements š§
+* Add flag for disabling deprecation warnings by [@jlowin](https://github.com/jlowin) in [#802](https://github.com/jlowin/fastmcp/pull/802)
+* Add examples to Tool Arg Param transformation by [@strawgate](https://github.com/strawgate) in [#806](https://github.com/jlowin/fastmcp/pull/806)
+### Fixes š
+* Restore .settings access as deprecated by [@jlowin](https://github.com/jlowin) in [#800](https://github.com/jlowin/fastmcp/pull/800)
+* Ensure handling of false http kwargs correctly; removed unused kwarg by [@jlowin](https://github.com/jlowin) in [#804](https://github.com/jlowin/fastmcp/pull/804)
+* Bump mcp 1.9.4 by [@jlowin](https://github.com/jlowin) in [#835](https://github.com/jlowin/fastmcp/pull/835)
+### Docs š
+* Update changelog for 2.8.0 by [@jlowin](https://github.com/jlowin) in [#794](https://github.com/jlowin/fastmcp/pull/794)
+* Update welcome docs by [@jlowin](https://github.com/jlowin) in [#808](https://github.com/jlowin/fastmcp/pull/808)
+* Update headers in docs by [@jlowin](https://github.com/jlowin) in [#809](https://github.com/jlowin/fastmcp/pull/809)
+* Add MCP group to tutorials by [@jlowin](https://github.com/jlowin) in [#810](https://github.com/jlowin/fastmcp/pull/810)
+* Add Community section to documentation by [@zzstoatzz](https://github.com/zzstoatzz) in [#819](https://github.com/jlowin/fastmcp/pull/819)
+* Add 2.8 update by [@jlowin](https://github.com/jlowin) in [#821](https://github.com/jlowin/fastmcp/pull/821)
+* Embed YouTube videos in community showcase by [@zzstoatzz](https://github.com/zzstoatzz) in [#820](https://github.com/jlowin/fastmcp/pull/820)
+### Other Changes š¦¾
+* Ensure http args are passed through by [@jlowin](https://github.com/jlowin) in [#803](https://github.com/jlowin/fastmcp/pull/803)
+* Fix install link in readme by [@jlowin](https://github.com/jlowin) in [#836](https://github.com/jlowin/fastmcp/pull/836)
+
+**Full Changelog**: [v2.8.0...v2.8.1](https://github.com/jlowin/fastmcp/compare/v2.8.0...v2.8.1)
+
+
+
## [v2.8.0: Transform and Roll Out](https://github.com/jlowin/fastmcp/releases/tag/v2.8.0)
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index c56ce634e920bb32c14251cf44de6b13509d17b3..ea5971a9992d688dcfcf766c2080c6937433e7df 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -102,7 +102,7 @@ config = {
"mcpServers": {
"server_name": {
# Remote HTTP/SSE server
- "transport": "streamable-http", # or "sse"
+ "transport": "http", # or "sse"
"url": "https://api.example.com/mcp",
"headers": {"Authorization": "Bearer token"},
"auth": "oauth" # or bearer token string
diff --git a/docs/clients/logging.mdx b/docs/clients/logging.mdx
index 9c28a5d251d192695b94fc61d92eb31451e71b19..f9cc9fcf5b5e2e45ae4603ad71bf37d05ec4beb2 100644
--- a/docs/clients/logging.mdx
+++ b/docs/clients/logging.mdx
@@ -11,7 +11,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
MCP servers can emit log messages to clients. The client can handle these logs through a log handler callback.
-## Setting Up Log Handling
+## Log Handler
Provide a `log_handler` function when creating the client:
@@ -31,13 +31,27 @@ client = Client(
)
```
-## LogMessage Structure
+### Handler Parameters
-The `log_handler` receives a `LogMessage` object with:
+The `log_handler` is called every time a log message is received. It receives a `LogMessage` object:
-- **`level`**: Log level (e.g., "debug", "info", "warning", "error")
-- **`logger`**: Logger name (optional, may be None)
-- **`data`**: The actual log message content
+
+
+
+
+ The log level
+
+
+
+ The logger name (optional, may be None)
+
+
+
+ The actual log message content
+
+
+
+
```python
async def detailed_log_handler(message: LogMessage):
@@ -51,13 +65,12 @@ async def detailed_log_handler(message: LogMessage):
## Default Log Handling
-If you don't provide a custom `log_handler`, FastMCP uses a default handler that emits DEBUG level logs:
+If you don't provide a custom `log_handler`, FastMCP uses a default handler that emits a DEBUG-level FastMCP log for every log message received from the server, which is useful for visibility without polluting your own logs.
```python
-# Without custom handler - uses default DEBUG logging
client = Client("my_mcp_server.py")
async with client:
- # Server logs will be emitted at DEBUG level
+ # Server logs will be emitted at DEBUG level automatically
await client.call_tool("some_tool")
```
\ No newline at end of file
diff --git a/docs/clients/progress.mdx b/docs/clients/progress.mdx
index bd500fa2650d7ba6be945d3bf2b358293a10376a..ff3e0aa853909e72dda89079014b19d0b39664c1 100644
--- a/docs/clients/progress.mdx
+++ b/docs/clients/progress.mdx
@@ -11,7 +11,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
MCP servers can report progress during long-running operations. The client can receive these updates through a progress handler.
-## Setting Up Progress Handling
+## Progress Handler
Set a progress handler when creating the client:
@@ -35,6 +35,26 @@ client = Client(
)
```
+### Handler Parameters
+
+The progress handler receives three parameters:
+
+
+
+
+ Current progress value
+
+
+
+ Expected total value (may be None)
+
+
+
+ Optional status message (may be None)
+
+
+
+
## Per-Call Progress Handler
Override the progress handler for specific tool calls:
@@ -48,12 +68,3 @@ async with client:
progress_handler=my_progress_handler
)
```
-
-## Handler Parameters
-
-The progress handler receives:
-
-- **`progress`** (float): Current progress value
-- **`total`** (float | None): Expected total value (may be None)
-- **`message`** (str | None): Optional status message (may be None)
-
diff --git a/docs/clients/sampling.mdx b/docs/clients/sampling.mdx
index 25d035478e0ca31106b352a202cb80ba99ce44af..0483a59b325914caa3980948948bbbec61ef013c 100644
--- a/docs/clients/sampling.mdx
+++ b/docs/clients/sampling.mdx
@@ -5,13 +5,13 @@ description: Handle server-initiated LLM sampling requests.
icon: robot
---
-import { VersionBadge } from '/snippets/version-badge.mdx'
+import { VersionBadge } from "/snippets/version-badge.mdx";
MCP servers can request LLM completions from clients. The client handles these requests through a sampling handler callback.
-## Setting Up Sampling Handling
+## Sampling Handler
Provide a `sampling_handler` function when creating the client:
@@ -38,26 +38,88 @@ client = Client(
)
```
-## Handler Parameters
+### Handler Parameters
The sampling handler receives three parameters:
-### SamplingMessage
-
-- **`role`**: Message role (e.g., "user", "assistant", "system")
-- **`content`**: Message content (usually has `.text` attribute)
-
-### SamplingParams
-
-- **`systemPrompt`**: System prompt string (optional)
-- **`maxTokens`**: Maximum tokens to generate (optional)
-- **`temperature`**: Sampling temperature (optional)
-- **`topP`**: Top-p sampling parameter (optional)
-- **`stopSequences`**: List of stop sequences (optional)
-
-### RequestContext
-
-- **`request_id`**: Unique identifier for the sampling request
+
+
+
+
+ The role of the message.
+
+
+
+ The content of the message.
+
+ TextContent is most common, and has a `.text` attribute.
+
+
+
+
+
+
+
+ The messages to sample from
+
+
+
+ The server's preferences for which model to select. The client MAY ignore
+ these preferences.
+
+
+ The hints to use for model selection.
+
+
+
+ The cost priority for model selection.
+
+
+
+ The speed priority for model selection.
+
+
+
+ The intelligence priority for model selection.
+
+
+
+
+
+ An optional system prompt the server wants to use for sampling.
+
+
+
+ A request to include context from one or more MCP servers (including the caller), to
+ be attached to the prompt.
+
+
+
+ The sampling temperature.
+
+
+
+ The maximum number of tokens to sample.
+
+
+
+ The stop sequences to use for sampling.
+
+
+
+ Optional metadata to pass through to the LLM provider.
+
+
+
+
+
+
+
+ Unique identifier for the MCP request
+
+
+
+
## Basic Example
@@ -75,10 +137,10 @@ async def basic_sampling_handler(
for message in messages:
content = message.content.text if hasattr(message.content, 'text') else str(message.content)
conversation.append(f"{message.role}: {content}")
-
+
# Use the system prompt if provided
system_prompt = params.systemPrompt or "You are a helpful assistant."
-
+
# Here you would integrate with your preferred LLM service
# This is just a placeholder response
return f"Response based on conversation: {' | '.join(conversation)}"
@@ -88,4 +150,3 @@ client = Client(
sampling_handler=basic_sampling_handler
)
```
-
diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx
index 94c94833cdf2dc22aabae0229ab4c49db7034cc8..d02aaf6f2db02087abf7d6a450ad8ecdeb1b4550 100644
--- a/docs/clients/transports.mdx
+++ b/docs/clients/transports.mdx
@@ -41,7 +41,7 @@ Streamable HTTP is the recommended transport for web-based deployments, providin
- **Class:** `fastmcp.client.transports.StreamableHttpTransport`
- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) that do not contain `/sse/` in the path
-- **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
+- **Server Compatibility:** Works with FastMCP servers running in `http` mode
#### Basic Usage
@@ -150,7 +150,7 @@ client = Client(transport)
- **Use Streamable HTTP when:**
- Setting up new deployments (recommended default)
- You need bidirectional streaming
- - You're connecting to FastMCP servers running in `streamable-http` mode
+ - You're connecting to FastMCP servers running in `http` mode
- **Use SSE when:**
- Connecting to legacy FastMCP servers running in `sse` mode
@@ -397,7 +397,7 @@ config = {
# Remote HTTP server
"weather": {
"url": "https://weather-api.example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
},
# Local stdio server
"assistant": {
@@ -408,7 +408,7 @@ config = {
# Another remote server
"calendar": {
"url": "https://calendar-api.example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
}
}
}
diff --git a/docs/style.css b/docs/css/banner.css
similarity index 52%
rename from docs/style.css
rename to docs/css/banner.css
index 1e98ae2ff6a4c4c29f98d5e34d317c8ffeac0392..093d9b797a4d87004cc482c4d82006a499a1b2aa 100644
--- a/docs/style.css
+++ b/docs/css/banner.css
@@ -1,17 +1,3 @@
-/* Code highlighting -- target only inline code elements, not code blocks */
-p code:not(pre code),
-table code:not(pre code),
-li code:not(pre code),
-h1 code:not(pre code),
-h2 code:not(pre code),
-h3 code:not(pre code),
-h4 code:not(pre code),
-h5 code:not(pre code),
-h6 code:not(pre code) {
- color: #f72585 !important;
- background-color: rgba(247, 37, 133, 0.09);
-}
-
/* Banner styling -- improve readability with better contrast */
#banner {
background: #f1f5f9 !important;
@@ -79,41 +65,3 @@ h6 code:not(pre code) {
color: #f1f5f9 !important;
}
-/* Version badge -- display a badge with the current version of the documentation */
-.version-badge {
- display: inline-block;
- align-items: center;
- gap: 0.3em;
- font-size: 1em;
- margin-top: 0px;
- margin-bottom: 0px;
- padding-top: 6px;
- padding-bottom: 6px;
- padding-left: 20px;
- padding-right: 20px;
- font-family: "Inter", sans-serif;
- color: #ff5400;
- background: #fef2f2;
- border: 1px solid rgba(220, 38, 38, 0.3);
- border-radius: 12px;
- box-shadow: none;
- vertical-align: middle;
- position: relative;
- transition: box-shadow 0.2s, transform 0.15s;
-}
-
-.version-badge-container {
- margin: 0;
- padding: 0;
-}
-
-.version-badge:hover {
- box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1);
- transform: translateY(-1px) scale(1.03);
-}
-
-.dark .version-badge {
- color: #f1f5f9;
- background: #334155;
- border: 1px solid #64748b;
-}
diff --git a/docs/css/python-sdk.css b/docs/css/python-sdk.css
new file mode 100644
index 0000000000000000000000000000000000000000..72a64c21a66e4d5385e0740c3bc63acaf7f68dbe
--- /dev/null
+++ b/docs/css/python-sdk.css
@@ -0,0 +1,3 @@
+a:has(svg.icon) {
+ border: none !important;
+}
\ No newline at end of file
diff --git a/docs/css/style.css b/docs/css/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..9716917b1dd3af068bee1605c6adadeba2d4b108
--- /dev/null
+++ b/docs/css/style.css
@@ -0,0 +1,13 @@
+/* Code highlighting -- target only inline code elements, not code blocks */
+p code:not(pre code),
+table code:not(pre code),
+li code:not(pre code),
+h1 code:not(pre code),
+h2 code:not(pre code),
+h3 code:not(pre code),
+h4 code:not(pre code),
+h5 code:not(pre code),
+h6 code:not(pre code) {
+ color: #f72585 !important;
+ background-color: rgba(247, 37, 133, 0.09);
+}
diff --git a/docs/css/version-badge.css b/docs/css/version-badge.css
new file mode 100644
index 0000000000000000000000000000000000000000..daff22177fb089b81937e5526845372cdf5cf013
--- /dev/null
+++ b/docs/css/version-badge.css
@@ -0,0 +1,39 @@
+/* Version badge -- display a badge with the current version of the documentation */
+.version-badge {
+ display: inline-block;
+ align-items: center;
+ gap: 0.3em;
+ font-size: 1em;
+ margin-top: 0px;
+ margin-bottom: 0px;
+ padding-top: 6px;
+ padding-bottom: 6px;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-family: "Inter", sans-serif;
+ color: #ff5400;
+ background: #fef2f2;
+ border: 1px solid rgba(220, 38, 38, 0.3);
+ border-radius: 12px;
+ box-shadow: none;
+ vertical-align: middle;
+ position: relative;
+ transition: box-shadow 0.2s, transform 0.15s;
+}
+
+.version-badge-container {
+ margin: 0;
+ padding: 0;
+}
+
+.version-badge:hover {
+ box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1);
+ transform: translateY(-1px) scale(1.03);
+}
+
+.dark .version-badge {
+ color: #f1f5f9;
+ background: #334155;
+ border: 1px solid #64748b;
+}
+
diff --git a/docs/deployment/running-server.mdx b/docs/deployment/running-server.mdx
index 591cba32c2e620ee7de3db5b8c547f102120fd26..6436c82dadc00f324cdbc98173d986683a264a61 100644
--- a/docs/deployment/running-server.mdx
+++ b/docs/deployment/running-server.mdx
@@ -105,7 +105,7 @@ When using Stdio transport, you will typically *not* run the server yourself as
Streamable HTTP is a modern, efficient transport for exposing your MCP server via HTTP. It is the recommended transport for web-based deployments.
-To run a server using Streamable HTTP, you can use the `run()` method with the `transport` argument set to `"streamable-http"`. This will start a Uvicorn server on the default host (`127.0.0.1`), port (`8000`), and path (`/mcp/`).
+To run a server using Streamable HTTP, you can use the `run()` method with the `transport` argument set to `"http"`. This will start a Uvicorn server on the default host (`127.0.0.1`), port (`8000`), and path (`/mcp/`).
```python {6} server.py
from fastmcp import FastMCP
@@ -113,7 +113,7 @@ from fastmcp import FastMCP
mcp = FastMCP()
if __name__ == "__main__":
- mcp.run(transport="streamable-http")
+ mcp.run(transport="http")
```
```python {5} client.py
import asyncio
@@ -128,6 +128,10 @@ if __name__ == "__main__":
```
+
+For backward compatibility, wherever `"http"` is accepted as a transport name, you can also pass `"streamable-http"` as a fully supported alias. This is particularly useful when upgrading from FastMCP 1.x in the official Python SDK and FastMCP \<= 2.9, where `"streamable-http"` was the standard name.
+
+
To customize the host, port, path, or log level, provide appropriate keyword arguments to the `run()` method.
@@ -138,7 +142,7 @@ mcp = FastMCP()
if __name__ == "__main__":
mcp.run(
- transport="streamable-http",
+ transport="http",
host="127.0.0.1",
port=4200,
path="/my-custom-path",
@@ -158,7 +162,6 @@ if __name__ == "__main__":
```
-
### SSE
@@ -250,7 +253,7 @@ def hello(name: str) -> str:
async def main():
# Use run_async() in async contexts
- await mcp.run_async(transport="streamable-http")
+ await mcp.run_async(transport="http")
if __name__ == "__main__":
asyncio.run(main())
diff --git a/docs/docs.json b/docs/docs.json
index 66971c813823a5c83291a4fd48d666d4449875b7..f84e9b09b71bef27f1e22b5fbc6ee56d3255bacf 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -76,7 +76,9 @@
{
"group": "Authentication",
"icon": "shield-check",
- "pages": ["servers/auth/bearer"]
+ "pages": [
+ "servers/auth/bearer"
+ ]
},
"servers/middleware",
"servers/openapi",
@@ -85,7 +87,10 @@
{
"group": "Deployment",
"icon": "upload",
- "pages": ["deployment/running-server", "deployment/asgi"]
+ "pages": [
+ "deployment/running-server",
+ "deployment/asgi"
+ ]
}
]
},
@@ -116,7 +121,10 @@
{
"group": "Authentication",
"icon": "user-shield",
- "pages": ["clients/auth/oauth", "clients/auth/bearer"]
+ "pages": [
+ "clients/auth/oauth",
+ "clients/auth/bearer"
+ ]
}
]
},
@@ -124,9 +132,11 @@
"group": "Integrations",
"pages": [
"integrations/anthropic",
+ "integrations/chatgpt",
+ "integrations/claude-code",
"integrations/claude-desktop",
- "integrations/openai",
"integrations/gemini",
+ "integrations/openai",
"integrations/contrib"
]
},
@@ -153,13 +163,17 @@
},
{
"anchor": "What's New",
- "pages": ["updates", "changelog"]
+ "pages": [
+ "updates",
+ "changelog"
+ ]
},
-
{
"anchor": "Community",
"icon": "users",
- "pages": ["community/showcase"]
+ "pages": [
+ "community/showcase"
+ ]
}
]
},
@@ -243,7 +257,17 @@
"python-sdk/fastmcp-server-context",
"python-sdk/fastmcp-server-dependencies",
"python-sdk/fastmcp-server-http",
- "python-sdk/fastmcp-server-middleware",
+ {
+ "group": "middleware",
+ "pages": [
+ "python-sdk/fastmcp-server-middleware-__init__",
+ "python-sdk/fastmcp-server-middleware-error_handling",
+ "python-sdk/fastmcp-server-middleware-logging",
+ "python-sdk/fastmcp-server-middleware-middleware",
+ "python-sdk/fastmcp-server-middleware-rate_limiting",
+ "python-sdk/fastmcp-server-middleware-timing"
+ ]
+ },
"python-sdk/fastmcp-server-openapi",
"python-sdk/fastmcp-server-proxy",
"python-sdk/fastmcp-server-server"
@@ -266,10 +290,12 @@
"python-sdk/fastmcp-utilities-components",
"python-sdk/fastmcp-utilities-exceptions",
"python-sdk/fastmcp-utilities-http",
+ "python-sdk/fastmcp-utilities-inspect",
"python-sdk/fastmcp-utilities-json_schema",
"python-sdk/fastmcp-utilities-logging",
"python-sdk/fastmcp-utilities-mcp_config",
"python-sdk/fastmcp-utilities-openapi",
+ "python-sdk/fastmcp-utilities-tests",
"python-sdk/fastmcp-utilities-types"
]
}
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
index 4e59b3557e7f79c9c5d8167eaa91c17c99463719..d63a077a4c446a411b6b7458255cd3259fb81baa 100644
--- a/docs/getting-started/installation.mdx
+++ b/docs/getting-started/installation.mdx
@@ -47,7 +47,7 @@ FastMCP root path: ~/Developer/fastmcp
Upgrading from the official MCP SDK's FastMCP 1.0 to FastMCP 2.0 is generally straightforward. The core server API is highly compatible, and in many cases, changing your import statement from `from mcp.server.fastmcp import FastMCP` to `from fastmcp import FastMCP` will be sufficient.
-```python {1-5}
+```python {5}
# Before
# from mcp.server.fastmcp import FastMCP
@@ -56,8 +56,9 @@ from fastmcp import FastMCP
mcp = FastMCP("My MCP Server")
```
+
-Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the 1.0 API. However, as the projects diverge, this can not be guaranteed. You may see deprecation warnings if you attempt to use 1.0 APIs in FastMCP 2.x. Please refer to this documentation for details on new capabilities.
+Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the official 1.0 API. However, as the projects diverge, this can not be guaranteed. You may see deprecation warnings if you attempt to use 1.0 APIs in FastMCP 2.x. Please refer to this documentation for details on new capabilities.
## Versioning and Breaking Changes
diff --git a/docs/integrations/anthropic.mdx b/docs/integrations/anthropic.mdx
index e9ebe860148670672aa9a1c8896ee628324ea47f..6e2651a7c19724b0f9d7b3b85fd4b1e01ed1775c 100644
--- a/docs/integrations/anthropic.mdx
+++ b/docs/integrations/anthropic.mdx
@@ -31,7 +31,7 @@ def roll_dice(n_dice: int) -> list[int]:
return [random.randint(1, 6) for _ in range(n_dice)]
if __name__ == "__main__":
- mcp.run(transport="sse", port=8000)
+ mcp.run(transport="http", port=8000)
```
## Deploy the Server
@@ -70,7 +70,7 @@ You'll also need to authenticate with Anthropic. You can do this by setting the
export ANTHROPIC_API_KEY="your-api-key"
```
-Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/sse` as the endpoint because we deployed an SSE server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.**
+Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.**
```python {5, 13-22}
import anthropic
@@ -88,7 +88,7 @@ response = client.beta.messages.create(
mcp_servers=[
{
"type": "url",
- "url": f"{url}/sse",
+ "url": f"{url}/mcp/",
"name": "dice-server",
}
],
@@ -175,7 +175,7 @@ def roll_dice(n_dice: int) -> list[int]:
if __name__ == "__main__":
print(f"\n---\n\nš Dice Roller access token:\n\n{access_token}\n\n---\n")
- mcp.run(transport="sse", port=8000)
+ mcp.run(transport="http", port=8000)
```
### Client Authentication
@@ -213,7 +213,7 @@ response = client.beta.messages.create(
mcp_servers=[
{
"type": "url",
- "url": f"{url}/sse",
+ "url": f"{url}/mcp/",
"name": "dice-server",
"authorization_token": access_token
}
diff --git a/docs/integrations/chatgpt.mdx b/docs/integrations/chatgpt.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..a4d2c694205549ce8565afabfbeac157cacba7fa
--- /dev/null
+++ b/docs/integrations/chatgpt.mdx
@@ -0,0 +1,158 @@
+---
+title: ChatGPT + FastMCP
+sidebarTitle: ChatGPT
+description: Connect FastMCP servers to ChatGPT Deep Research
+icon: message-smile
+tag: NEW
+---
+
+ChatGPT supports MCP servers through remote HTTP connections, allowing you to extend ChatGPT's capabilities with custom tools and knowledge from your FastMCP servers.
+
+
+MCP integration with ChatGPT is currently limited to **Deep Research** functionality and is not available for general chat. This feature is available for ChatGPT Pro, Team, Enterprise, and Edu users.
+
+
+
+OpenAI's official MCP documentation and examples are built with **FastMCP v2**! Check out their [sample MCP server](https://github.com/openai/mcp-server-sample) which demonstrates FastMCP in action.
+
+
+## Deep Research
+
+ChatGPT's Deep Research feature requires MCP servers to be internet-accessible HTTP endpoints with **exactly two specific tools**:
+
+- **`search`**: For searching through your resources and returning matching IDs
+- **`fetch`**: For retrieving the full content of specific resources by ID
+
+
+If your server doesn't implement both `search` and `fetch` tools with the correct signatures, ChatGPT will show the error: "This MCP server doesn't implement our specification". Both tools are required.
+
+
+### Tool Descriptions Matter
+
+Since ChatGPT needs to understand how to use your tools effectively, **write detailed tool descriptions**. The description teaches ChatGPT how to form queries, what parameters to use, and what to expect from your data. Poor descriptions lead to poor search results.
+
+### Create a Server
+
+A Deep Research-compatible server must implement these two required tools:
+
+- **`search(query: str)`** - Takes a query of any kind and returns matching record IDs
+- **`fetch(id: str)`** - Takes an ID and returns the record
+
+**Critical**: Write detailed docstrings for both tools. These descriptions teach ChatGPT how to use your tools effectively. Poor descriptions lead to poor search results.
+
+The `search` tool should take a query (of any kind!) and return IDs. The `fetch` tool should take an ID and return the record.
+
+Here's a reference server implementation you can adapt (see also [OpenAI's sample server](https://github.com/openai/mcp-server-sample) for comparison):
+
+```python server.py [expandable]
+import json
+from pathlib import Path
+from dataclasses import dataclass
+from fastmcp import FastMCP
+
+@dataclass
+class Record:
+ id: str
+ title: str
+ text: str
+ metadata: dict
+
+def create_server(
+ records_path: Path | str,
+ name: str | None = None,
+ instructions: str | None = None,
+) -> FastMCP:
+ """Create a FastMCP server that can search and fetch records from a JSON file."""
+ records = json.loads(Path(records_path).read_text())
+
+ RECORDS = [Record(**r) for r in records]
+ LOOKUP = {r.id: r for r in RECORDS}
+
+ mcp = FastMCP(name=name or "Deep Research MCP", instructions=instructions)
+
+ @mcp.tool()
+ async def search(query: str):
+ """
+ Simple unranked keyword search across title, text, and metadata.
+ Searches for any of the query terms in the record content.
+ Returns a list of matching record IDs for ChatGPT to fetch.
+ """
+ toks = query.lower().split()
+ ids = []
+ for r in RECORDS:
+ record_txt = " ".join(
+ [r.title, r.text, " ".join(r.metadata.values())]
+ ).lower()
+ if any(t in record_txt for t in toks):
+ ids.append(r.id)
+
+ return {"ids": ids}
+
+ @mcp.tool()
+ async def fetch(id: str):
+ """
+ Fetch a record by ID.
+ Returns the complete record data for ChatGPT to analyze and cite.
+ """
+ if id not in LOOKUP:
+ raise ValueError(f"Unknown record ID: {id}")
+ return LOOKUP[id]
+
+ return mcp
+
+if __name__ == "__main__":
+ mcp = create_server("path/to/records.json")
+ mcp.run(transport="http", port=8000)
+```
+
+### Deploy the Server
+
+Your server must be deployed to a public URL in order for ChatGPT to access it.
+
+For development, you can use tools like `ngrok` to temporarily expose a locally-running server to the internet. We'll do that for this example (you may need to install `ngrok` and create a free account), but you can use any other method to deploy your server.
+
+Assuming you saved the above code as `server.py`, you can run the following two commands in two separate terminals to deploy your server and expose it to the internet:
+
+
+```bash FastMCP server
+python server.py
+```
+
+```bash ngrok
+ngrok http 8000
+```
+
+
+
+This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks.
+
+
+### Connect to ChatGPT
+
+Replace `https://your-server-url.com` with the actual URL of your server (such as your ngrok URL).
+
+1. Open ChatGPT and go to **Settings** ā **Connectors**
+2. Click **Add custom connector**
+3. Enter your server details:
+ - **Name**: Library Catalog
+ - **URL**: Your server URL (e.g., `https://abc123.ngrok.io`)
+ - **Description**: A library catalog for searching and retrieving books
+
+#### Test the Connection
+
+1. Start a new chat in ChatGPT
+2. Click **Tools** ā **Run deep research**
+3. Select your **Library Catalog** connector as a source
+4. Ask questions like:
+ - "Search for Python programming books"
+ - "Find books about AI and machine learning"
+ - "Show me books by the Python Software Foundation"
+
+ChatGPT will use your server's search and fetch tools to find relevant information and cite the sources in its response.
+
+### Troubleshooting
+
+#### "This MCP server doesn't implement our specification"
+
+
+If you get this error, it most likely means that your server doesn't implement the required tools (`search` and `fetch`). To correct it, ensure that your server meets the service requirements.
\ No newline at end of file
diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..ad99f5c387f43e7b014047fcc375ab2a69d99163
--- /dev/null
+++ b/docs/integrations/claude-code.mdx
@@ -0,0 +1,60 @@
+---
+title: Claude Code + FastMCP
+sidebarTitle: Claude Code
+description: Connect FastMCP servers to Claude Code
+icon: message-smile
+tag: NEW
+---
+
+Claude Code supports MCP servers through multiple transport methods, allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers.
+
+
+Claude Code supports both local and remote MCP servers with flexible configuration options. See the [Claude Code MCP documentation](https://docs.anthropic.com/en/docs/claude-code/mcp) for other transport methods.
+
+
+
+Claude Code provides built-in MCP management commands to easily add, configure, and authenticate your FastMCP servers.
+
+
+## Create a Server
+
+You can create FastMCP servers using STDIO transport, remote HTTP servers, or local HTTP servers. This example shows one common approach: running an HTTP server locally for development.
+
+```python server.py
+import random
+from fastmcp import FastMCP
+
+mcp = FastMCP(name="Dice Roller")
+
+@mcp.tool
+def roll_dice(n_dice: int) -> list[int]:
+ """Roll `n_dice` 6-sided dice and return the results."""
+ return [random.randint(1, 6) for _ in range(n_dice)]
+
+if __name__ == "__main__":
+ mcp.run(transport="http", port=8000)
+```
+
+## Connect to Claude Code
+
+Start your server and add it to Claude Code:
+
+```bash
+# Start your server first
+python server.py
+```
+
+Then add it to Claude Code:
+```bash
+claude mcp add dice --transport http http://localhost:8000/mcp/
+```
+
+## Using Your Server
+
+Once connected, Claude Code will automatically discover and use your server's tools when relevant:
+
+```
+Roll some dice for me
+```
+
+Claude will call your `roll_dice` tool and provide the results. If your server provides resources, you can reference them with `@` mentions like `@dice:file://path/to/resource`.
\ No newline at end of file
diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx
index 9c4729dbe5546e4ebab543f6ad5f92ea8ca9e6ab..4c4faba1e3cf8c37ea445b12c285ffe63d7bb91e 100644
--- a/docs/integrations/claude-desktop.mdx
+++ b/docs/integrations/claude-desktop.mdx
@@ -2,11 +2,15 @@
title: Claude Desktop + FastMCP
sidebarTitle: Claude Desktop
description: Call FastMCP servers from Claude Desktop
-icon: desktop
+icon: message-smile
---
-Claude Desktop supports MCP servers through local STDIO connections, allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers.
+Claude Desktop supports MCP servers through local STDIO connections and remote servers (beta), allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers.
+
+
+Remote MCP server support is currently in beta and available for users on Claude Pro, Max, Team, and Enterprise plans (as of June 2025). Most users will still need to use local STDIO connections.
+
This guide focuses specifically on using FastMCP servers with Claude Desktop. For general Claude Desktop MCP setup and official examples, see the [official Claude Desktop quickstart guide](https://modelcontextprotocol.io/quickstart/user).
@@ -15,10 +19,10 @@ This guide focuses specifically on using FastMCP servers with Claude Desktop. Fo
## Requirements
-Claude Desktop requires MCP servers to run locally using STDIO transport. This means your server will communicate with Claude through standard input/output rather than HTTP.
+Claude Desktop traditionally requires MCP servers to run locally using STDIO transport, where your server communicates with Claude through standard input/output rather than HTTP. However, users on certain plans now have access to remote server support as well.
-If you need to connect to remote servers, you can create a **proxy server** that runs locally via STDIO and forwards requests to remote HTTP servers. See the [Proxy Servers](#proxy-servers) section below.
+If you don't have access to remote server support or need to connect to remote servers, you can create a **proxy server** that runs locally via STDIO and forwards requests to remote HTTP servers. See the [Proxy Servers](#proxy-servers) section below.
## Create a Server
@@ -181,7 +185,7 @@ Claude Desktop runs servers in a completely isolated environment with no access
## Remote Servers
-Claude Desktop only supports local STDIO servers, but FastMCP can create a proxy server that forwards requests to a remote HTTP server. You can install the proxy server in Claude Desktop.
+Users on Claude Pro, Max, Team, and Enterprise plans have first-class remote server support via integrations. For other users, or as an alternative approach, FastMCP can create a proxy server that forwards requests to a remote HTTP server. You can install the proxy server in Claude Desktop.
Create a proxy server that connects to a remote HTTP server:
diff --git a/docs/integrations/gemini.mdx b/docs/integrations/gemini.mdx
index ab9e68ce0087e330c7ceffe9c8ba0aaebaecb07c..359ef4e0a1eb6fb84a760d3ab3d37776de2628f7 100644
--- a/docs/integrations/gemini.mdx
+++ b/docs/integrations/gemini.mdx
@@ -99,7 +99,7 @@ from fastmcp import Client
from fastmcp.client.auth import BearerAuth
mcp_client = Client(
- "https://my-server.com/sse",
+ "https://my-server.com/mcp/",
auth=BearerAuth(""),
)
```
diff --git a/docs/integrations/openai.mdx b/docs/integrations/openai.mdx
index ba0d00941f9852ce6699f7876f768bc67abce5da..2d1940b9b8a29e2cb140246b59134a5d6439a3b5 100644
--- a/docs/integrations/openai.mdx
+++ b/docs/integrations/openai.mdx
@@ -38,7 +38,7 @@ def roll_dice(n_dice: int) -> list[int]:
return [random.randint(1, 6) for _ in range(n_dice)]
if __name__ == "__main__":
- mcp.run(transport="sse", port=8000)
+ mcp.run(transport="http", port=8000)
```
### Deploy the Server
@@ -77,7 +77,7 @@ You'll also need to authenticate with OpenAI. You can do this by setting the `OP
export OPENAI_API_KEY="your-api-key"
```
-Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/sse` as the endpoint because we deployed an SSE server with the default path; you may need to use a different endpoint if you customized your server's deployment.
+Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment.
```python {4, 11-16}
from openai import OpenAI
@@ -93,7 +93,7 @@ resp = client.responses.create(
{
"type": "mcp",
"server_label": "dice_server",
- "server_url": f"{url}/sse",
+ "server_url": f"{url}/mcp/",
"require_approval": "never",
},
],
@@ -172,7 +172,7 @@ def roll_dice(n_dice: int) -> list[int]:
if __name__ == "__main__":
print(f"\n---\n\nš Dice Roller access token:\n\n{access_token}\n\n---\n")
- mcp.run(transport="sse", port=8000)
+ mcp.run(transport="http", port=8000)
```
#### Client Authentication
@@ -212,7 +212,7 @@ resp = client.responses.create(
{
"type": "mcp",
"server_label": "dice_server",
- "server_url": f"{url}/sse",
+ "server_url": f"{url}/mcp/",
"require_approval": "never",
"headers": {
"Authorization": f"Bearer {access_token}"
diff --git a/docs/patterns/cli.mdx b/docs/patterns/cli.mdx
index 84654975bdb905fc1e81519788662fb135cb2263..9c01d133df534229120307ba42b7fff660fcbc6c 100644
--- a/docs/patterns/cli.mdx
+++ b/docs/patterns/cli.mdx
@@ -42,11 +42,12 @@ This command runs the server directly in your current Python environment. You ar
| Option | Flag | Description |
| ------ | ---- | ----------- |
-| Transport | `--transport`, `-t` | Transport protocol to use (`stdio`, `streamable-http`, or `sse`) |
+| Transport | `--transport`, `-t` | Transport protocol to use (`stdio`, `http`, or `sse`) |
| Host | `--host` | Host to bind to when using http transport (default: 127.0.0.1) |
| Port | `--port`, `-p` | Port to bind to when using http transport (default: 8000) |
| Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
+
#### Server Specification
@@ -79,14 +80,14 @@ if __name__ == "__main__":
You can run it with Streamable HTTP transport regardless of what's in the `__main__` block:
```bash
-fastmcp run server.py --transport streamable-http --port 8000
+fastmcp run server.py --transport http --port 8000
```
**Examples**
```bash
# Run a local server with Streamable HTTP transport on a custom port
-fastmcp run server.py --transport streamable-http --port 8000
+fastmcp run server.py --transport http --port 8000
# Connect to a remote server and proxy as a stdio server
fastmcp run https://example.com/mcp-server
@@ -112,14 +113,14 @@ The `dev` command is a shortcut for testing a server over STDIO only. When the I
1. Select "STDIO" from the transport dropdown
2. Connect manually
-This command does not support HTTP testing. To test a server over HTTP:
-1. Start your server manually with HTTP transport using either:
+This command does not support HTTP testing. To test a server over Streamable HTTP or SSE:
+1. Start your server manually with the appropriate transport using either the command line:
```bash
- fastmcp run server.py --transport streamable-http
+ fastmcp run server.py --transport http
```
- or
+ or by setting the transport in your code:
```bash
- python server.py # Assuming your __main__ block sets HTTP transport
+ python server.py # Assuming your __main__ block sets Streamable HTTP transport
```
2. Open the MCP Inspector separately and connect to your running server
diff --git a/docs/python-sdk/fastmcp-cli-claude.mdx b/docs/python-sdk/fastmcp-cli-claude.mdx
index 6ea44b33e86f3ec2ccbc4cead82aa3fcc6c8dcb1..b56b633383d7c1d0ccb1048d2dfdd6db0f8c3e69 100644
--- a/docs/python-sdk/fastmcp-cli-claude.mdx
+++ b/docs/python-sdk/fastmcp-cli-claude.mdx
@@ -10,7 +10,7 @@ Claude app integration utilities.
## Functions
-### `get_claude_config_path`
+### `get_claude_config_path`
```python
get_claude_config_path() -> Path | None
@@ -20,7 +20,7 @@ get_claude_config_path() -> Path | None
Get the Claude config directory based on platform.
-### `update_claude_config`
+### `update_claude_config`
```python
update_claude_config(file_spec: str, server_name: str) -> bool
diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx
index 1ebb968b2c1f582db04b7e5b11b53d1d5e183dc1..3ab68da9a4427caa94d56574806ef67f8444725c 100644
--- a/docs/python-sdk/fastmcp-cli-cli.mdx
+++ b/docs/python-sdk/fastmcp-cli-cli.mdx
@@ -10,13 +10,13 @@ FastMCP CLI tools.
## Functions
-### `version`
+### `version`
```python
version(ctx: Context)
```
-### `dev`
+### `dev`
```python
dev(server_spec: str = typer.Argument(..., help='Python file to run, optionally with :object suffix'), with_editable: Annotated[Path | None, typer.Option('--with-editable', '-e', help='Directory containing pyproject.toml to install in editable mode', exists=True, file_okay=False, resolve_path=True)] = None, with_packages: Annotated[list[str], typer.Option('--with', help='Additional packages to install')] = [], inspector_version: Annotated[str | None, typer.Option('--inspector-version', help='Version of the MCP Inspector to use')] = None, ui_port: Annotated[int | None, typer.Option('--ui-port', help='Port for the MCP Inspector UI')] = None, server_port: Annotated[int | None, typer.Option('--server-port', help='Port for the MCP Inspector Proxy server')] = None) -> None
@@ -26,10 +26,10 @@ dev(server_spec: str = typer.Argument(..., help='Python file to run, optionally
Run a MCP server with the MCP Inspector.
-### `run`
+### `run`
```python
-run(ctx: typer.Context, server_spec: str = typer.Argument(..., help='Python file, object specification (file:obj), or URL'), transport: Annotated[str | None, typer.Option('--transport', '-t', help='Transport protocol to use (stdio, streamable-http, or sse)')] = None, host: Annotated[str | None, typer.Option('--host', help='Host to bind to when using http transport (default: 127.0.0.1)')] = None, port: Annotated[int | None, typer.Option('--port', '-p', help='Port to bind to when using http transport (default: 8000)')] = None, log_level: Annotated[str | None, typer.Option('--log-level', '-l', help='Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)')] = None) -> None
+run(ctx: typer.Context, server_spec: str = typer.Argument(..., help='Python file, object specification (file:obj), or URL'), transport: Annotated[str | None, typer.Option('--transport', '-t', help='Transport protocol to use (stdio, http, or sse)')] = None, host: Annotated[str | None, typer.Option('--host', help='Host to bind to when using http transport (default: 127.0.0.1)')] = None, port: Annotated[int | None, typer.Option('--port', '-p', help='Port to bind to when using http transport (default: 8000)')] = None, log_level: Annotated[str | None, typer.Option('--log-level', '-l', help='Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)')] = None) -> None
```
@@ -51,7 +51,7 @@ Server arguments can be passed after -- :
fastmcp run server.py -- --config config.json --debug
-### `install`
+### `install`
```python
install(server_spec: str = typer.Argument(..., help='Python file to run, optionally with :object suffix'), server_name: Annotated[str | None, typer.Option('--name', '-n', help="Custom name for the server (defaults to server's name attribute or file name)")] = None, with_editable: Annotated[Path | None, typer.Option('--with-editable', '-e', help='Directory containing pyproject.toml to install in editable mode', exists=True, file_okay=False, resolve_path=True)] = None, with_packages: Annotated[list[str], typer.Option('--with', help='Additional packages to install')] = [], env_vars: Annotated[list[str], typer.Option('--env-var', '-v', help='Environment variables in KEY=VALUE format')] = [], env_file: Annotated[Path | None, typer.Option('--env-file', '-f', help='Load environment variables from a .env file', exists=True, file_okay=True, dir_okay=False, resolve_path=True)] = None) -> None
@@ -63,3 +63,25 @@ Install a MCP server in the Claude desktop app.
Environment variables are preserved once added and only updated if new values
are explicitly provided.
+
+### `inspect`
+
+```python
+inspect(server_spec: str = typer.Argument(..., help='Python file to inspect, optionally with :object suffix'), output: Annotated[Path, typer.Option('--output', '-o', help='Output file path for the JSON report (default: server-info.json)')] = Path('server-info.json')) -> None
+```
+
+
+Inspect a FastMCP server and generate a JSON report.
+
+This command analyzes a FastMCP server (v1.x or v2.x) and generates
+a comprehensive JSON report containing information about the server's
+name, instructions, version, tools, prompts, resources, templates,
+and capabilities.
+
+**Examples:**
+
+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
+
diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx
index 7505c7fb44c474f1b85cb0f78f852c1795032a3d..78adc9056a069cc3b989ba05e66a0d70f863ccf5 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.
## Functions
-### `is_url`
+### `is_url`
```python
is_url(path: str) -> bool
@@ -20,7 +20,7 @@ is_url(path: str) -> bool
Check if a string is a URL.
-### `parse_file_path`
+### `parse_file_path`
```python
parse_file_path(server_spec: str) -> tuple[Path, str | None]
@@ -36,7 +36,7 @@ Parse a file path that may include a server object specification.
- Tuple of (file_path, server_object)
-### `import_server`
+### `import_server`
```python
import_server(file: Path, server_object: str | None = None) -> Any
@@ -53,7 +53,7 @@ Import a MCP server from a file.
- The server object
-### `create_client_server`
+### `create_client_server`
```python
create_client_server(url: str) -> Any
@@ -69,7 +69,7 @@ Create a FastMCP server from a client URL.
- A FastMCP server instance
-### `import_server_with_args`
+### `import_server_with_args`
```python
import_server_with_args(file: Path, server_object: str | None = None, server_args: list[str] | None = None) -> Any
@@ -87,7 +87,7 @@ Import a server with optional command line arguments.
- The imported server object
-### `run_command`
+### `run_command`
```python
run_command(server_spec: str, transport: str | None = None, host: str | None = None, port: int | None = None, log_level: str | None = None, server_args: list[str] | None = None) -> None
diff --git a/docs/python-sdk/fastmcp-client-auth-bearer.mdx b/docs/python-sdk/fastmcp-client-auth-bearer.mdx
index ab0c15240f74fccafdedd536a94f08a2b73ad1d2..c83e354b54fce14a7db1ab3cd854a88682cd574a 100644
--- a/docs/python-sdk/fastmcp-client-auth-bearer.mdx
+++ b/docs/python-sdk/fastmcp-client-auth-bearer.mdx
@@ -7,11 +7,11 @@ sidebarTitle: bearer
## Classes
-### `BearerAuth`
+### `BearerAuth`
**Methods:**
-#### `auth_flow`
+#### `auth_flow`
```python
auth_flow(self, request)
diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx
index f10afba36470bf8bf698e1869c6147a56e87fd6b..19ad489e995c66d3d03df993dcad54771cdd9831 100644
--- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx
+++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx
@@ -7,13 +7,13 @@ sidebarTitle: oauth
## Functions
-### `default_cache_dir`
+### `default_cache_dir`
```python
default_cache_dir() -> Path
```
-### `OAuth`
+### `OAuth`
```python
OAuth(mcp_url: str, scopes: str | list[str] | None = None, client_name: str = 'FastMCP Client', token_storage_cache_dir: Path | None = None, additional_client_metadata: dict[str, Any] | None = None) -> _MCPOAuthClientProvider
@@ -38,7 +38,7 @@ httpx.AsyncClient (or appropriate FastMCP client/transport instance)
## Classes
-### `ServerOAuthMetadata`
+### `ServerOAuthMetadata`
More flexible OAuth metadata model that accepts broader ranges of values
@@ -48,13 +48,13 @@ This handles real-world OAuth servers like PayPal that may support
additional methods not in the MCP specification.
-### `OAuthClientProvider`
+### `OAuthClientProvider`
OAuth client provider with more flexible OAuth metadata discovery.
-### `FileTokenStorage`
+### `FileTokenStorage`
File-based token storage implementation for OAuth credentials and tokens.
@@ -65,7 +65,7 @@ Each instance is tied to a specific server URL for proper token isolation.
**Methods:**
-#### `get_base_url`
+#### `get_base_url`
```python
get_base_url(url: str) -> str
@@ -74,7 +74,7 @@ get_base_url(url: str) -> str
Extract the base URL (scheme + host) from a URL.
-#### `get_cache_key`
+#### `get_cache_key`
```python
get_cache_key(self) -> str
@@ -83,7 +83,7 @@ get_cache_key(self) -> str
Generate a safe filesystem key from the server's base URL.
-#### `clear`
+#### `clear`
```python
clear(self) -> None
@@ -92,7 +92,7 @@ clear(self) -> None
Clear all cached data for this server.
-#### `clear_all`
+#### `clear_all`
```python
clear_all(cls, cache_dir: Path | None = None) -> None
diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx
index 4c3f252bf11598535f6a4f4463618ad3175ecb5d..3b99527e77b861f18e0c91c82fe465da199c486c 100644
--- a/docs/python-sdk/fastmcp-client-client.mdx
+++ b/docs/python-sdk/fastmcp-client-client.mdx
@@ -7,48 +7,48 @@ sidebarTitle: client
## Classes
-### `Client`
-
-
-
- MCP client that delegates connection management to a Transport instance.
-
- The Client class is responsible for MCP protocol logic, while the Transport
- handles connection establishment and management. Client provides methods for
- working with resources, prompts, tools and other MCP capabilities.
-
- Args:
- transport: Connection source specification, which can be:
- - ClientTransport: Direct transport instance
- - FastMCP: In-process FastMCP server
- - AnyUrl | str: URL to connect to
- - Path: File path for local socket
- - MCPConfig: MCP server configuration
- - dict: Transport configuration
- roots: Optional RootsList or RootsHandler for filesystem access
- sampling_handler: Optional handler for sampling requests
- log_handler: Optional handler for log messages
- message_handler: Optional handler for protocol messages
- progress_handler: Optional handler for progress notifications
- timeout: Optional timeout for requests (seconds or timedelta)
- init_timeout: Optional timeout for initial connection (seconds or timedelta).
- Set to 0 to disable. If None, uses the value in the FastMCP global settings.
-
- Examples:
- ```python # Connect to FastMCP server client =
- Client("http://localhost:8080")
-
- async with client:
- # List available resources resources = await client.list_resources()
+### `Client`
+
+
+MCP client that delegates connection management to a Transport instance.
+
+The Client class is responsible for MCP protocol logic, while the Transport
+handles connection establishment and management. Client provides methods for
+working with resources, prompts, tools and other MCP capabilities.
+
+**Args:**
+- `transport`: Connection source specification, which can be\:
+- ClientTransport\: Direct transport instance
+- FastMCP\: In-process FastMCP server
+- AnyUrl | str\: URL to connect to
+- Path\: File path for local socket
+- MCPConfig\: MCP server configuration
+- dict\: Transport configuration
+- `roots`: Optional RootsList or RootsHandler for filesystem access
+- `sampling_handler`: Optional handler for sampling requests
+- `log_handler`: Optional handler for log messages
+- `message_handler`: Optional handler for protocol messages
+- `progress_handler`: Optional handler for progress notifications
+- `timeout`: Optional timeout for requests (seconds or timedelta)
+- `init_timeout`: Optional timeout for initial connection (seconds or timedelta).
+Set to 0 to disable. If None, uses the value in the FastMCP global settings.
+
+**Examples:**
+
+```python # Connect to FastMCP server client =
+Client("http://localhost:8080")
+
+async with client:
+ # List available resources resources = await client.list_resources()
+
+ # Call a tool result = await client.call_tool("my_tool", {"param":
+ "value"})
+```
- # Call a tool result = await client.call_tool("my_tool", {"param":
- "value"})
- ```
-
**Methods:**
-#### `session`
+#### `session`
```python
session(self) -> ClientSession
@@ -57,7 +57,7 @@ session(self) -> ClientSession
Get the current active session. Raises RuntimeError if not connected.
-#### `initialize_result`
+#### `initialize_result`
```python
initialize_result(self) -> mcp.types.InitializeResult
@@ -66,7 +66,7 @@ initialize_result(self) -> mcp.types.InitializeResult
Get the result of the initialization request.
-#### `set_roots`
+#### `set_roots`
```python
set_roots(self, roots: RootsList | RootsHandler) -> None
@@ -75,7 +75,7 @@ set_roots(self, roots: RootsList | RootsHandler) -> None
Set the roots for the client. This does not automatically call `send_roots_list_changed`.
-#### `set_sampling_callback`
+#### `set_sampling_callback`
```python
set_sampling_callback(self, sampling_callback: SamplingHandler) -> None
@@ -84,7 +84,7 @@ set_sampling_callback(self, sampling_callback: SamplingHandler) -> None
Set the sampling callback for the client.
-#### `is_connected`
+#### `is_connected`
```python
is_connected(self) -> bool
diff --git a/docs/python-sdk/fastmcp-client-logging.mdx b/docs/python-sdk/fastmcp-client-logging.mdx
index 84d201db77485e40d338955685b17e7fafbd4e18..83da895c3b70914a24675b92f8a715c3e9b0b9c4 100644
--- a/docs/python-sdk/fastmcp-client-logging.mdx
+++ b/docs/python-sdk/fastmcp-client-logging.mdx
@@ -7,7 +7,7 @@ sidebarTitle: logging
## Functions
-### `create_log_callback`
+### `create_log_callback`
```python
create_log_callback(handler: LogHandler | None = None) -> LoggingFnT
diff --git a/docs/python-sdk/fastmcp-client-oauth_callback.mdx b/docs/python-sdk/fastmcp-client-oauth_callback.mdx
index 6eab9de3a71fc0503f8a1cd7776156dd53e3550f..e251c5ac4603ec9d4523f48a21a5e06f4bfc756f 100644
--- a/docs/python-sdk/fastmcp-client-oauth_callback.mdx
+++ b/docs/python-sdk/fastmcp-client-oauth_callback.mdx
@@ -15,7 +15,7 @@ and display styled responses to users.
## Functions
-### `create_callback_html`
+### `create_callback_html`
```python
create_callback_html(message: str, is_success: bool = True, title: str = 'FastMCP OAuth', server_url: str | None = None) -> str
@@ -25,7 +25,7 @@ create_callback_html(message: str, is_success: bool = True, title: str = 'FastMC
Create a styled HTML response for OAuth callbacks.
-### `create_oauth_callback_server`
+### `create_oauth_callback_server`
```python
create_oauth_callback_server(port: int, callback_path: str = '/callback', server_url: str | None = None, response_future: asyncio.Future | None = None) -> Server
@@ -46,17 +46,17 @@ Create an OAuth callback server.
## Classes
-### `CallbackResponse`
+### `CallbackResponse`
**Methods:**
-#### `from_dict`
+#### `from_dict`
```python
from_dict(cls, data: dict[str, str]) -> CallbackResponse
```
-#### `to_dict`
+#### `to_dict`
```python
to_dict(self) -> dict[str, str]
diff --git a/docs/python-sdk/fastmcp-client-roots.mdx b/docs/python-sdk/fastmcp-client-roots.mdx
index 820e1d0a7556418d0a2f8e5905d0d7423d5af7b3..a081bc2fa4245e5607c944a4e46123f4343757aa 100644
--- a/docs/python-sdk/fastmcp-client-roots.mdx
+++ b/docs/python-sdk/fastmcp-client-roots.mdx
@@ -7,13 +7,13 @@ sidebarTitle: roots
## Functions
-### `convert_roots_list`
+### `convert_roots_list`
```python
convert_roots_list(roots: RootsList) -> list[mcp.types.Root]
```
-### `create_roots_callback`
+### `create_roots_callback`
```python
create_roots_callback(handler: RootsList | RootsHandler) -> ListRootsFnT
diff --git a/docs/python-sdk/fastmcp-client-sampling.mdx b/docs/python-sdk/fastmcp-client-sampling.mdx
index be78badebe6d700f266704ab71ae3cebd99f84f8..53d3893de278135d0baa25661bd1023b8268a1be 100644
--- a/docs/python-sdk/fastmcp-client-sampling.mdx
+++ b/docs/python-sdk/fastmcp-client-sampling.mdx
@@ -7,7 +7,7 @@ sidebarTitle: sampling
## Functions
-### `create_sampling_callback`
+### `create_sampling_callback`
```python
create_sampling_callback(sampling_handler: SamplingHandler) -> SamplingFnT
diff --git a/docs/python-sdk/fastmcp-client-transports.mdx b/docs/python-sdk/fastmcp-client-transports.mdx
index a4f9d22e64185c36da46f2865e77dbe58f378a8c..adbab20ee7ec0559fc8f44af5106b82ea18464ef 100644
--- a/docs/python-sdk/fastmcp-client-transports.mdx
+++ b/docs/python-sdk/fastmcp-client-transports.mdx
@@ -7,63 +7,63 @@ sidebarTitle: transports
## Functions
-### `infer_transport`
+### `infer_transport`
```python
infer_transport(transport: ClientTransport | FastMCP | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str) -> ClientTransport
```
+Infer the appropriate transport type from the given transport argument.
- Infer the appropriate transport type from the given transport argument.
+This function attempts to infer the correct transport type from the provided
+argument, handling various input types and converting them to the appropriate
+ClientTransport subclass.
- This function attempts to infer the correct transport type from the provided
- argument, handling various input types and converting them to the appropriate
- ClientTransport subclass.
+The function supports these input types:
+- ClientTransport: Used directly without modification
+- FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
+- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
+- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
+- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
- The function supports these input types:
- - ClientTransport: Used directly without modification
- - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
- - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
- - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
- - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
+For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
- For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
+For MCPConfig with multiple servers, a composite client is created where each server
+is mounted with its name as prefix. This allows accessing tools and resources from multiple
+servers through a single unified client interface, using naming patterns like
+`servername_toolname` for tools and `protocol://servername/path` for resources.
+If the MCPConfig contains only one server, a direct connection is established without prefixing.
- For MCPConfig with multiple servers, a composite client is created where each server
- is mounted with its name as prefix. This allows accessing tools and resources from multiple
- servers through a single unified client interface, using naming patterns like
- `servername_toolname` for tools and `protocol://servername/path` for resources.
- If the MCPConfig contains only one server, a direct connection is established without prefixing.
+**Examples:**
- Examples:
- ```python
- # Connect to a local Python script
- transport = infer_transport("my_script.py")
-
- # Connect to a remote server via HTTP
- transport = infer_transport("http://example.com/mcp")
+```python
+# Connect to a local Python script
+transport = infer_transport("my_script.py")
+
+# Connect to a remote server via HTTP
+transport = infer_transport("http://example.com/mcp")
+
+# Connect to multiple servers using MCPConfig
+config = {
+ "mcpServers": {
+ "weather": {"url": "http://weather.example.com/mcp"},
+ "calendar": {"url": "http://calendar.example.com/mcp"}
+ }
+}
+transport = infer_transport(config)
+```
- # Connect to multiple servers using MCPConfig
- config = {
- "mcpServers": {
- "weather": {"url": "http://weather.example.com/mcp"},
- "calendar": {"url": "http://calendar.example.com/mcp"}
- }
- }
- transport = infer_transport(config)
- ```
-
## Classes
-### `SessionKwargs`
+### `SessionKwargs`
Keyword arguments for the MCP ClientSession constructor.
-### `ClientTransport`
+### `ClientTransport`
Abstract base class for different MCP client transport mechanisms.
@@ -72,25 +72,25 @@ A Transport is responsible for establishing and managing connections
to an MCP server, and providing a ClientSession within an async context.
-### `WSTransport`
+### `WSTransport`
Transport implementation that connects to an MCP server via WebSockets.
-### `SSETransport`
+### `SSETransport`
Transport implementation that connects to an MCP server via Server-Sent Events.
-### `StreamableHttpTransport`
+### `StreamableHttpTransport`
Transport implementation that connects to an MCP server via Streamable HTTP Requests.
-### `StdioTransport`
+### `StdioTransport`
Base transport for connecting to an MCP server via subprocess with stdio.
@@ -99,37 +99,37 @@ This is a base class that can be subclassed for specific command-based
transports like Python, Node, Uvx, etc.
-### `PythonStdioTransport`
+### `PythonStdioTransport`
Transport for running Python scripts.
-### `FastMCPStdioTransport`
+### `FastMCPStdioTransport`
Transport for running FastMCP servers using the FastMCP CLI.
-### `NodeStdioTransport`
+### `NodeStdioTransport`
Transport for running Node.js scripts.
-### `UvxStdioTransport`
+### `UvxStdioTransport`
Transport for running commands via the uvx tool.
-### `NpxStdioTransport`
+### `NpxStdioTransport`
Transport for running commands via the npx tool.
-### `FastMCPTransport`
+### `FastMCPTransport`
In-memory transport for FastMCP servers.
@@ -140,52 +140,53 @@ servers from the low-level MCP SDK. This is particularly useful for unit
tests or scenarios where client and server run in the same runtime.
-### `MCPConfigTransport`
+### `MCPConfigTransport`
Transport for connecting to one or more MCP servers defined in an MCPConfig.
- This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
- object or dictionary matching the MCPConfig schema. It supports two key scenarios:
-
- 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
- 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
- all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
-
- In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
- and resources with the pattern `protocol://{server_name}/path/to/resource`.
-
- This is particularly useful for creating clients that need to interact with multiple specialized
- MCP servers through a single interface, simplifying client code.
-
- Examples:
- ```python
- from fastmcp import Client
- from fastmcp.utilities.mcp_config import MCPConfig
-
- # Create a config with multiple servers
- config = {
- "mcpServers": {
- "weather": {
- "url": "https://weather-api.example.com/mcp",
- "transport": "streamable-http"
- },
- "calendar": {
- "url": "https://calendar-api.example.com/mcp",
- "transport": "streamable-http"
- }
- }
+This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
+object or dictionary matching the MCPConfig schema. It supports two key scenarios:
+
+1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
+2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
+ all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
+
+In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
+and resources with the pattern `protocol://{server_name}/path/to/resource`.
+
+This is particularly useful for creating clients that need to interact with multiple specialized
+MCP servers through a single interface, simplifying client code.
+
+**Examples:**
+
+```python
+from fastmcp import Client
+from fastmcp.utilities.mcp_config import MCPConfig
+
+# Create a config with multiple servers
+config = {
+ "mcpServers": {
+ "weather": {
+ "url": "https://weather-api.example.com/mcp",
+ "transport": "http"
+ },
+ "calendar": {
+ "url": "https://calendar-api.example.com/mcp",
+ "transport": "http"
}
+ }
+}
+
+# Create a client with the config
+client = Client(config)
- # Create a client with the config
- client = Client(config)
+async with client:
+ # Access tools with prefixes
+ weather = await client.call_tool("weather_get_forecast", {"city": "London"})
+ events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
- async with client:
- # Access tools with prefixes
- weather = await client.call_tool("weather_get_forecast", {"city": "London"})
- events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
+ # Access resources with prefixed URIs
+ icons = await client.read_resource("weather://weather/icons/sunny")
+```
- # Access resources with prefixed URIs
- icons = await client.read_resource("weather://weather/icons/sunny")
- ```
-
diff --git a/docs/python-sdk/fastmcp-exceptions.mdx b/docs/python-sdk/fastmcp-exceptions.mdx
index 9726d1cde6fd3473e2c6faf2abf3c3faadc22982..6b54286b557e33a64326004b0e224fd4bda435fa 100644
--- a/docs/python-sdk/fastmcp-exceptions.mdx
+++ b/docs/python-sdk/fastmcp-exceptions.mdx
@@ -10,55 +10,55 @@ Custom exceptions for FastMCP.
## Classes
-### `FastMCPError`
+### `FastMCPError`
Base error for FastMCP.
-### `ValidationError`
+### `ValidationError`
Error in validating parameters or return values.
-### `ResourceError`
+### `ResourceError`
Error in resource operations.
-### `ToolError`
+### `ToolError`
Error in tool operations.
-### `PromptError`
+### `PromptError`
Error in prompt operations.
-### `InvalidSignature`
+### `InvalidSignature`
Invalid signature for use with FastMCP.
-### `ClientError`
+### `ClientError`
Error in client operations.
-### `NotFoundError`
+### `NotFoundError`
Object not found.
-### `DisabledError`
+### `DisabledError`
Object is disabled.
diff --git a/docs/python-sdk/fastmcp-prompts-prompt.mdx b/docs/python-sdk/fastmcp-prompts-prompt.mdx
index 60028f3167441742d513f4228cb252cebd9d5e0a..726962933d8b5c81263bc9d1c11c45b570044cb8 100644
--- a/docs/python-sdk/fastmcp-prompts-prompt.mdx
+++ b/docs/python-sdk/fastmcp-prompts-prompt.mdx
@@ -10,7 +10,7 @@ Base classes for FastMCP prompts.
## Functions
-### `Message`
+### `Message`
```python
Message(content: str | MCPContent, role: Role | None = None, **kwargs: Any) -> PromptMessage
@@ -22,13 +22,13 @@ A user-friendly constructor for PromptMessage.
## Classes
-### `PromptArgument`
+### `PromptArgument`
An argument that can be passed to a prompt.
-### `Prompt`
+### `Prompt`
A prompt template that can be rendered with parameters.
@@ -36,7 +36,7 @@ A prompt template that can be rendered with parameters.
**Methods:**
-#### `to_mcp_prompt`
+#### `to_mcp_prompt`
```python
to_mcp_prompt(self, **overrides: Any) -> MCPPrompt
@@ -45,7 +45,7 @@ to_mcp_prompt(self, **overrides: Any) -> MCPPrompt
Convert the prompt to an MCP prompt.
-#### `from_function`
+#### `from_function`
```python
from_function(fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None) -> FunctionPrompt
@@ -60,7 +60,7 @@ The function can return:
- A sequence of any of the above
-### `FunctionPrompt`
+### `FunctionPrompt`
A prompt that is a function.
@@ -68,7 +68,7 @@ A prompt that is a function.
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None) -> FunctionPrompt
diff --git a/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx b/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx
index 041337c28f7ca01e31bfe8fc906d1a5dee1f00ec..2ba84f742c5069314ef10ad83f2dbc6e25c54cdb 100644
--- a/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx
+++ b/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx
@@ -7,7 +7,7 @@ sidebarTitle: prompt_manager
## Classes
-### `PromptManager`
+### `PromptManager`
Manages FastMCP prompts.
@@ -15,7 +15,7 @@ Manages FastMCP prompts.
**Methods:**
-#### `mount`
+#### `mount`
```python
mount(self, server: MountedServer) -> None
@@ -24,7 +24,7 @@ mount(self, server: MountedServer) -> None
Adds a mounted server as a source for prompts.
-#### `add_prompt_from_fn`
+#### `add_prompt_from_fn`
```python
add_prompt_from_fn(self, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, description: str | None = None, tags: set[str] | None = None) -> FunctionPrompt
@@ -33,7 +33,7 @@ add_prompt_from_fn(self, fn: Callable[..., PromptResult | Awaitable[PromptResult
Create a prompt from a function.
-#### `add_prompt`
+#### `add_prompt`
```python
add_prompt(self, prompt: Prompt) -> Prompt
diff --git a/docs/python-sdk/fastmcp-resources-resource.mdx b/docs/python-sdk/fastmcp-resources-resource.mdx
index dcfc51f002db8383ea46d65986aff270034210c8..ac6c401399f85a5aa4235a547a02d5fb8f32e683 100644
--- a/docs/python-sdk/fastmcp-resources-resource.mdx
+++ b/docs/python-sdk/fastmcp-resources-resource.mdx
@@ -10,7 +10,7 @@ Base classes and interfaces for FastMCP resources.
## Classes
-### `Resource`
+### `Resource`
Base class for all resources.
@@ -18,13 +18,13 @@ Base class for all resources.
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(fn: Callable[[], Any], uri: str | AnyUrl, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None) -> FunctionResource
```
-#### `set_default_mime_type`
+#### `set_default_mime_type`
```python
set_default_mime_type(cls, mime_type: str | None) -> str
@@ -33,7 +33,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str
Set default MIME type if not provided.
-#### `set_default_name`
+#### `set_default_name`
```python
set_default_name(self) -> Self
@@ -42,7 +42,7 @@ set_default_name(self) -> Self
Set default name from URI if not provided.
-#### `to_mcp_resource`
+#### `to_mcp_resource`
```python
to_mcp_resource(self, **overrides: Any) -> MCPResource
@@ -51,7 +51,7 @@ to_mcp_resource(self, **overrides: Any) -> MCPResource
Convert the resource to an MCPResource.
-#### `key`
+#### `key`
```python
key(self) -> str
@@ -63,7 +63,7 @@ keys having a certain value, as the same tool loaded from different
hierarchies of servers may have different keys.
-### `FunctionResource`
+### `FunctionResource`
A resource that defers data loading by wrapping a function.
@@ -80,7 +80,7 @@ The function can return:
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[[], Any], uri: str | AnyUrl, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None) -> FunctionResource
diff --git a/docs/python-sdk/fastmcp-resources-resource_manager.mdx b/docs/python-sdk/fastmcp-resources-resource_manager.mdx
index 9adb43e83495c30dfe9638ae5e2bd030aa41e185..f3b4fa65f982018bb9ac711d3abeb14e33f36f30 100644
--- a/docs/python-sdk/fastmcp-resources-resource_manager.mdx
+++ b/docs/python-sdk/fastmcp-resources-resource_manager.mdx
@@ -10,7 +10,7 @@ Resource manager functionality.
## Classes
-### `ResourceManager`
+### `ResourceManager`
Manages FastMCP resources.
@@ -18,7 +18,7 @@ Manages FastMCP resources.
**Methods:**
-#### `mount`
+#### `mount`
```python
mount(self, server: MountedServer) -> None
@@ -27,7 +27,7 @@ mount(self, server: MountedServer) -> None
Adds a mounted server as a source for resources and templates.
-#### `add_resource_or_template_from_fn`
+#### `add_resource_or_template_from_fn`
```python
add_resource_or_template_from_fn(self, fn: Callable[..., Any], uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> Resource | ResourceTemplate
@@ -48,7 +48,7 @@ Add a resource or template to the manager from a function.
- returns the existing resource or template.
-#### `add_resource_from_fn`
+#### `add_resource_from_fn`
```python
add_resource_from_fn(self, fn: Callable[..., Any], uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> Resource
@@ -69,7 +69,7 @@ Add a resource to the manager from a function.
- returns the existing resource.
-#### `add_resource`
+#### `add_resource`
```python
add_resource(self, resource: Resource) -> Resource
@@ -83,7 +83,7 @@ will be used as the storage key. To overwrite it, call
Resource.with_key() before calling this method.
-#### `add_template_from_fn`
+#### `add_template_from_fn`
```python
add_template_from_fn(self, fn: Callable[..., Any], uri_template: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> ResourceTemplate
@@ -92,7 +92,7 @@ add_template_from_fn(self, fn: Callable[..., Any], uri_template: str, name: str
Create a template from a function.
-#### `add_template`
+#### `add_template`
```python
add_template(self, template: ResourceTemplate) -> ResourceTemplate
diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx
index c1810f0976cfe4ee32c1e092b49ea2ffc01b8088..99f8218e13cbbfe12dd75ef1b80e22e2e952b4ae 100644
--- a/docs/python-sdk/fastmcp-resources-template.mdx
+++ b/docs/python-sdk/fastmcp-resources-template.mdx
@@ -10,13 +10,13 @@ Resource template functionality.
## Functions
-### `build_regex`
+### `build_regex`
```python
build_regex(template: str) -> re.Pattern
```
-### `match_uri_template`
+### `match_uri_template`
```python
match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None
@@ -24,7 +24,7 @@ match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None
## Classes
-### `ResourceTemplate`
+### `ResourceTemplate`
A template for dynamically creating resources.
@@ -32,13 +32,13 @@ A template for dynamically creating resources.
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None) -> FunctionResourceTemplate
```
-#### `set_default_mime_type`
+#### `set_default_mime_type`
```python
set_default_mime_type(cls, mime_type: str | None) -> str
@@ -47,7 +47,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str
Set default MIME type if not provided.
-#### `matches`
+#### `matches`
```python
matches(self, uri: str) -> dict[str, Any] | None
@@ -56,7 +56,7 @@ matches(self, uri: str) -> dict[str, Any] | None
Check if URI matches template and extract parameters.
-#### `to_mcp_template`
+#### `to_mcp_template`
```python
to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate
@@ -65,7 +65,7 @@ to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate
Convert the resource template to an MCPResourceTemplate.
-#### `from_mcp_template`
+#### `from_mcp_template`
```python
from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate
@@ -74,7 +74,7 @@ from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate
Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object.
-#### `key`
+#### `key`
```python
key(self) -> str
@@ -86,7 +86,7 @@ keys having a certain value, as the same tool loaded from different
hierarchies of servers may have different keys.
-### `FunctionResourceTemplate`
+### `FunctionResourceTemplate`
A template for dynamically creating resources.
@@ -94,7 +94,7 @@ A template for dynamically creating resources.
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None) -> FunctionResourceTemplate
diff --git a/docs/python-sdk/fastmcp-resources-types.mdx b/docs/python-sdk/fastmcp-resources-types.mdx
index 675b44cc1187ce08b89b7e10a666c360c3b321c4..7fa595b8e0389ef9c1ee7b612d3de12ba66732eb 100644
--- a/docs/python-sdk/fastmcp-resources-types.mdx
+++ b/docs/python-sdk/fastmcp-resources-types.mdx
@@ -10,19 +10,19 @@ Concrete resource implementations.
## Classes
-### `TextResource`
+### `TextResource`
A resource that reads from a string.
-### `BinaryResource`
+### `BinaryResource`
A resource that reads from bytes.
-### `FileResource`
+### `FileResource`
A resource that reads from a file.
@@ -32,7 +32,7 @@ Set is_binary=True to read file as binary data instead of text.
**Methods:**
-#### `validate_absolute_path`
+#### `validate_absolute_path`
```python
validate_absolute_path(cls, path: Path) -> Path
@@ -41,7 +41,7 @@ validate_absolute_path(cls, path: Path) -> Path
Ensure path is absolute.
-#### `set_binary_from_mime_type`
+#### `set_binary_from_mime_type`
```python
set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool
@@ -50,13 +50,13 @@ set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool
Set is_binary based on mime_type if not explicitly set.
-### `HttpResource`
+### `HttpResource`
A resource that reads from an HTTP endpoint.
-### `DirectoryResource`
+### `DirectoryResource`
A resource that lists files in a directory.
@@ -64,7 +64,7 @@ A resource that lists files in a directory.
**Methods:**
-#### `validate_absolute_path`
+#### `validate_absolute_path`
```python
validate_absolute_path(cls, path: Path) -> Path
@@ -73,7 +73,7 @@ validate_absolute_path(cls, path: Path) -> Path
Ensure path is absolute.
-#### `list_files`
+#### `list_files`
```python
list_files(self) -> list[Path]
diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx
index 8a20aa71629dd34e0cd3703f80ab2d461f9b8646..5fd5cce45275ad692b160456b2ad65a8934c907c 100644
--- a/docs/python-sdk/fastmcp-server-auth-auth.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx
@@ -7,4 +7,4 @@ sidebarTitle: auth
## Classes
-### `OAuthProvider`
+### `OAuthProvider`
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-bearer.mdx b/docs/python-sdk/fastmcp-server-auth-providers-bearer.mdx
index 5e85ee1e98a1a8ff5e698402306fea1d25f16c8c..f6a6285be7e7ab70caeba95434e92386580138f8 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-bearer.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-bearer.mdx
@@ -7,23 +7,23 @@ sidebarTitle: bearer
## Classes
-### `JWKData`
+### `JWKData`
JSON Web Key data structure.
-### `JWKSData`
+### `JWKSData`
JSON Web Key Set data structure.
-### `RSAKeyPair`
+### `RSAKeyPair`
**Methods:**
-#### `generate`
+#### `generate`
```python
generate(cls) -> 'RSAKeyPair'
@@ -35,7 +35,7 @@ Generate an RSA key pair for testing.
- (private_key_pem, public_key_pem)
-#### `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
@@ -57,7 +57,7 @@ Generate a test JWT token for testing purposes.
- Signed JWT token string
-### `BearerAuthProvider`
+### `BearerAuthProvider`
Simple JWT Bearer Token validator for hosted MCP servers.
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-bearer_env.mdx b/docs/python-sdk/fastmcp-server-auth-providers-bearer_env.mdx
index f64c65c84e3de6f62a1aebe27d49d1415a9a7c57..e1984efb66172c2174e5a5096ef9974174556c9c 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-bearer_env.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-bearer_env.mdx
@@ -7,13 +7,13 @@ sidebarTitle: bearer_env
## Classes
-### `EnvBearerAuthProviderSettings`
+### `EnvBearerAuthProviderSettings`
Settings for the BearerAuthProvider.
-### `EnvBearerAuthProvider`
+### `EnvBearerAuthProvider`
A BearerAuthProvider that loads settings from environment variables. Any
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx
index ef34ce2fb5ee870ecefb8fa8f783012337f4f57e..c11f3b87e009c42bc61836adcfd82d568c4d8f9f 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx
@@ -7,7 +7,7 @@ sidebarTitle: in_memory
## Classes
-### `InMemoryOAuthProvider`
+### `InMemoryOAuthProvider`
An in-memory OAuth provider for testing purposes.
diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx
index ea1d92643386d05344939680668dbdd99e23931f..4cc497740309e15b9cf786f8283207dcf7b5afa6 100644
--- a/docs/python-sdk/fastmcp-server-context.mdx
+++ b/docs/python-sdk/fastmcp-server-context.mdx
@@ -7,7 +7,7 @@ sidebarTitle: context
## Functions
-### `set_context`
+### `set_context`
```python
set_context(context: Context) -> Generator[Context, None, None]
@@ -15,7 +15,7 @@ set_context(context: Context) -> Generator[Context, None, None]
## Classes
-### `Context`
+### `Context`
Context object providing access to MCP capabilities.
@@ -53,7 +53,7 @@ The context is optional - tools that don't need it can omit the parameter.
**Methods:**
-#### `request_context`
+#### `request_context`
```python
request_context(self) -> RequestContext
@@ -64,7 +64,7 @@ Access to the underlying request context.
If called outside of a request context, this will raise a ValueError.
-#### `client_id`
+#### `client_id`
```python
client_id(self) -> str | None
@@ -73,7 +73,7 @@ client_id(self) -> str | None
Get the client ID if available.
-#### `request_id`
+#### `request_id`
```python
request_id(self) -> str
@@ -82,7 +82,7 @@ request_id(self) -> str
Get the unique ID for this request.
-#### `session_id`
+#### `session_id`
```python
session_id(self) -> str | None
@@ -99,7 +99,7 @@ the same client session.
- for stdio and in-memory transports which don't use session IDs.
-#### `session`
+#### `session`
```python
session(self)
@@ -108,7 +108,7 @@ session(self)
Access to the underlying session for advanced usage.
-#### `get_http_request`
+#### `get_http_request`
```python
get_http_request(self) -> Request
diff --git a/docs/python-sdk/fastmcp-server-dependencies.mdx b/docs/python-sdk/fastmcp-server-dependencies.mdx
index 0d6c3707497a8d93bd8e15f3e6f1f410ade67b92..dce54051b5c8a1111edf1e52a15d97ff752fe85e 100644
--- a/docs/python-sdk/fastmcp-server-dependencies.mdx
+++ b/docs/python-sdk/fastmcp-server-dependencies.mdx
@@ -7,19 +7,19 @@ sidebarTitle: dependencies
## Functions
-### `get_context`
+### `get_context`
```python
get_context() -> Context
```
-### `get_http_request`
+### `get_http_request`
```python
get_http_request() -> Request
```
-### `get_http_headers`
+### `get_http_headers`
```python
get_http_headers(include_all: bool = False) -> dict[str, str]
diff --git a/docs/python-sdk/fastmcp-server-http.mdx b/docs/python-sdk/fastmcp-server-http.mdx
index 63f2768cb061c37d6b5379d37af92ca1b8fa8b1c..75afb765f0bb55519b3f23dfe8c746e205a14d5e 100644
--- a/docs/python-sdk/fastmcp-server-http.mdx
+++ b/docs/python-sdk/fastmcp-server-http.mdx
@@ -7,13 +7,13 @@ sidebarTitle: http
## Functions
-### `set_http_request`
+### `set_http_request`
```python
set_http_request(request: Request) -> Generator[Request, None, None]
```
-### `setup_auth_middleware_and_routes`
+### `setup_auth_middleware_and_routes`
```python
setup_auth_middleware_and_routes(auth: OAuthProvider) -> tuple[list[Middleware], list[BaseRoute], list[str]]
@@ -29,7 +29,7 @@ Set up authentication middleware and routes if auth is enabled.
- Tuple of (middleware, auth_routes, required_scopes)
-### `create_base_app`
+### `create_base_app`
```python
create_base_app(routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None) -> StarletteWithLifespan
@@ -48,7 +48,7 @@ Create a base Starlette app with common middleware and routes.
- A Starlette application
-### `create_sse_app`
+### `create_sse_app`
```python
create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: OAuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan
@@ -70,7 +70,7 @@ Returns:
A Starlette application with RequestContextMiddleware
-### `create_streamable_http_app`
+### `create_streamable_http_app`
```python
create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, auth: OAuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan
@@ -96,17 +96,17 @@ Return an instance of the StreamableHTTP server app.
## Classes
-### `StarletteWithLifespan`
+### `StarletteWithLifespan`
**Methods:**
-#### `lifespan`
+#### `lifespan`
```python
lifespan(self) -> Lifespan
```
-### `RequestContextMiddleware`
+### `RequestContextMiddleware`
Middleware that stores each request in a ContextVar
diff --git a/docs/python-sdk/fastmcp-server-middleware-__init__.mdx b/docs/python-sdk/fastmcp-server-middleware-__init__.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..8583b1df9b03cc609f7c8413adeb55f934809291
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-middleware-__init__.mdx
@@ -0,0 +1,8 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.server.middleware`
+
+*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx b/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..735b3c3e5ed592c2b1e3448dc5f8f7cbcf1f4a2e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-middleware-error_handling.mdx
@@ -0,0 +1,40 @@
+---
+title: error_handling
+sidebarTitle: error_handling
+---
+
+# `fastmcp.server.middleware.error_handling`
+
+
+Error handling middleware for consistent error responses and tracking.
+
+## Classes
+
+### `ErrorHandlingMiddleware`
+
+
+Middleware that provides consistent error handling and logging.
+
+Catches exceptions, logs them appropriately, and converts them to
+proper MCP error responses. Also tracks error patterns for monitoring.
+
+
+**Methods:**
+
+#### `get_error_stats`
+
+```python
+get_error_stats(self) -> dict[str, int]
+```
+
+Get error statistics for monitoring.
+
+
+### `RetryMiddleware`
+
+
+Middleware that implements automatic retry logic for failed requests.
+
+Retries requests that fail with transient errors, using exponential
+backoff to avoid overwhelming the server or external dependencies.
+
diff --git a/docs/python-sdk/fastmcp-server-middleware-logging.mdx b/docs/python-sdk/fastmcp-server-middleware-logging.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..c45e3096a363d6dda3df0b06dec737cf178b406b
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-middleware-logging.mdx
@@ -0,0 +1,29 @@
+---
+title: logging
+sidebarTitle: logging
+---
+
+# `fastmcp.server.middleware.logging`
+
+
+Comprehensive logging middleware for FastMCP servers.
+
+## Classes
+
+### `LoggingMiddleware`
+
+
+Middleware that provides comprehensive request and response logging.
+
+Logs all MCP messages with configurable detail levels. Useful for debugging,
+monitoring, and understanding server usage patterns.
+
+
+### `StructuredLoggingMiddleware`
+
+
+Middleware that provides structured JSON logging for better log analysis.
+
+Outputs structured logs that are easier to parse and analyze with log
+aggregation tools like ELK stack, Splunk, or cloud logging services.
+
diff --git a/docs/python-sdk/fastmcp-server-middleware-middleware.mdx b/docs/python-sdk/fastmcp-server-middleware-middleware.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..179864e5db77864a64c3e86f68b90588fdff72d4
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-middleware-middleware.mdx
@@ -0,0 +1,56 @@
+---
+title: middleware
+sidebarTitle: middleware
+---
+
+# `fastmcp.server.middleware.middleware`
+
+## Functions
+
+### `make_middleware_wrapper`
+
+```python
+make_middleware_wrapper(middleware: Middleware, call_next: CallNext[T, R]) -> CallNext[T, R]
+```
+
+
+Create a wrapper that applies a single middleware to a context. The
+closure bakes in the middleware and call_next function, so it can be
+passed to other functions that expect a call_next function.
+
+
+## Classes
+
+### `CallNext`
+
+### `CallToolResult`
+
+### `ListToolsResult`
+
+### `ListResourcesResult`
+
+### `ListResourceTemplatesResult`
+
+### `ListPromptsResult`
+
+### `ServerResultProtocol`
+
+### `MiddlewareContext`
+
+
+Unified context for all middleware operations.
+
+
+**Methods:**
+
+#### `copy`
+
+```python
+copy(self, **kwargs: Any) -> MiddlewareContext[T]
+```
+
+### `Middleware`
+
+
+Base class for FastMCP middleware with dispatching hooks.
+
diff --git a/docs/python-sdk/fastmcp-server-middleware-rate_limiting.mdx b/docs/python-sdk/fastmcp-server-middleware-rate_limiting.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..a983ce3f457f545721b7e6cf232adfd400d28c05
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-middleware-rate_limiting.mdx
@@ -0,0 +1,47 @@
+---
+title: rate_limiting
+sidebarTitle: rate_limiting
+---
+
+# `fastmcp.server.middleware.rate_limiting`
+
+
+Rate limiting middleware for protecting FastMCP servers from abuse.
+
+## Classes
+
+### `RateLimitError`
+
+
+Error raised when rate limit is exceeded.
+
+
+### `TokenBucketRateLimiter`
+
+
+Token bucket implementation for rate limiting.
+
+
+### `SlidingWindowRateLimiter`
+
+
+Sliding window rate limiter implementation.
+
+
+### `RateLimitingMiddleware`
+
+
+Middleware that implements rate limiting to prevent server abuse.
+
+Uses a token bucket algorithm by default, allowing for burst traffic
+while maintaining a sustainable long-term rate.
+
+
+### `SlidingWindowRateLimitingMiddleware`
+
+
+Middleware that implements sliding window rate limiting.
+
+Uses a sliding window approach which provides more precise rate limiting
+but uses more memory to track individual request timestamps.
+
diff --git a/docs/python-sdk/fastmcp-server-middleware-timing.mdx b/docs/python-sdk/fastmcp-server-middleware-timing.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..c2805a3f7e4a6ad0f045300bc9fc7c3f9efbe4c8
--- /dev/null
+++ b/docs/python-sdk/fastmcp-server-middleware-timing.mdx
@@ -0,0 +1,29 @@
+---
+title: timing
+sidebarTitle: timing
+---
+
+# `fastmcp.server.middleware.timing`
+
+
+Timing middleware for measuring and logging request performance.
+
+## Classes
+
+### `TimingMiddleware`
+
+
+Middleware that logs the execution time of requests.
+
+Only measures and logs timing for request messages (not notifications).
+Provides insights into performance characteristics of your MCP server.
+
+
+### `DetailedTimingMiddleware`
+
+
+Enhanced timing middleware with per-operation breakdowns.
+
+Provides detailed timing information for different types of MCP operations,
+allowing you to identify performance bottlenecks in specific operations.
+
diff --git a/docs/python-sdk/fastmcp-server-openapi.mdx b/docs/python-sdk/fastmcp-server-openapi.mdx
index d2490cea7a497fdb337b74c405726c9d0b405f6c..e57a6fd180f6ed94766a1bc7f9493ff06a1140a1 100644
--- a/docs/python-sdk/fastmcp-server-openapi.mdx
+++ b/docs/python-sdk/fastmcp-server-openapi.mdx
@@ -10,13 +10,13 @@ FastMCP server implementation for OpenAPI integration.
## Classes
-### `MCPType`
+### `MCPType`
Type of FastMCP component to create from a route.
-### `RouteType`
+### `RouteType`
Deprecated: Use MCPType instead.
@@ -24,31 +24,31 @@ Deprecated: Use MCPType instead.
This enum is kept for backward compatibility and will be removed in a future version.
-### `RouteMap`
+### `RouteMap`
Mapping configuration for HTTP routes to FastMCP component types.
-### `OpenAPITool`
+### `OpenAPITool`
Tool implementation for OpenAPI endpoints.
-### `OpenAPIResource`
+### `OpenAPIResource`
Resource implementation for OpenAPI endpoints.
-### `OpenAPIResourceTemplate`
+### `OpenAPIResourceTemplate`
Resource template implementation for OpenAPI endpoints.
-### `FastMCPOpenAPI`
+### `FastMCPOpenAPI`
FastMCP server implementation that creates components from an OpenAPI schema.
diff --git a/docs/python-sdk/fastmcp-server-proxy.mdx b/docs/python-sdk/fastmcp-server-proxy.mdx
index bad549605ec19e98e221653f855b36d545c6b779..e480b9167e551fd38e857f585040ced45ea852c2 100644
--- a/docs/python-sdk/fastmcp-server-proxy.mdx
+++ b/docs/python-sdk/fastmcp-server-proxy.mdx
@@ -7,25 +7,25 @@ sidebarTitle: proxy
## Classes
-### `ProxyToolManager`
+### `ProxyToolManager`
A ToolManager that sources its tools from a remote client in addition to local and mounted tools.
-### `ProxyResourceManager`
+### `ProxyResourceManager`
A ResourceManager that sources its resources from a remote client in addition to local and mounted resources.
-### `ProxyPromptManager`
+### `ProxyPromptManager`
A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts.
-### `ProxyTool`
+### `ProxyTool`
A Tool that represents and executes a tool on a remote server.
@@ -33,7 +33,7 @@ A Tool that represents and executes a tool on a remote server.
**Methods:**
-#### `from_mcp_tool`
+#### `from_mcp_tool`
```python
from_mcp_tool(cls, client: Client, mcp_tool: mcp.types.Tool) -> ProxyTool
@@ -42,7 +42,7 @@ from_mcp_tool(cls, client: Client, mcp_tool: mcp.types.Tool) -> ProxyTool
Factory method to create a ProxyTool from a raw MCP tool schema.
-### `ProxyResource`
+### `ProxyResource`
A Resource that represents and reads a resource from a remote server.
@@ -50,7 +50,7 @@ A Resource that represents and reads a resource from a remote server.
**Methods:**
-#### `from_mcp_resource`
+#### `from_mcp_resource`
```python
from_mcp_resource(cls, client: Client, mcp_resource: mcp.types.Resource) -> ProxyResource
@@ -59,7 +59,7 @@ from_mcp_resource(cls, client: Client, mcp_resource: mcp.types.Resource) -> Prox
Factory method to create a ProxyResource from a raw MCP resource schema.
-### `ProxyTemplate`
+### `ProxyTemplate`
A ResourceTemplate that represents and creates resources from a remote server template.
@@ -67,7 +67,7 @@ A ResourceTemplate that represents and creates resources from a remote server te
**Methods:**
-#### `from_mcp_template`
+#### `from_mcp_template`
```python
from_mcp_template(cls, client: Client, mcp_template: mcp.types.ResourceTemplate) -> ProxyTemplate
@@ -76,7 +76,7 @@ from_mcp_template(cls, client: Client, mcp_template: mcp.types.ResourceTemplate)
Factory method to create a ProxyTemplate from a raw MCP template schema.
-### `ProxyPrompt`
+### `ProxyPrompt`
A Prompt that represents and renders a prompt from a remote server.
@@ -84,7 +84,7 @@ A Prompt that represents and renders a prompt from a remote server.
**Methods:**
-#### `from_mcp_prompt`
+#### `from_mcp_prompt`
```python
from_mcp_prompt(cls, client: Client, mcp_prompt: mcp.types.Prompt) -> ProxyPrompt
@@ -93,7 +93,7 @@ from_mcp_prompt(cls, client: Client, mcp_prompt: mcp.types.Prompt) -> ProxyPromp
Factory method to create a ProxyPrompt from a raw MCP prompt schema.
-### `FastMCPProxy`
+### `FastMCPProxy`
A FastMCP server that acts as a proxy to a remote MCP-compliant server.
diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx
index 2b3c1ed83133f2dc417184204b29c3e1e032f07d..8e6cc2bf50de5ba097cfce6ea209fcc9b67fcccb 100644
--- a/docs/python-sdk/fastmcp-server-server.mdx
+++ b/docs/python-sdk/fastmcp-server-server.mdx
@@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers.
## Functions
-### `add_resource_prefix`
+### `add_resource_prefix`
```python
add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str
@@ -19,26 +19,36 @@ add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'p
Add a prefix to a resource URI.
- Args:
- uri: The original resource URI
- prefix: The prefix to add
+**Args:**
+- `uri`: The original resource URI
+- `prefix`: The prefix to add
+
+**Returns:**
+- The resource URI with the prefix added
- Returns:
- The resource URI with the prefix added
+**Examples:**
+
+With new style:
+```python
+add_resource_prefix("resource://path/to/resource", "prefix")
+"resource://prefix/path/to/resource"
+```
+With legacy style:
+```python
+add_resource_prefix("resource://path/to/resource", "prefix")
+"prefix+resource://path/to/resource"
+```
+With absolute path:
+```python
+add_resource_prefix("resource:///absolute/path", "prefix")
+"resource://prefix//absolute/path"
+```
- Examples:
- >>> add_resource_prefix("resource://path/to/resource", "prefix")
- "resource://prefix/path/to/resource" # with new style
- >>> add_resource_prefix("resource://path/to/resource", "prefix")
- "prefix+resource://path/to/resource" # with legacy style
- >>> add_resource_prefix("resource:///absolute/path", "prefix")
- "resource://prefix//absolute/path" # with new style
+**Raises:**
+- `ValueError`: If the URI doesn't match the expected protocol\://path format
- Raises:
- ValueError: If the URI doesn't match the expected protocol://path format
-
-### `remove_resource_prefix`
+### `remove_resource_prefix`
```python
remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str
@@ -47,26 +57,37 @@ remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol',
Remove a prefix from a resource URI.
- Args:
- uri: The resource URI with a prefix
- prefix: The prefix to remove
- prefix_format: The format of the prefix to remove
- Returns:
- The resource URI with the prefix removed
+**Args:**
+- `uri`: The resource URI with a prefix
+- `prefix`: The prefix to remove
+- `prefix_format`: The format of the prefix to remove
- Examples:
- >>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
- "resource://path/to/resource" # with new style
- >>> remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
- "resource://path/to/resource" # with legacy style
- >>> remove_resource_prefix("resource://prefix//absolute/path", "prefix")
- "resource:///absolute/path" # with new style
+Returns:
+ The resource URI with the prefix removed
- Raises:
- ValueError: If the URI doesn't match the expected protocol://path format
-
+**Examples:**
-### `has_resource_prefix`
+With new style:
+```python
+remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
+"resource://path/to/resource"
+```
+With legacy style:
+```python
+remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
+"resource://path/to/resource"
+```
+With absolute path:
+```python
+remove_resource_prefix("resource://prefix//absolute/path", "prefix")
+"resource:///absolute/path"
+```
+
+**Raises:**
+- `ValueError`: If the URI doesn't match the expected protocol\://path format
+
+
+### `has_resource_prefix`
```python
has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool
@@ -75,53 +96,63 @@ has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'p
Check if a resource URI has a specific prefix.
- Args:
- uri: The resource URI to check
- prefix: The prefix to look for
+**Args:**
+- `uri`: The resource URI to check
+- `prefix`: The prefix to look for
+
+**Returns:**
+- True if the URI has the specified prefix, False otherwise
+
+**Examples:**
- Returns:
- True if the URI has the specified prefix, False otherwise
+With new style:
+```python
+has_resource_prefix("resource://prefix/path/to/resource", "prefix")
+True
+```
+With legacy style:
+```python
+has_resource_prefix("prefix+resource://path/to/resource", "prefix")
+True
+```
+With other path:
+```python
+has_resource_prefix("resource://other/path/to/resource", "prefix")
+False
+```
- Examples:
- >>> has_resource_prefix("resource://prefix/path/to/resource", "prefix")
- True # with new style
- >>> has_resource_prefix("prefix+resource://path/to/resource", "prefix")
- True # with legacy style
- >>> has_resource_prefix("resource://other/path/to/resource", "prefix")
- False
+**Raises:**
+- `ValueError`: If the URI doesn't match the expected protocol\://path format
- Raises:
- ValueError: If the URI doesn't match the expected protocol://path format
-
## Classes
-### `FastMCP`
+### `FastMCP`
**Methods:**
-#### `settings`
+#### `settings`
```python
settings(self) -> Settings
```
-#### `name`
+#### `name`
```python
name(self) -> str
```
-#### `instructions`
+#### `instructions`
```python
instructions(self) -> str | None
```
-#### `run`
+#### `run`
```python
-run(self, transport: Literal['stdio', 'streamable-http', 'sse'] | None = None, **transport_kwargs: Any) -> None
+run(self, transport: Transport | None = None, **transport_kwargs: Any) -> None
```
Run the FastMCP server. Note this is a synchronous function.
@@ -130,13 +161,13 @@ Run the FastMCP server. Note this is a synchronous function.
- `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http")
-#### `add_middleware`
+#### `add_middleware`
```python
add_middleware(self, middleware: Middleware) -> None
```
-#### `custom_route`
+#### `custom_route`
```python
custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True)
@@ -157,7 +188,7 @@ Starlette's reverse URL lookup feature)
- `include_in_schema`: Whether to include in OpenAPI schema, defaults to True
-#### `add_tool`
+#### `add_tool`
```python
add_tool(self, tool: Tool) -> None
@@ -172,7 +203,7 @@ with the Context type annotation. See the @tool decorator for examples.
- `tool`: The Tool instance to register
-#### `remove_tool`
+#### `remove_tool`
```python
remove_tool(self, name: str) -> None
@@ -187,19 +218,19 @@ Remove a tool from the server.
- `NotFoundError`: If the tool is not found
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: AnyFunction) -> FunctionTool
```
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool]
```
-#### `tool`
+#### `tool`
```python
tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool
@@ -223,12 +254,37 @@ This decorator supports multiple calling patterns:
- `name`: Optional name for the tool (keyword-only, alternative to name_or_fn)
- `description`: Optional description of what the tool does
- `tags`: Optional set of tags for categorizing the tool
-- `annotations`: Optional annotations about the tool's behavior (e.g. {"is_async"\: True})
+- `annotations`: Optional annotations about the tool's behavior
- `exclude_args`: Optional list of argument names to exclude from the tool schema
- `enabled`: Optional boolean to enable or disable the tool
+**Examples:**
+
+Register a tool with a custom name:
+```python
+@server.tool
+def my_tool(x: int) -> str:
+ return str(x)
-#### `add_resource`
+# Register a tool with a custom name
+@server.tool
+def my_tool(x: int) -> str:
+ return str(x)
+
+@server.tool("custom_name")
+def my_tool(x: int) -> str:
+ return str(x)
+
+@server.tool(name="custom_name")
+def my_tool(x: int) -> str:
+ return str(x)
+
+# Direct function call
+server.tool(my_function, name="custom_name")
+```
+
+
+#### `add_resource`
```python
add_resource(self, resource: Resource) -> None
@@ -240,7 +296,7 @@ Add a resource to the server.
- `resource`: A Resource instance to add
-#### `add_template`
+#### `add_template`
```python
add_template(self, template: ResourceTemplate) -> None
@@ -252,7 +308,7 @@ Add a resource template to the server.
- `template`: A ResourceTemplate instance to add
-#### `add_resource_fn`
+#### `add_resource_fn`
```python
add_resource_fn(self, fn: AnyFunction, uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> None
@@ -272,7 +328,7 @@ has parameters, it will be registered as a template resource.
- `tags`: Optional set of tags for categorizing the resource
-#### `resource`
+#### `resource`
```python
resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate]
@@ -301,8 +357,36 @@ has parameters, it will be registered as a template resource.
- `tags`: Optional set of tags for categorizing the resource
- `enabled`: Optional boolean to enable or disable the resource
+**Examples:**
+
+Register a resource with a custom name:
+```python
+@server.resource("resource://my-resource")
+def get_data() -> str:
+ return "Hello, world!"
+
+@server.resource("resource://my-resource")
+async get_data() -> str:
+ data = await fetch_data()
+ return f"Hello, world! {data}"
+
+@server.resource("resource://{city}/weather")
+def get_weather(city: str) -> str:
+ return f"Weather for {city}"
+
+@server.resource("resource://{city}/weather")
+def get_weather_with_context(city: str, ctx: Context) -> str:
+ ctx.info(f"Fetching weather for {city}")
+ return f"Weather for {city}"
+
+@server.resource("resource://{city}/weather")
+async def get_weather(city: str) -> str:
+ data = await fetch_weather(city)
+ return f"Weather for {city}: {data}"
+```
-#### `add_prompt`
+
+#### `add_prompt`
```python
add_prompt(self, prompt: Prompt) -> None
@@ -314,19 +398,19 @@ Add a prompt to the server.
- `prompt`: A Prompt instance to add
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt
```
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt]
```
-#### `prompt`
+#### `prompt`
```python
prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt
@@ -352,9 +436,11 @@ Decorator to register a prompt.
tags: Optional set of tags for categorizing the prompt
enabled: Optional boolean to enable or disable the prompt
- Example:
+ Examples:
+
+ ```python
@server.prompt
- def analyze_table(table_name: str) -> list\[Message]:
+ def analyze_table(table_name: str) -> list[Message]:
schema = read_table_schema(table_name)
return [
{
@@ -365,7 +451,7 @@ Decorator to register a prompt.
]
@server.prompt()
- def analyze_with_context(table_name: str, ctx: Context) -> list\[Message]:
+ def analyze_with_context(table_name: str, ctx: Context) -> list[Message]:
ctx.info(f"Analyzing table {table_name}")
schema = read_table_schema(table_name)
return [
@@ -377,7 +463,7 @@ Decorator to register a prompt.
]
@server.prompt("custom_name")
- def analyze_file(path: str) -> list\[Message]:
+ def analyze_file(path: str) -> list[Message]:
content = await read_file(path)
return [
{
@@ -393,14 +479,15 @@ Decorator to register a prompt.
]
@server.prompt(name="custom_name")
- def another_prompt(data: str) -> list\[Message]:
+ def another_prompt(data: str) -> list[Message]:
return [{"role": "user", "content": data}]
# Direct function call
server.prompt(my_function, name="custom_name")
+ ```
-#### `sse_app`
+#### `sse_app`
```python
sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan
@@ -414,7 +501,7 @@ Create a Starlette app for the SSE server.
- `middleware`: A list of middleware to apply to the app
-#### `streamable_http_app`
+#### `streamable_http_app`
```python
streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan
@@ -427,10 +514,10 @@ Create a Starlette app for the StreamableHTTP server.
- `middleware`: A list of middleware to apply to the app
-#### `http_app`
+#### `http_app`
```python
-http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['streamable-http', 'sse'] = 'streamable-http') -> StarletteWithLifespan
+http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan
```
Create a Starlette app using the specified HTTP transport.
@@ -444,7 +531,7 @@ Create a Starlette app using the specified HTTP transport.
- A Starlette application configured with the specified transport
-#### `mount`
+#### `mount`
```python
mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None
@@ -498,7 +585,7 @@ automatically determined based on whether the server has a custom lifespan
- `prompt_separator`: Deprecated. Separator character for prompt names.
-#### `from_openapi`
+#### `from_openapi`
```python
from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI
@@ -507,7 +594,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route
Create a FastMCP server from an OpenAPI specification.
-#### `from_fastapi`
+#### `from_fastapi`
```python
from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI
@@ -516,7 +603,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap]
Create a FastMCP server from a FastAPI application.
-#### `as_proxy`
+#### `as_proxy`
```python
as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy
@@ -524,13 +611,13 @@ as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any]
Create a FastMCP proxy server for the given backend.
-The ``backend`` argument can be either an existing :class:`~fastmcp.client.Client`
-instance or any value accepted as the ``transport`` argument of
-:class:`~fastmcp.client.Client`. This mirrors the convenience of the
-``Client`` constructor.
+The `backend` argument can be either an existing `fastmcp.client.Client`
+instance or any value accepted as the `transport` argument of
+`fastmcp.client.Client`. This mirrors the convenience of the
+`fastmcp.client.Client` constructor.
-#### `from_client`
+#### `from_client`
```python
from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy
@@ -539,4 +626,4 @@ from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPPr
Create a FastMCP proxy server from a FastMCP client.
-### `MountedServer`
+### `MountedServer`
diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx
index fd3e3d791b98b7fae21170e5a9cca4433e61ca4d..6725277cb13597cb79ab83ad73ca06aa49693e30 100644
--- a/docs/python-sdk/fastmcp-settings.mdx
+++ b/docs/python-sdk/fastmcp-settings.mdx
@@ -7,7 +7,7 @@ sidebarTitle: settings
## Classes
-### `ExtendedEnvSettingsSource`
+### `ExtendedEnvSettingsSource`
A special EnvSettingsSource that allows for multiple env var prefixes to be used.
@@ -17,15 +17,15 @@ Raises a deprecation warning if the old `FASTMCP_SERVER_` prefix is used.
**Methods:**
-#### `get_field_value`
+#### `get_field_value`
```python
get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]
```
-### `ExtendedSettingsConfigDict`
+### `ExtendedSettingsConfigDict`
-### `Settings`
+### `Settings`
FastMCP settings.
@@ -33,13 +33,13 @@ FastMCP settings.
**Methods:**
-#### `settings_customise_sources`
+#### `settings_customise_sources`
```python
settings_customise_sources(cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource) -> tuple[PydanticBaseSettingsSource, ...]
```
-#### `settings`
+#### `settings`
```python
settings(self) -> Self
@@ -49,7 +49,7 @@ This property is for backwards compatibility with FastMCP < 2.8.0,
which accessed fastmcp.settings.settings
-#### `setup_logging`
+#### `setup_logging`
```python
setup_logging(self) -> Self
diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx
index 7cae406aaa56c04304a346c9102c6b09023ca0bb..07aef85a947ae4ffa6c3ed7f34100a963b11eb2e 100644
--- a/docs/python-sdk/fastmcp-tools-tool.mdx
+++ b/docs/python-sdk/fastmcp-tools-tool.mdx
@@ -7,7 +7,7 @@ sidebarTitle: tool
## Functions
-### `default_serializer`
+### `default_serializer`
```python
default_serializer(data: Any) -> str
@@ -15,7 +15,7 @@ default_serializer(data: Any) -> str
## Classes
-### `Tool`
+### `Tool`
Internal tool registration info.
@@ -23,13 +23,13 @@ Internal tool registration info.
**Methods:**
-#### `to_mcp_tool`
+#### `to_mcp_tool`
```python
to_mcp_tool(self, **overrides: Any) -> MCPTool
```
-#### `from_function`
+#### `from_function`
```python
from_function(fn: Callable[..., Any], name: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None) -> FunctionTool
@@ -38,17 +38,17 @@ from_function(fn: Callable[..., Any], name: str | None = None, description: str
Create a Tool from a function.
-#### `from_tool`
+#### `from_tool`
```python
from_tool(cls, tool: Tool, transform_fn: Callable[..., Any] | None = None, name: str | None = None, transform_args: dict[str, ArgTransform] | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None) -> TransformedTool
```
-### `FunctionTool`
+### `FunctionTool`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], name: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None) -> FunctionTool
@@ -57,11 +57,11 @@ from_function(cls, fn: Callable[..., Any], name: str | None = None, description:
Create a Tool from a function.
-### `ParsedFunction`
+### `ParsedFunction`
**Methods:**
-#### `from_function`
+#### `from_function`
```python
from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True) -> ParsedFunction
diff --git a/docs/python-sdk/fastmcp-tools-tool_manager.mdx b/docs/python-sdk/fastmcp-tools-tool_manager.mdx
index fad031d72a4d093c3cf457ca6b22e93a31fe267e..75328aca1a361b4b5f9b14394270014817782f1c 100644
--- a/docs/python-sdk/fastmcp-tools-tool_manager.mdx
+++ b/docs/python-sdk/fastmcp-tools-tool_manager.mdx
@@ -7,7 +7,7 @@ sidebarTitle: tool_manager
## Classes
-### `ToolManager`
+### `ToolManager`
Manages FastMCP tools.
@@ -15,7 +15,7 @@ Manages FastMCP tools.
**Methods:**
-#### `mount`
+#### `mount`
```python
mount(self, server: MountedServer) -> None
@@ -24,7 +24,7 @@ mount(self, server: MountedServer) -> None
Adds a mounted server as a source for tools.
-#### `add_tool_from_fn`
+#### `add_tool_from_fn`
```python
add_tool_from_fn(self, fn: Callable[..., Any], name: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, exclude_args: list[str] | None = None) -> Tool
@@ -33,7 +33,7 @@ add_tool_from_fn(self, fn: Callable[..., Any], name: str | None = None, descript
Add a tool to the server.
-#### `add_tool`
+#### `add_tool`
```python
add_tool(self, tool: Tool) -> Tool
@@ -42,7 +42,7 @@ add_tool(self, tool: Tool) -> Tool
Register a tool with the server.
-#### `remove_tool`
+#### `remove_tool`
```python
remove_tool(self, key: str) -> None
diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx
index abee7d5eb6dafb12c076be65bf5c7b011a800874..6a7ea8ceb93bc428a1233a8b5afffb0078e80320 100644
--- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx
+++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx
@@ -7,58 +7,69 @@ sidebarTitle: tool_transform
## Classes
-### `ArgTransform`
+### `ArgTransform`
Configuration for transforming a parent tool's argument.
- This class allows fine-grained control over how individual arguments are transformed
- when creating a new tool from an existing one. You can rename arguments, change their
- descriptions, add default values, or hide them from clients while passing constants.
+This class allows fine-grained control over how individual arguments are transformed
+when creating a new tool from an existing one. You can rename arguments, change their
+descriptions, add default values, or hide them from clients while passing constants.
- Attributes:
- name: New name for the argument. Use None to keep original name, or ... for no change.
- description: New description for the argument. Use None to remove description, or ... for no change.
- default: New default value for the argument. Use ... for no change.
- default_factory: Callable that returns a default value. Cannot be used with default.
- type: New type for the argument. Use ... for no change.
- hide: If True, hide this argument from clients but pass a constant value to parent.
- required: If True, make argument required (remove default). Use ... for no change.
- examples: Examples for the argument. Use ... for no change.
+**Examples:**
- Examples:
- # Rename argument 'old_name' to 'new_name'
- ArgTransform(name="new_name")
+Rename argument 'old_name' to 'new_name'
+```python
+ArgTransform(name="new_name")
+```
+
+Change description only
+```python
+ArgTransform(description="Updated description")
+```
- # Change description only
- ArgTransform(description="Updated description")
+Add a default value (makes argument optional)
+```python
+ArgTransform(default=42)
+```
- # Add a default value (makes argument optional)
- ArgTransform(default=42)
+Add a default factory (makes argument optional)
+```python
+ArgTransform(default_factory=lambda: time.time())
+```
- # Add a default factory (makes argument optional)
- ArgTransform(default_factory=lambda: time.time())
+Change the type
+```python
+ArgTransform(type=str)
+```
- # Change the type
- ArgTransform(type=str)
+Hide the argument entirely from clients
+```python
+ArgTransform(hide=True)
+```
- # Hide the argument entirely from clients
- ArgTransform(hide=True)
+Hide argument but pass a constant value to parent
+```python
+ArgTransform(hide=True, default="constant_value")
+```
- # Hide argument but pass a constant value to parent
- ArgTransform(hide=True, default="constant_value")
+Hide argument but pass a factory-generated value to parent
+```python
+ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex)
+```
- # Hide argument but pass a factory-generated value to parent
- ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex)
+Make an optional parameter required (removes any default)
+```python
+ArgTransform(required=True)
+```
- # Make an optional parameter required (removes any default)
- ArgTransform(required=True)
+Combine multiple transformations
+```python
+ArgTransform(name="new_name", description="New desc", default=None, type=int)
+```
- # Combine multiple transformations
- ArgTransform(name="new_name", description="New desc", default=None, type=int)
-
-### `TransformedTool`
+### `TransformedTool`
A tool that is transformed from another tool.
@@ -74,7 +85,7 @@ with transformed arguments.
**Methods:**
-#### `from_tool`
+#### `from_tool`
```python
from_tool(cls, tool: Tool, name: str | None = None, description: str | None = None, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, enabled: bool | None = None) -> TransformedTool
@@ -90,9 +101,9 @@ argument names.
- `name`: New name for the tool. Defaults to parent tool's name.
- `transform_args`: Optional transformations for parent tool arguments.
Only specified arguments are transformed, others pass through unchanged\:
-- str\: Simple rename
-- ArgTransform\: Complex transformation (rename/description/default/drop)
-- None\: Drop the argument
+- Simple rename (str)
+- Complex transformation (rename/description/default/drop) (ArgTransform)
+- Drop the argument (None)
- `description`: New description. Defaults to parent's description.
- `tags`: New tags. Defaults to parent's tags.
- `annotations`: New annotations. Defaults to parent's annotations.
@@ -101,17 +112,28 @@ Only specified arguments are transformed, others pass through unchanged\:
**Returns:**
- TransformedTool with the specified transformations.
-Examples:
-- # Transform specific arguments only
-- Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged
-- # Custom function with partial transforms
-- async def custom(x: int, y: int) -> str:
-result = await forward(x=x, y=y)
-return f"Custom: {result}"
-- Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"})
-- # Using **kwargs (gets all args, transformed and untransformed)
-- async def flexible(**kwargs) -> str:
-result = await forward(**kwargs)
-return f"Got: {kwargs}"
-- Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
+**Examples:**
+
+# Transform specific arguments only
+```python
+Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged
+```
+
+# Custom function with partial transforms
+```python
+async def custom(x: int, y: int) -> str:
+ result = await forward(x=x, y=y)
+ return f"Custom: {result}"
+
+Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"})
+```
+
+# Using **kwargs (gets all args, transformed and untransformed)
+```python
+async def flexible(**kwargs) -> str:
+ result = await forward(**kwargs)
+ return f"Got: {kwargs}"
+
+Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
+```
diff --git a/docs/python-sdk/fastmcp-utilities-cache.mdx b/docs/python-sdk/fastmcp-utilities-cache.mdx
index ab41395d9e561dcf06693bd2c398b062761c2656..49b0794a246970a6298480f8b248fad80d1d864a 100644
--- a/docs/python-sdk/fastmcp-utilities-cache.mdx
+++ b/docs/python-sdk/fastmcp-utilities-cache.mdx
@@ -7,23 +7,23 @@ sidebarTitle: cache
## Classes
-### `TimedCache`
+### `TimedCache`
**Methods:**
-#### `set`
+#### `set`
```python
set(self, key: Any, value: Any) -> None
```
-#### `get`
+#### `get`
```python
get(self, key: Any) -> Any
```
-#### `clear`
+#### `clear`
```python
clear(self) -> None
diff --git a/docs/python-sdk/fastmcp-utilities-components.mdx b/docs/python-sdk/fastmcp-utilities-components.mdx
index 61434c7d52ba6f89daf9efb9e22004007d0085f4..8a27b2ac721a53c1c03b110e5ca3143a3cf4257e 100644
--- a/docs/python-sdk/fastmcp-utilities-components.mdx
+++ b/docs/python-sdk/fastmcp-utilities-components.mdx
@@ -7,7 +7,7 @@ sidebarTitle: components
## Classes
-### `FastMCPComponent`
+### `FastMCPComponent`
Base class for FastMCP tools, prompts, resources, and resource templates.
@@ -15,7 +15,7 @@ Base class for FastMCP tools, prompts, resources, and resource templates.
**Methods:**
-#### `key`
+#### `key`
```python
key(self) -> str
@@ -27,13 +27,13 @@ keys having a certain value, as the same tool loaded from different
hierarchies of servers may have different keys.
-#### `with_key`
+#### `with_key`
```python
with_key(self, key: str) -> Self
```
-#### `enable`
+#### `enable`
```python
enable(self) -> None
@@ -42,7 +42,7 @@ enable(self) -> None
Enable the component.
-#### `disable`
+#### `disable`
```python
disable(self) -> None
diff --git a/docs/python-sdk/fastmcp-utilities-exceptions.mdx b/docs/python-sdk/fastmcp-utilities-exceptions.mdx
index 2d480a14669cade382ef0e632a5882dc39a71b3e..6b33526dc13eb899f504b0781b3f2ab09c22b2fa 100644
--- a/docs/python-sdk/fastmcp-utilities-exceptions.mdx
+++ b/docs/python-sdk/fastmcp-utilities-exceptions.mdx
@@ -7,13 +7,13 @@ sidebarTitle: exceptions
## Functions
-### `iter_exc`
+### `iter_exc`
```python
iter_exc(group: BaseExceptionGroup)
```
-### `get_catch_handlers`
+### `get_catch_handlers`
```python
get_catch_handlers() -> Mapping[type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any]]
diff --git a/docs/python-sdk/fastmcp-utilities-http.mdx b/docs/python-sdk/fastmcp-utilities-http.mdx
index 6e5e4b75ff2257130349c90a8a7879ecee24c234..661f4e575ea41fcf363a97a6beedf2bfc4fa3667 100644
--- a/docs/python-sdk/fastmcp-utilities-http.mdx
+++ b/docs/python-sdk/fastmcp-utilities-http.mdx
@@ -7,7 +7,7 @@ sidebarTitle: http
## Functions
-### `find_available_port`
+### `find_available_port`
```python
find_available_port() -> int
diff --git a/docs/python-sdk/fastmcp-utilities-inspect.mdx b/docs/python-sdk/fastmcp-utilities-inspect.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..f7b09c2293995674e5aad74df58c09ca5dc9f22b
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-inspect.mdx
@@ -0,0 +1,41 @@
+---
+title: inspect
+sidebarTitle: inspect
+---
+
+# `fastmcp.utilities.inspect`
+
+
+Utilities for inspecting FastMCP instances.
+
+## Classes
+
+### `ToolInfo`
+
+
+Information about a tool.
+
+
+### `PromptInfo`
+
+
+Information about a prompt.
+
+
+### `ResourceInfo`
+
+
+Information about a resource.
+
+
+### `TemplateInfo`
+
+
+Information about a resource template.
+
+
+### `FastMCPInfo`
+
+
+Information extracted from a FastMCP instance.
+
diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx
index ad68473a0755bc23e0a33292dfc590922a3ea222..282c03745521703c97955e5bd72a087a6cfdc488 100644
--- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx
+++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx
@@ -7,7 +7,7 @@ sidebarTitle: json_schema
## Functions
-### `compress_schema`
+### `compress_schema`
```python
compress_schema(schema: dict, prune_params: list[str] | None = None, prune_defs: bool = True, prune_additional_properties: bool = True, prune_titles: bool = False) -> dict
diff --git a/docs/python-sdk/fastmcp-utilities-logging.mdx b/docs/python-sdk/fastmcp-utilities-logging.mdx
index 90e294f6aa0f9a7e1dfbe235dfc673156bd2ce05..03ca4a1bb5ce60204e89b9b385981cafa36ef31f 100644
--- a/docs/python-sdk/fastmcp-utilities-logging.mdx
+++ b/docs/python-sdk/fastmcp-utilities-logging.mdx
@@ -10,7 +10,7 @@ Logging utilities for FastMCP.
## Functions
-### `get_logger`
+### `get_logger`
```python
get_logger(name: str) -> logging.Logger
@@ -26,7 +26,7 @@ Get a logger nested under FastMCP namespace.
- a configured logger instance
-### `configure_logging`
+### `configure_logging`
```python
configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool = True) -> None
diff --git a/docs/python-sdk/fastmcp-utilities-mcp_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_config.mdx
index b74dfcfa0089bfdce5eb4c5694022e2bef177cdc..fe1d6f1566bf9a650d207a356d1b9357439194af 100644
--- a/docs/python-sdk/fastmcp-utilities-mcp_config.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_config.mdx
@@ -7,10 +7,10 @@ sidebarTitle: mcp_config
## Functions
-### `infer_transport_type_from_url`
+### `infer_transport_type_from_url`
```python
-infer_transport_type_from_url(url: str | AnyUrl) -> Literal['streamable-http', 'sse']
+infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse']
```
@@ -19,31 +19,31 @@ Infer the appropriate transport type from the given URL.
## Classes
-### `StdioMCPServer`
+### `StdioMCPServer`
**Methods:**
-#### `to_transport`
+#### `to_transport`
```python
to_transport(self) -> StdioTransport
```
-### `RemoteMCPServer`
+### `RemoteMCPServer`
**Methods:**
-#### `to_transport`
+#### `to_transport`
```python
to_transport(self) -> StreamableHttpTransport | SSETransport
```
-### `MCPConfig`
+### `MCPConfig`
**Methods:**
-#### `from_dict`
+#### `from_dict`
```python
from_dict(cls, config: dict[str, Any]) -> MCPConfig
diff --git a/docs/python-sdk/fastmcp-utilities-openapi.mdx b/docs/python-sdk/fastmcp-utilities-openapi.mdx
index 7b7d0aa6217b61e824136cc096297916c37adbe6..e64157c681aa32ffe23a8b4df8e510815b4d80c4 100644
--- a/docs/python-sdk/fastmcp-utilities-openapi.mdx
+++ b/docs/python-sdk/fastmcp-utilities-openapi.mdx
@@ -7,7 +7,7 @@ sidebarTitle: openapi
## Functions
-### `parse_openapi_to_http_routes`
+### `parse_openapi_to_http_routes`
```python
parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]
@@ -20,7 +20,7 @@ using the openapi-pydantic library.
Supports both OpenAPI 3.0.x and 3.1.x versions.
-### `clean_schema_for_display`
+### `clean_schema_for_display`
```python
clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None
@@ -30,7 +30,7 @@ clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None
Clean up a schema dictionary for display by removing internal/complex fields.
-### `generate_example_from_schema`
+### `generate_example_from_schema`
```python
generate_example_from_schema(schema: JsonSchema | None) -> Any
@@ -41,7 +41,7 @@ Generate a simple example value from a JSON schema dictionary.
Very basic implementation focusing on types.
-### `format_json_for_description`
+### `format_json_for_description`
```python
format_json_for_description(data: Any, indent: int = 2) -> str
@@ -51,7 +51,7 @@ format_json_for_description(data: Any, indent: int = 2) -> str
Formats Python data as a JSON string block for markdown.
-### `format_description_with_responses`
+### `format_description_with_responses`
```python
format_description_with_responses(base_description: str, responses: dict[str, Any], parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str
@@ -76,31 +76,31 @@ including its description, whether it is required, and its content schema.
## Classes
-### `ParameterInfo`
+### `ParameterInfo`
Represents a single parameter for an HTTP operation in our IR.
-### `RequestBodyInfo`
+### `RequestBodyInfo`
Represents the request body for an HTTP operation in our IR.
-### `ResponseInfo`
+### `ResponseInfo`
Represents response information in our IR.
-### `HTTPRoute`
+### `HTTPRoute`
Intermediate Representation for a single OpenAPI operation.
-### `OpenAPIParser`
+### `OpenAPIParser`
Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1.
@@ -108,7 +108,7 @@ Unified parser for OpenAPI schemas with generic type parameters to handle both 3
**Methods:**
-#### `parse`
+#### `parse`
```python
parse(self) -> list[HTTPRoute]
diff --git a/docs/python-sdk/fastmcp-utilities-tests.mdx b/docs/python-sdk/fastmcp-utilities-tests.mdx
new file mode 100644
index 0000000000000000000000000000000000000000..78e0180c11ea7f3ed8ef05a5b4076a5f92bc0891
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-tests.mdx
@@ -0,0 +1,42 @@
+---
+title: tests
+sidebarTitle: tests
+---
+
+# `fastmcp.utilities.tests`
+
+## Functions
+
+### `temporary_settings`
+
+```python
+temporary_settings(**kwargs: Any)
+```
+
+
+Temporarily override FastMCP setting values.
+
+**Args:**
+- `**kwargs`: The settings to override, including nested settings.
+
+
+### `run_server_in_process`
+
+```python
+run_server_in_process(server_fn: Callable[..., None], *args, **kwargs) -> Generator[str, None, None]
+```
+
+
+Context manager that runs a FastMCP server in a separate process and
+returns the server URL. When the context manager is exited, the server process is killed.
+
+**Args:**
+- `server_fn`: The function that runs a FastMCP server. FastMCP servers are
+not pickleable, so we need a function that creates and runs one.
+- `*args`: Arguments to pass to the server function.
+- `provide_host_and_port`: Whether to provide the host and port to the server function as kwargs.
+- `**kwargs`: Keyword arguments to pass to the server function.
+
+**Returns:**
+- The server URL.
+
diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx
index 3810bb878042f7fce7d0b8c4748daf56ed3c7f63..19a5b7b45eeb928a18ad05854d06fb6e740ee05c 100644
--- a/docs/python-sdk/fastmcp-utilities-types.mdx
+++ b/docs/python-sdk/fastmcp-utilities-types.mdx
@@ -10,7 +10,7 @@ Common types used across FastMCP.
## Functions
-### `get_cached_typeadapter`
+### `get_cached_typeadapter`
```python
get_cached_typeadapter(cls: T) -> TypeAdapter[T]
@@ -23,7 +23,7 @@ However, this isn't feasible for user-generated functions. Instead, we use a
cache to minimize the cost of creating them as much as possible.
-### `issubclass_safe`
+### `issubclass_safe`
```python
issubclass_safe(cls: type, base: type) -> bool
@@ -33,7 +33,7 @@ issubclass_safe(cls: type, base: type) -> bool
Check if cls is a subclass of base, even if cls is a type variable.
-### `is_class_member_of_type`
+### `is_class_member_of_type`
```python
is_class_member_of_type(cls: type, base: type) -> bool
@@ -46,7 +46,7 @@ Base can be a type, a UnionType, or an Annotated type. Generic types are not
considered members (e.g. T is not a member of list\[T]).
-### `find_kwarg_by_type`
+### `find_kwarg_by_type`
```python
find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None
@@ -60,13 +60,13 @@ Includes union types that contain the kwarg_type, as well as Annotated types.
## Classes
-### `FastMCPBaseModel`
+### `FastMCPBaseModel`
Base model for FastMCP models.
-### `Image`
+### `Image`
Helper class for returning images from tools.
@@ -74,7 +74,7 @@ Helper class for returning images from tools.
**Methods:**
-#### `to_image_content`
+#### `to_image_content`
```python
to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> ImageContent
@@ -83,7 +83,7 @@ to_image_content(self, mime_type: str | None = None, annotations: Annotations |
Convert to MCP ImageContent.
-### `Audio`
+### `Audio`
Helper class for returning audio from tools.
@@ -91,13 +91,13 @@ Helper class for returning audio from tools.
**Methods:**
-#### `to_audio_content`
+#### `to_audio_content`
```python
to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> AudioContent
```
-### `File`
+### `File`
Helper class for returning audio from tools.
@@ -105,7 +105,7 @@ Helper class for returning audio from tools.
**Methods:**
-#### `to_resource_content`
+#### `to_resource_content`
```python
to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> EmbeddedResource
diff --git a/docs/servers/auth/bearer.mdx b/docs/servers/auth/bearer.mdx
index 900bebe5ab41764de605621165fb927bcc5e6ce8..15df171f6b5522ee6b7a80f3553be0933ed2835c 100644
--- a/docs/servers/auth/bearer.mdx
+++ b/docs/servers/auth/bearer.mdx
@@ -19,7 +19,7 @@ The [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26
Bearer Token authentication is a common way to secure HTTP-based APIs. In this model, the client sends a token (usually a JSON Web Token or JWT) in the `Authorization` header with the "Bearer" scheme. The server then validates this token to grant or deny access.
-FastMCP supports Bearer Token authentication for its HTTP-based transports (`streamable-http` and `sse`), allowing you to protect your server from unauthorized access.
+FastMCP supports Bearer Token authentication for its HTTP-based transports (`http` and `sse`), allowing you to protect your server from unauthorized access.
## Authentication Strategy
@@ -61,13 +61,27 @@ mcp = FastMCP(name="My MCP Server", auth=auth)
### Configuration Parameters
-| Parameter | Type | Required | Description |
-|-----------|------|----------|-------------|
-| `public_key` | `str` | If `jwks_uri` is not provided | RSA public key in PEM format for static key validation |
-| `jwks_uri` | `str` | If `public_key` is not provided | URL for JSON Web Key Set endpoint |
-| `issuer` | `str` | No | Expected JWT `iss` claim value |
-| `audience` | `str` | No | Expected JWT `aud` claim value |
-| `required_scopes` | `list[str]` | No | Global scopes required for all requests |
+
+
+ RSA public key in PEM format for static key validation. Required if `jwks_uri` is not provided
+
+
+
+ URL for JSON Web Key Set endpoint. Required if `public_key` is not provided
+
+
+
+ Expected JWT `iss` claim value
+
+
+
+ Expected JWT `aud` claim value
+
+
+
+ Global scopes required for all requests
+
+
#### Public Key
@@ -141,15 +155,35 @@ print(f"Test token: {token}")
The `create_token()` method accepts these parameters:
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| `subject` | `str` | `"fastmcp-user"` | JWT subject claim (usually user ID) |
-| `issuer` | `str` | `"https://fastmcp.example.com"` | JWT issuer claim |
-| `audience` | `str` | `None` | JWT audience claim |
-| `scopes` | `list[str]` | `None` | OAuth scopes to include |
-| `expires_in_seconds` | `int` | `3600` | Token expiration time |
-| `additional_claims` | `dict` | `None` | Extra claims to include |
-| `kid` | `str` | `None` | Key ID for JWKS lookup |
+
+
+ JWT subject claim (usually user ID)
+
+
+
+ JWT issuer claim
+
+
+
+ JWT audience claim
+
+
+
+ OAuth scopes to include
+
+
+
+ Token expiration time in seconds
+
+
+
+ Extra claims to include in the token
+
+
+
+ Key ID for JWKS lookup
+
+
## Accessing Token Claims
@@ -179,10 +213,21 @@ async def get_my_data(ctx: Context) -> dict:
### AccessToken Properties
-| Property | Type | Description |
-|----------|------|-------------|
-| `token` | `str` | The raw JWT string |
-| `client_id` | `str` | Authenticated principal identifier |
-| `scopes` | `list[str]` | Granted scopes |
-| `expires_at` | `datetime \| None` | Token expiration timestamp |
+
+
+ The raw JWT string
+
+
+
+ Authenticated principal identifier
+
+
+
+ Granted scopes
+
+
+
+ Token expiration timestamp
+
+
diff --git a/docs/servers/middleware.mdx b/docs/servers/middleware.mdx
index 51ddd9327d95701a235ba60882bb05a3a07f8ba0..078f330ea6e6debc037ad719544467a3b572b9c2 100644
--- a/docs/servers/middleware.mdx
+++ b/docs/servers/middleware.mdx
@@ -329,93 +329,246 @@ parent.mount(child, prefix="child")
When a client calls "child_tool", the request will flow through the parent's authentication middleware first, then route to the child server where it will go through the child's logging middleware.
-## Examples
+## Built-in Middleware Examples
-### Authentication Middleware
+FastMCP includes several middleware implementations that demonstrate best practices and provide immediately useful functionality. Let's explore how each type works by building simplified versions, then see how to use the full implementations.
-This middleware checks for a valid authorization token on all requests:
+### Timing Middleware
+
+Performance monitoring is essential for understanding your server's behavior and identifying bottlenecks. FastMCP includes timing middleware at `fastmcp.server.middleware.timing`.
+
+Here's an example of how it works:
```python
+import time
from fastmcp.server.middleware import Middleware, MiddlewareContext
-from fastmcp.exceptions import ToolError
-class AuthenticationMiddleware(Middleware):
- def __init__(self, required_token: str):
- self.required_token = required_token
+class SimpleTimingMiddleware(Middleware):
+ async def on_request(self, context: MiddlewareContext, call_next):
+ start_time = time.perf_counter()
+
+ try:
+ result = await call_next(context)
+ duration_ms = (time.perf_counter() - start_time) * 1000
+ print(f"Request {context.method} completed in {duration_ms:.2f}ms")
+ return result
+ except Exception as e:
+ duration_ms = (time.perf_counter() - start_time) * 1000
+ print(f"Request {context.method} failed after {duration_ms:.2f}ms: {e}")
+ raise
+```
+
+To use the full version with proper logging and configuration:
+
+```python
+from fastmcp.server.middleware.timing import (
+ TimingMiddleware,
+ DetailedTimingMiddleware
+)
+
+# Basic timing for all requests
+mcp.add_middleware(TimingMiddleware())
+
+# Detailed per-operation timing (tools, resources, prompts)
+mcp.add_middleware(DetailedTimingMiddleware())
+```
+
+The built-in versions include custom logger support, proper formatting, and **DetailedTimingMiddleware** provides operation-specific hooks like `on_call_tool` and `on_read_resource` for granular timing.
+
+### Logging Middleware
+
+Request and response logging is crucial for debugging, monitoring, and understanding usage patterns in your MCP server. FastMCP provides comprehensive logging middleware at `fastmcp.server.middleware.logging`.
+
+Here's an example of how it works:
+
+```python
+from fastmcp.server.middleware import Middleware, MiddlewareContext
+
+class SimpleLoggingMiddleware(Middleware):
+ async def on_message(self, context: MiddlewareContext, call_next):
+ print(f"Processing {context.method} from {context.source}")
+
+ try:
+ result = await call_next(context)
+ print(f"Completed {context.method}")
+ return result
+ except Exception as e:
+ print(f"Failed {context.method}: {e}")
+ raise
+```
+
+To use the full versions with advanced features:
+
+```python
+from fastmcp.server.middleware.logging import (
+ LoggingMiddleware,
+ StructuredLoggingMiddleware
+)
+
+# Human-readable logging with payload support
+mcp.add_middleware(LoggingMiddleware(
+ include_payloads=True,
+ max_payload_length=1000
+))
+
+# JSON-structured logging for log aggregation tools
+mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True))
+```
+
+The built-in versions include payload logging, structured JSON output, custom logger support, payload size limits, and operation-specific hooks for granular control.
+
+### Rate Limiting Middleware
+
+Rate limiting is essential for protecting your server from abuse, ensuring fair resource usage, and maintaining performance under load. FastMCP includes sophisticated rate limiting middleware at `fastmcp.server.middleware.rate_limiting`.
+
+Here's an example of how it works:
+
+```python
+import time
+from collections import defaultdict
+from fastmcp.server.middleware import Middleware, MiddlewareContext
+from mcp import McpError
+from mcp.types import ErrorData
+
+class SimpleRateLimitMiddleware(Middleware):
+ def __init__(self, requests_per_minute: int = 60):
+ self.requests_per_minute = requests_per_minute
+ self.client_requests = defaultdict(list)
async def on_request(self, context: MiddlewareContext, call_next):
- if hasattr(context, 'fastmcp_context') and context.fastmcp_context:
- try:
- request = context.fastmcp_context.get_http_request()
- auth_header = request.headers.get("Authorization")
-
- if not auth_header or not auth_header.startswith("Bearer "):
- raise ToolError("Missing or invalid authorization header")
-
- token = auth_header.split(" ", 1)[1]
- if token != self.required_token:
- raise ToolError("Invalid authentication token")
-
- except Exception:
- pass
+ current_time = time.time()
+ client_id = "default" # In practice, extract from headers or context
+
+ # Clean old requests and check limit
+ cutoff_time = current_time - 60
+ self.client_requests[client_id] = [
+ req_time for req_time in self.client_requests[client_id]
+ if req_time > cutoff_time
+ ]
+
+ if len(self.client_requests[client_id]) >= self.requests_per_minute:
+ raise McpError(ErrorData(code=-32000, message="Rate limit exceeded"))
+ self.client_requests[client_id].append(current_time)
return await call_next(context)
+```
+
+To use the full versions with advanced algorithms:
-# Usage
-mcp = FastMCP("SecureServer")
-mcp.add_middleware(AuthenticationMiddleware("secret-token-123"))
+```python
+from fastmcp.server.middleware.rate_limiting import (
+ RateLimitingMiddleware,
+ SlidingWindowRateLimitingMiddleware
+)
+
+# Token bucket rate limiting (allows controlled bursts)
+mcp.add_middleware(RateLimitingMiddleware(
+ max_requests_per_second=10.0,
+ burst_capacity=20
+))
+
+# Sliding window rate limiting (precise time-based control)
+mcp.add_middleware(SlidingWindowRateLimitingMiddleware(
+ max_requests=100,
+ window_minutes=1
+))
```
-### Performance Monitoring Middleware
+The built-in versions include token bucket algorithms, per-client identification, global rate limiting, and async-safe implementations with configurable client identification functions.
-This middleware tracks how long tools take to execute:
+### Error Handling Middleware
+
+Consistent error handling and recovery is critical for robust MCP servers. FastMCP provides comprehensive error handling middleware at `fastmcp.server.middleware.error_handling`.
+
+Here's an example of how it works:
```python
-import time
import logging
+from fastmcp.server.middleware import Middleware, MiddlewareContext
-class PerformanceMiddleware(Middleware):
+class SimpleErrorHandlingMiddleware(Middleware):
def __init__(self):
- self.logger = logging.getLogger("performance")
+ self.logger = logging.getLogger("errors")
+ self.error_counts = {}
- async def on_call_tool(self, context: MiddlewareContext, call_next):
- tool_name = context.message.name
- start_time = time.time()
-
+ async def on_message(self, context: MiddlewareContext, call_next):
try:
- result = await call_next(context)
- execution_time = time.time() - start_time
-
- self.logger.info(
- f"Tool {tool_name} completed in {execution_time:.3f}s"
- )
-
- return result
+ return await call_next(context)
+ except Exception as error:
+ # Log the error and track statistics
+ error_key = f"{type(error).__name__}:{context.method}"
+ self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
- except Exception as e:
- execution_time = time.time() - start_time
- self.logger.error(
- f"Tool {tool_name} failed after {execution_time:.3f}s: {e}"
- )
+ self.logger.error(f"Error in {context.method}: {type(error).__name__}: {error}")
raise
```
-### Request Transformation Middleware
+To use the full versions with advanced features:
+
+```python
+from fastmcp.server.middleware.error_handling import (
+ ErrorHandlingMiddleware,
+ RetryMiddleware
+)
+
+# Comprehensive error logging and transformation
+mcp.add_middleware(ErrorHandlingMiddleware(
+ include_traceback=True,
+ transform_errors=True,
+ error_callback=my_error_callback
+))
+
+# Automatic retry with exponential backoff
+mcp.add_middleware(RetryMiddleware(
+ max_retries=3,
+ retry_exceptions=(ConnectionError, TimeoutError)
+))
+```
+
+The built-in versions include error transformation, custom callbacks, configurable retry logic, and proper MCP error formatting.
+
+### Combining Middleware
-This middleware adds metadata to tool calls:
+These middleware work together seamlessly:
```python
-class TransformationMiddleware(Middleware):
- async def on_call_tool(self, context: MiddlewareContext, call_next):
- if hasattr(context.message, 'arguments'):
- args = context.message.arguments or {}
- args['_middleware_timestamp'] = context.timestamp.isoformat()
-
- modified_context = context.copy(
- message=context.message.model_copy(update={'arguments': args})
- )
- else:
- modified_context = context
+from fastmcp import FastMCP
+from fastmcp.server.middleware.timing import TimingMiddleware
+from fastmcp.server.middleware.logging import LoggingMiddleware
+from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware
+from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
+
+mcp = FastMCP("Production Server")
+
+# Add middleware in logical order
+mcp.add_middleware(ErrorHandlingMiddleware()) # Handle errors first
+mcp.add_middleware(RateLimitingMiddleware(max_requests_per_second=50))
+mcp.add_middleware(TimingMiddleware()) # Time actual execution
+mcp.add_middleware(LoggingMiddleware()) # Log everything
+
+@mcp.tool
+def my_tool(data: str) -> str:
+ return f"Processed: {data}"
+```
+
+This configuration provides comprehensive monitoring, protection, and observability for your MCP server.
+
+### Custom Middleware Example
+
+You can also create custom middleware by extending the base class:
+
+```python
+from fastmcp.server.middleware import Middleware, MiddlewareContext
+
+class CustomHeaderMiddleware(Middleware):
+ async def on_request(self, context: MiddlewareContext, call_next):
+ # Add custom logic here
+ print(f"Processing {context.method}")
- return await call_next(modified_context)
+ result = await call_next(context)
+
+ print(f"Completed {context.method}")
+ return result
+
+mcp.add_middleware(CustomHeaderMiddleware())
```
\ No newline at end of file
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index 80c79978134f7bc7b934564aa8369345c35cca38..f296cb74ec3dbab3f833b3dbdea2b1b33716c2c6 100644
--- a/docs/servers/prompts.mdx
+++ b/docs/servers/prompts.mdx
@@ -57,6 +57,41 @@ def generate_code_request(language: str, task_description: str) -> PromptMessage
Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
+#### Decorator Arguments
+
+While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.prompt` decorator:
+
+```python
+@mcp.prompt(
+ name="analyze_data_request", # Custom prompt name
+ description="Creates a request to analyze data with specific parameters", # Custom description
+ tags={"analysis", "data"} # Optional categorization tags
+)
+def data_analysis_prompt(
+ data_uri: str = Field(description="The URI of the resource containing the data."),
+ analysis_type: str = Field(default="summary", description="Type of analysis.")
+) -> str:
+ """This docstring is ignored when description is provided."""
+ return f"Please perform a '{analysis_type}' analysis on the data found at {data_uri}."
+```
+
+
+
+ Sets the explicit prompt name exposed via MCP. If not provided, uses the function name
+
+
+
+ Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose
+
+
+
+ A set of strings used to categorize the prompt. Clients might use tags to filter or group available prompts
+
+
+
+ A boolean to enable or disable the prompt. See [Disabling Prompts](#disabling-prompts) for more information
+
+
### Argument Types
@@ -177,28 +212,6 @@ def data_analysis_prompt(
In this example, the client *must* provide `data_uri`. If `analysis_type` or `include_charts` are omitted, their default values will be used.
-### Prompt Metadata
-
-While FastMCP infers the name and description from your function, you can override these and add tags using arguments to the `@mcp.prompt` decorator:
-
-```python
-@mcp.prompt(
- name="analyze_data_request", # Custom prompt name
- description="Creates a request to analyze data with specific parameters", # Custom description
- tags={"analysis", "data"} # Optional categorization tags
-)
-def data_analysis_prompt(
- data_uri: str = Field(description="The URI of the resource containing the data."),
- analysis_type: str = Field(default="summary", description="Type of analysis.")
-) -> str:
- """This docstring is ignored when description is provided."""
- return f"Please perform a '{analysis_type}' analysis on the data found at {data_uri}."
-```
-
-- **`name`**: Sets the explicit prompt name exposed via MCP.
-- **`description`**: Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose.
-- **`tags`**: A set of strings used to categorize the prompt. Clients *might* use tags to filter or group available prompts.
-- **`enabled`**: A boolean to enable or disable the prompt (defaults to `True`). See [Disabling Prompts](#disabling-prompts) for more information.
### Disabling Prompts
diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx
index 5a9bffccd32037986cc1e4efa5af3c81367cca38..5ebff6a04cc624234e8cedb5da5c404673fc5e90 100644
--- a/docs/servers/proxy.mdx
+++ b/docs/servers/proxy.mdx
@@ -118,7 +118,7 @@ config = {
"mcpServers": {
"default": { # For single server configs, 'default' is commonly used
"url": "https://example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
}
}
}
@@ -145,11 +145,11 @@ config = {
"mcpServers": {
"weather": {
"url": "https://weather-api.example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
},
"calendar": {
"url": "https://calendar-api.example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
}
}
}
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index f38834980d31c47b189fd964032e1ed7743a397a..127f7080536d0d839656dcf46fc49d47d681302b 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -58,18 +58,9 @@ def get_config() -> dict:
* Resource Name: Taken from the function name (`get_greeting`).
* Resource Description: Taken from the function's docstring.
-### Return Values
-
-FastMCP automatically converts your function's return value into the appropriate MCP resource content:
-
-- **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default).
-- **`dict`, `list`, `pydantic.BaseModel`**: Automatically serialized to a JSON string and sent as `TextResourceContents` (with `mime_type="application/json"` by default).
-- **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`).
-- **`None`**: Results in an empty resource content list being returned.
-
-### Resource Metadata
+#### Decorator Arguments
-You can customize the resource's properties using arguments in the decorator:
+You can customize the resource's properties using arguments in the `@mcp.resource` decorator:
```python
from fastmcp import FastMCP
@@ -89,12 +80,40 @@ def get_application_status() -> dict:
return {"status": "ok", "uptime": 12345, "version": mcp.settings.version} # Example usage
```
-- **`uri`**: The unique identifier for the resource (required).
-- **`name`**: A human-readable name (defaults to function name).
-- **`description`**: Explanation of the resource (defaults to docstring).
-- **`mime_type`**: Specifies the content type (FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types).
-- **`tags`**: A set of strings for categorization, potentially used by clients for filtering.
-- **`enabled`**: A boolean to enable or disable the resource (defaults to `True`). See [Disabling Resources](#disabling-resources) for more information.
+
+
+ The unique identifier for the resource
+
+
+
+ A human-readable name. If not provided, defaults to function name
+
+
+
+ Explanation of the resource. If not provided, defaults to docstring
+
+
+
+ Specifies the content type. FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types
+
+
+
+ A set of strings for categorization, potentially used by clients for filtering
+
+
+
+ A boolean to enable or disable the resource. See [Disabling Resources](#disabling-resources) for more information
+
+
+
+### Return Values
+
+FastMCP automatically converts your function's return value into the appropriate MCP resource content:
+
+- **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default).
+- **`dict`, `list`, `pydantic.BaseModel`**: Automatically serialized to a JSON string and sent as `TextResourceContents` (with `mime_type="application/json"` by default).
+- **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`).
+- **`None`**: Results in an empty resource content list being returned.
### Disabling Resources
diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx
index 1cb5f089b5858d0c2421c05992301908aee9e1ed..df3dfd2b0bbb6f55e72f2b43a01b1c2ccb4425c6 100644
--- a/docs/servers/server.mdx
+++ b/docs/servers/server.mdx
@@ -31,13 +31,31 @@ mcp_with_instructions = FastMCP(
The `FastMCP` constructor accepts several arguments:
-* `name`: (Optional) A human-readable name for your server. Defaults to "FastMCP".
-* `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality.
-* `lifespan`: (Optional) An async context manager function for server startup and shutdown logic.
-* `tags`: (Optional) A set of strings to tag the server itself.
-* `tools`: (Optional) A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator.
-* `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration
-
+
+
+ A human-readable name for your server
+
+
+
+ Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality
+
+
+
+ An async context manager function for server startup and shutdown logic
+
+
+
+ A set of strings to tag the server itself
+
+
+
+ A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator
+
+
+
+ Keyword arguments corresponding to additional `ServerSettings` configuration
+
+
## Components
FastMCP servers expose several types of components to the client:
@@ -158,8 +176,8 @@ if __name__ == "__main__":
# This runs the server, defaulting to STDIO transport
mcp.run()
- # To use a different transport, e.g., HTTP:
- # mcp.run(transport="streamable-http", host="127.0.0.1", port=9000)
+ # To use a different transport, e.g., Streamable HTTP:
+ # mcp.run(transport="http", host="127.0.0.1", port=9000)
```
FastMCP supports several transport options:
@@ -235,6 +253,34 @@ mcp = FastMCP(
)
```
+### Constructor Parameters
+
+
+
+ Optional server dependencies list with package specifications
+
+
+
+ Only expose components with at least one matching tag
+
+
+
+ Hide components with any matching tag
+
+
+
+ How to handle duplicate tool registrations
+
+
+
+ How to handle duplicate resource registrations
+
+
+
+ How to handle duplicate prompt registrations
+
+
+
### Global Settings
Global settings affect all FastMCP servers and can be configured via environment variables (prefixed with `FASTMCP_`) or in a `.env` file:
@@ -260,7 +306,7 @@ Transport settings are provided when running the server and control network beha
```python
# Configure transport when running
mcp.run(
- transport="streamable-http",
+ transport="http",
host="0.0.0.0", # Bind to all interfaces
port=9000, # Custom port
log_level="DEBUG", # Override global log level
@@ -268,7 +314,7 @@ mcp.run(
# Or for async usage
await mcp.run_async(
- transport="streamable-http",
+ transport="http",
host="127.0.0.1",
port=8080,
)
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 300a6c7dd6865381b5cd50b61dd91771420fa632..b136e97028e0a838693a775344d9b691ef3f1f07 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -49,9 +49,68 @@ The way you define your Python function dictates how the tool appears and behave
Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
-### Parameters
+#### Decorator Arguments
-#### Annotations
+While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.tool` decorator:
+
+```python
+@mcp.tool(
+ name="find_products", # Custom tool name for the LLM
+ description="Search the product catalog with optional category filtering.", # Custom description
+ tags={"catalog", "search"}, # Optional tags for organization/filtering
+)
+def search_products_implementation(query: str, category: str | None = None) -> list[dict]:
+ """Internal function description (ignored if description is provided above)."""
+ # Implementation...
+ print(f"Searching for '{query}' in category '{category}'")
+ return [{"id": 2, "name": "Another Product"}]
+```
+
+
+
+ Sets the explicit tool name exposed via MCP. If not provided, uses the function name
+
+
+
+ Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose
+
+
+
+ A set of strings to categorize the tool. Clients might use tags to filter or group available tools
+
+
+
+ A boolean to enable or disable the tool. See [Disabling Tools](#disabling-tools) for more information
+
+
+
+ A list of argument names to exclude from the tool schema shown to the LLM. See [Excluding Arguments](#excluding-arguments) for more information
+
+
+
+ An optional `ToolAnnotations` object or dictionary to add additional metadata about the tool.
+
+
+ A human-readable title for the tool.
+
+
+ If true, the tool does not modify its environment.
+
+
+ If true, the tool may perform destructive updates to its environment.
+
+
+ If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment.
+
+
+ If true, this tool may interact with an "open world" of external entities. If false, the tool's domain of interaction is closed.
+
+
+
+
+### Tool Parameters
+
+#### Type Annotations
Type annotations for parameters are essential for proper tool functionality. They:
1. Inform the LLM about the expected data types for each parameter
@@ -150,28 +209,6 @@ def search_products(
In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided.
-### Metadata
-
-While FastMCP infers the name and description from your function, you can override these and add tags using arguments to the `@mcp.tool` decorator:
-
-```python
-@mcp.tool(
- name="find_products", # Custom tool name for the LLM
- description="Search the product catalog with optional category filtering.", # Custom description
- tags={"catalog", "search"}, # Optional tags for organization/filtering
-)
-def search_products_implementation(query: str, category: str | None = None) -> list[dict]:
- """Internal function description (ignored if description is provided above)."""
- # Implementation...
- print(f"Searching for '{query}' in category '{category}'")
- return [{"id": 2, "name": "Another Product"}]
-```
-
-- **`name`**: Sets the explicit tool name exposed via MCP.
-- **`description`**: Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose.
-- **`tags`**: A set of strings to categorize the tool. Clients *might* use tags to filter or group available tools.
-- **`enabled`**: A boolean to enable or disable the tool (defaults to `True`). See [Disabling Tools](#disabling-tools) for more information.
-- **`exclude_args`**: A list of argument names to exclude from the tool schema shown to the LLM. See [Excluding Arguments](#excluding-arguments) for more information.
### Excluding Arguments
diff --git a/docs/tutorials/rest-api.mdx b/docs/tutorials/rest-api.mdx
index 1b6ae12887273de3ecb4e8a98117cb73e25b09cd..cb14536443cedfa72c05f67ecbc5d19681b6cc5a 100644
--- a/docs/tutorials/rest-api.mdx
+++ b/docs/tutorials/rest-api.mdx
@@ -82,7 +82,7 @@ mcp = FastMCP.from_openapi(
)
if __name__ == "__main__":
- mcp.run(transport="streamable-http", port=8000)
+ mcp.run(transport="http", port=8000)
```
And that's it! With just a few lines of code, you've created an MCP server that exposes the entire JSONPlaceholder API as a collection of tools.
@@ -195,7 +195,7 @@ mcp = FastMCP.from_openapi(
)
if __name__ == "__main__":
- mcp.run(transport="streamable-http", port=8000)
+ mcp.run(transport="http", port=8000)
```
With this configuration:
- `GET /users/{id}` becomes a `ResourceTemplate`.
diff --git a/docs/updates.mdx b/docs/updates.mdx
index 27f7afb3916e0f1e60146ece8101c19da7c5afc3..bbd2ede484e00a208a39ded5406e10b22c67a8c0 100644
--- a/docs/updates.mdx
+++ b/docs/updates.mdx
@@ -5,6 +5,22 @@ icon: "sparkles"
tag: NEW
---
+
+
+FastMCP 2.9 is a major release that, among other things, introduces two important features that push beyond the basic MCP protocol.
+
+š¤ *MCP Middleware* brings a flexible middleware system for intercepting and controlling server operations - think authentication, logging, rate limiting, and custom business logic without touching core protocol code.
+
+⨠*Server-side type conversion* for prompts solves a major developer pain point: while MCP requires string arguments, your functions can now work with native Python types like lists and dictionaries, with automatic conversion handling the complexity.
+
+These features transform FastMCP from a simple protocol implementation into a powerful framework for building sophisticated MCP applications. Combined with the new `File` utility for binary data and improvements to authentication and serialization, this release makes FastMCP significantly more flexible and developer-friendly while maintaining full protocol compliance.
+
+
+
-FastMCP 2.3 introduces full support for Streamable HTTP, a modern alternative to SSE that simplifies MCP deployments over the web. Itās efficient, reliable, and now the default HTTP transport. Just run your server with transport="streamable-http" and connect clients via a standard URLāFastMCP handles the rest. No special setup required. This release makes deploying MCP servers easier and more portable than ever.
+FastMCP 2.3 introduces full support for Streamable HTTP, a modern alternative to SSE that simplifies MCP deployments over the web. Itās efficient, reliable, and now the default HTTP transport. Just run your server with transport="http" and connect clients via a standard URLāFastMCP handles the rest. No special setup required. This release makes deploying MCP servers easier and more portable than ever.
diff --git a/examples/atproto_mcp/README.md b/examples/atproto_mcp/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..5d83d97bffb6d0e2c9a82d4f27c3746adb734ade
--- /dev/null
+++ b/examples/atproto_mcp/README.md
@@ -0,0 +1,156 @@
+# ATProto MCP Server
+
+This example demonstrates a FastMCP server that provides tools and resources for interacting with the AT Protocol (Bluesky).
+
+## Features
+
+### Resources (Read-only)
+
+- **atproto://profile/status**: Get connection status and profile information
+- **atproto://timeline**: Retrieve your timeline feed
+- **atproto://notifications**: Get recent notifications
+
+### Tools (Actions)
+
+- **post**: Create posts with rich features (text, images, quotes, replies, links, mentions)
+- **create_thread**: Post multi-part threads with automatic linking
+- **search**: Search for posts by query
+- **follow**: Follow users by handle
+- **like**: Like posts by URI
+- **repost**: Share posts by URI
+
+## Setup
+
+1. Create a `.env` file in the root directory with your Bluesky credentials:
+
+```bash
+ATPROTO_HANDLE=your.handle@bsky.social
+ATPROTO_PASSWORD=your-app-password
+ATPROTO_PDS_URL=https://bsky.social # optional, defaults to bsky.social
+```
+
+2. Install and run the server:
+
+```bash
+# Install dependencies
+uv pip install -e .
+
+# Run the server
+uv run atproto-mcp
+```
+
+## The Unified Post Tool
+
+The `post` tool is a single, flexible interface for all posting needs:
+
+```python
+async def post(
+ text: str, # Required: Post content
+ images: list[str] = None, # Optional: Image URLs (max 4)
+ image_alts: list[str] = None, # Optional: Alt text for images
+ links: list[RichTextLink] = None, # Optional: Embedded links
+ mentions: list[RichTextMention] = None, # Optional: User mentions
+ reply_to: str = None, # Optional: Reply to post URI
+ reply_root: str = None, # Optional: Thread root URI
+ quote: str = None, # Optional: Quote post URI
+)
+```
+
+### Usage Examples
+
+```python
+from fastmcp import Client
+from atproto_mcp.server import atproto_mcp
+
+async def demo():
+ async with Client(atproto_mcp) as client:
+ # Simple post
+ await client.call_tool("post", {
+ "text": "Hello from FastMCP!"
+ })
+
+ # Post with image
+ await client.call_tool("post", {
+ "text": "Beautiful sunset! š
",
+ "images": ["https://example.com/sunset.jpg"],
+ "image_alts": ["Sunset over the ocean"]
+ })
+
+ # Reply to a post
+ await client.call_tool("post", {
+ "text": "Great point!",
+ "reply_to": "at://did:plc:xxx/app.bsky.feed.post/yyy"
+ })
+
+ # Quote post
+ await client.call_tool("post", {
+ "text": "This is important:",
+ "quote": "at://did:plc:xxx/app.bsky.feed.post/yyy"
+ })
+
+ # Rich text with links and mentions
+ await client.call_tool("post", {
+ "text": "Check out FastMCP by @alternatebuild.dev",
+ "links": [{"text": "FastMCP", "url": "https://github.com/jlowin/fastmcp"}],
+ "mentions": [{"handle": "alternatebuild.dev", "display_text": "@alternatebuild.dev"}]
+ })
+
+ # Advanced: Quote with image
+ await client.call_tool("post", {
+ "text": "Adding visual context:",
+ "quote": "at://did:plc:xxx/app.bsky.feed.post/yyy",
+ "images": ["https://example.com/chart.png"]
+ })
+
+ # Advanced: Reply with rich text
+ await client.call_tool("post", {
+ "text": "I agree! See this article for more info",
+ "reply_to": "at://did:plc:xxx/app.bsky.feed.post/yyy",
+ "links": [{"text": "this article", "url": "https://example.com/article"}]
+ })
+
+ # Create a thread
+ await client.call_tool("create_thread", {
+ "posts": [
+ {"text": "Starting a thread about Python š§µ"},
+ {"text": "Python is great for rapid prototyping"},
+ {"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]}
+ ]
+ })
+```
+
+## AI Assistant Use Cases
+
+The unified API enables natural AI assistant interactions:
+
+- **"Reply to that post with these findings"** ā Uses `reply_to` with rich text
+- **"Share this article with commentary"** ā Uses `quote` with the article link
+- **"Post this chart with explanation"** ā Uses `images` with descriptive text
+- **"Start a thread about AI safety"** ā Uses `create_thread` for automatic linking
+
+## Architecture
+
+The server is organized as:
+- `server.py` - Public API with resources and tools
+- `_atproto/` - Private implementation module
+ - `_client.py` - ATProto client management
+ - `_posts.py` - Unified posting logic
+ - `_profile.py` - Profile operations
+ - `_read.py` - Timeline, search, notifications
+ - `_social.py` - Follow, like, repost
+- `types.py` - TypedDict definitions
+- `settings.py` - Configuration management
+
+## Running the Demo
+
+```bash
+# Run demo (read-only)
+uv run python demo.py
+
+# Run demo with posting enabled
+uv run python demo.py --post
+```
+
+## Security Note
+
+Store your Bluesky credentials securely in environment variables. Never commit credentials to version control.
\ No newline at end of file
diff --git a/examples/atproto_mcp/demo.py b/examples/atproto_mcp/demo.py
new file mode 100644
index 0000000000000000000000000000000000000000..22ab38853a7f54dc8eb1443967c541b6e00b4e8a
--- /dev/null
+++ b/examples/atproto_mcp/demo.py
@@ -0,0 +1,257 @@
+"""Demo script showing all ATProto MCP server capabilities."""
+
+import argparse
+import asyncio
+import json
+from typing import cast
+
+from atproto_mcp.server import atproto_mcp
+from atproto_mcp.types import (
+ NotificationsResult,
+ PostResult,
+ ProfileInfo,
+ SearchResult,
+ TimelineResult,
+)
+
+from fastmcp import Client
+
+
+async def main(enable_posting: bool = False):
+ print("šµ ATProto MCP Server Demo\n")
+
+ async with Client(atproto_mcp) as client:
+ # 1. Check connection status (resource)
+ print("1. Checking connection status...")
+ result = await client.read_resource("atproto://profile/status")
+ status: ProfileInfo = (
+ json.loads(result[0].text) if result else cast(ProfileInfo, {})
+ )
+
+ if status.get("connected"):
+ print(f"ā
Connected as: @{status['handle']}")
+ print(f" Followers: {status['followers']}")
+ print(f" Following: {status['following']}")
+ print(f" Posts: {status['posts']}")
+ else:
+ print(f"ā Connection failed: {status.get('error')}")
+ return
+
+ # 2. Get timeline
+ print("\n2. Getting timeline...")
+ result = await client.read_resource("atproto://timeline")
+ timeline: TimelineResult = (
+ json.loads(result[0].text) if result else cast(TimelineResult, {})
+ )
+
+ if timeline.get("success") and timeline["posts"]:
+ print(f"ā
Found {timeline['count']} posts")
+ post = timeline["posts"][0]
+ print(f" Latest by @{post['author']}: {post['text'][:80]}...")
+ save_uri = post["uri"] # Save for later interactions
+ else:
+ print("ā No posts in timeline")
+ save_uri = None
+
+ # 3. Search for posts
+ print("\n3. Searching for posts about 'Bluesky'...")
+ result = await client.call_tool("search", {"query": "Bluesky", "limit": 5})
+ search: SearchResult = (
+ json.loads(result[0].text) if result else cast(SearchResult, {})
+ )
+
+ if search.get("success") and search["posts"]:
+ print(f"ā
Found {search['count']} posts")
+ print(f" Sample: {search['posts'][0]['text'][:80]}...")
+
+ # 4. Get notifications
+ print("\n4. Checking notifications...")
+ result = await client.read_resource("atproto://notifications")
+ notifs: NotificationsResult = (
+ json.loads(result[0].text) if result else cast(NotificationsResult, {})
+ )
+
+ if notifs.get("success"):
+ print(f"ā
You have {notifs['count']} notifications")
+ unread = sum(1 for n in notifs["notifications"] if not n["is_read"])
+ if unread:
+ print(f" ({unread} unread)")
+
+ # 5. Demo posting capabilities
+ if enable_posting:
+ print("\n5. Demonstrating posting capabilities...")
+
+ # a. Simple post
+ print("\n a) Creating a simple post...")
+ result = await client.call_tool(
+ "post",
+ {"text": "š§Ŗ Testing the unified ATProto MCP post tool! #FastMCP"},
+ )
+ post_result: PostResult = json.loads(result[0].text) if result else {}
+ if post_result.get("success"):
+ print(" ā
Posted successfully!")
+ simple_uri = post_result["uri"]
+ else:
+ print(f" ā Failed: {post_result.get('error')}")
+ simple_uri = None
+
+ # b. Post with rich text (link and mention)
+ print("\n b) Creating a post with rich text...")
+ result = await client.call_tool(
+ "post",
+ {
+ "text": "Check out FastMCP and follow @alternatebuild.dev for updates!",
+ "links": [
+ {"text": "FastMCP", "url": "https://github.com/jlowin/fastmcp"}
+ ],
+ "mentions": [
+ {
+ "handle": "alternatebuild.dev",
+ "display_text": "@alternatebuild.dev",
+ }
+ ],
+ },
+ )
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Rich text post created!")
+
+ # c. Reply to a post
+ if save_uri:
+ print("\n c) Replying to a post...")
+ result = await client.call_tool(
+ "post", {"text": "Great post! š", "reply_to": save_uri}
+ )
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Reply posted!")
+
+ # d. Quote post
+ if simple_uri:
+ print("\n d) Creating a quote post...")
+ result = await client.call_tool(
+ "post",
+ {
+ "text": "Quoting my own test post for demo purposes š",
+ "quote": simple_uri,
+ },
+ )
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Quote post created!")
+
+ # e. Post with image
+ print("\n e) Creating a post with image...")
+ result = await client.call_tool(
+ "post",
+ {
+ "text": "Here's a test image post! šø",
+ "images": ["https://picsum.photos/400/300"],
+ "image_alts": ["Random test image"],
+ },
+ )
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Image post created!")
+
+ # f. Quote with image (advanced)
+ if simple_uri:
+ print("\n f) Creating a quote post with image...")
+ result = await client.call_tool(
+ "post",
+ {
+ "text": "Quote + image combo! šØ",
+ "quote": simple_uri,
+ "images": ["https://picsum.photos/300/200"],
+ "image_alts": ["Another test image"],
+ },
+ )
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Quote with image created!")
+
+ # g. Social actions
+ if save_uri:
+ print("\n g) Demonstrating social actions...")
+
+ # Like
+ result = await client.call_tool("like", {"uri": save_uri})
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Liked a post!")
+
+ # Repost
+ result = await client.call_tool("repost", {"uri": save_uri})
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Reposted!")
+
+ # Follow
+ result = await client.call_tool(
+ "follow", {"handle": "alternatebuild.dev"}
+ )
+ if json.loads(result[0].text).get("success"):
+ print(" ā
Followed @alternatebuild.dev!")
+
+ # h. Thread creation (new!)
+ print("\n h) Creating a thread...")
+ result = await client.call_tool(
+ "create_thread",
+ {
+ "posts": [
+ {
+ "text": "Let me share some thoughts about the ATProto MCP server š§µ"
+ },
+ {
+ "text": "First, it makes posting from the terminal incredibly smooth"
+ },
+ {
+ "text": "The unified post API means one tool handles everything",
+ "links": [
+ {
+ "text": "everything",
+ "url": "https://github.com/jlowin/fastmcp",
+ }
+ ],
+ },
+ {
+ "text": "And now with create_thread, multi-post threads are trivial!"
+ },
+ ]
+ },
+ )
+ if json.loads(result[0].text).get("success"):
+ thread_result = json.loads(result[0].text)
+ print(f" ā
Thread created with {thread_result['post_count']} posts!")
+ else:
+ print("\n5. Posting capabilities (not enabled):")
+ print(" To test posting, run with --post flag")
+ print(" Example: python demo.py --post")
+
+ # 6. Show available capabilities
+ print("\n6. Available capabilities:")
+ print("\n Resources (read-only):")
+ print(" - atproto://profile/status")
+ print(" - atproto://timeline")
+ print(" - atproto://notifications")
+
+ print("\n Tools (actions):")
+ print(" - post: Unified posting with rich features")
+ print(" ⢠Simple text posts")
+ print(" ⢠Images (up to 4)")
+ print(" ⢠Rich text (links, mentions)")
+ print(" ⢠Replies and threads")
+ print(" ⢠Quote posts")
+ print(" ⢠Combinations (quote + image, reply + rich text, etc.)")
+ print(" - search: Search for posts")
+ print(" - create_thread: Post multi-part threads")
+ print(" - follow: Follow users")
+ print(" - like: Like posts")
+ print(" - repost: Share posts")
+
+ print("\n⨠Demo complete!")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="ATProto MCP Server Demo")
+ parser.add_argument(
+ "--post",
+ action="store_true",
+ help="Enable posting test messages to Bluesky",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(main(enable_posting=args.post))
diff --git a/examples/atproto_mcp/pyproject.toml b/examples/atproto_mcp/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..2f1b67ad9eaf26d6ada2ff911a7006b35ae79570
--- /dev/null
+++ b/examples/atproto_mcp/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "atproto-mcp"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+authors = [{ name = "zzstoatzz", email = "thrast36@gmail.com" }]
+requires-python = ">=3.10"
+dependencies = [
+ "fastmcp>=0.8.0",
+ "atproto@git+https://github.com/MarshalX/atproto.git@refs/pull/605/head",
+ "pydantic-settings>=2.0.0",
+ "websockets>=15.0.1",
+ "httpx>=0.27.0",
+]
+
+[project.scripts]
+atproto-mcp = "atproto_mcp.__main__:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.metadata]
+allow-direct-references = true
diff --git a/examples/atproto_mcp/src/atproto_mcp/__init__.py b/examples/atproto_mcp/src/atproto_mcp/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9752f9b8c0ca687eb531b08d1c54018a273190c8
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/__init__.py
@@ -0,0 +1,3 @@
+from atproto_mcp.settings import settings
+
+__all__ = ["settings"]
diff --git a/examples/atproto_mcp/src/atproto_mcp/__main__.py b/examples/atproto_mcp/src/atproto_mcp/__main__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb4c12e7ad123abea29955068a5cbeb3e1cbe4c1
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/__main__.py
@@ -0,0 +1,9 @@
+from atproto_mcp.server import atproto_mcp
+
+
+def main():
+ atproto_mcp.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cf63cec631dfe0549446b62475b1531cf3f84f75
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/__init__.py
@@ -0,0 +1,20 @@
+"""Private ATProto implementation module."""
+
+from ._client import get_client
+from ._posts import create_post, create_thread
+from ._profile import get_profile_info
+from ._read import fetch_notifications, fetch_timeline, search_for_posts
+from ._social import follow_user_by_handle, like_post_by_uri, repost_by_uri
+
+__all__ = [
+ "get_client",
+ "get_profile_info",
+ "create_post",
+ "create_thread",
+ "fetch_timeline",
+ "search_for_posts",
+ "fetch_notifications",
+ "follow_user_by_handle",
+ "like_post_by_uri",
+ "repost_by_uri",
+]
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/_client.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..40ee8e1603a9bb8b258ee5d19055b55c0bbbd4e7
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/_client.py
@@ -0,0 +1,16 @@
+"""ATProto client management."""
+
+from atproto import Client
+
+from atproto_mcp.settings import settings
+
+_client: Client | None = None
+
+
+def get_client() -> Client:
+ """Get or create an authenticated ATProto client."""
+ global _client
+ if _client is None:
+ _client = Client()
+ _client.login(settings.atproto_handle, settings.atproto_password)
+ return _client
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py
new file mode 100644
index 0000000000000000000000000000000000000000..e7a5b7dbd0e780619e9930b1f7b21acec228a96d
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/_posts.py
@@ -0,0 +1,385 @@
+"""Unified posting functionality."""
+
+import time
+from datetime import datetime
+
+from atproto import models
+
+from atproto_mcp.types import (
+ PostResult,
+ RichTextLink,
+ RichTextMention,
+ ThreadPost,
+ ThreadResult,
+)
+
+from ._client import get_client
+
+
+def create_post(
+ text: str,
+ images: list[str] | None = None,
+ image_alts: list[str] | None = None,
+ links: list[RichTextLink] | None = None,
+ mentions: list[RichTextMention] | None = None,
+ reply_to: str | None = None,
+ reply_root: str | None = None,
+ quote: str | None = None,
+) -> PostResult:
+ """Create a unified post with optional features.
+
+ Args:
+ text: Post text (max 300 chars)
+ images: URLs of images to attach (max 4)
+ image_alts: Alt text for images
+ links: Links to embed in rich text
+ mentions: User mentions to embed
+ reply_to: URI of post to reply to
+ reply_root: URI of thread root (defaults to reply_to)
+ quote: URI of post to quote
+ """
+ try:
+ client = get_client()
+ facets = []
+ embed = None
+ reply_ref = None
+
+ # Handle rich text facets (links and mentions)
+ if links or mentions:
+ facets = _build_facets(text, links, mentions, client)
+
+ # Handle replies
+ if reply_to:
+ reply_ref = _build_reply_ref(reply_to, reply_root, client)
+
+ # Handle quotes and images
+ if quote and images:
+ # Quote with images - create record with media embed
+ embed = _build_quote_with_images_embed(quote, images, image_alts, client)
+ elif quote:
+ # Quote only
+ embed = _build_quote_embed(quote, client)
+ elif images:
+ # Images only - use send_images for proper handling
+ return _send_images(text, images, image_alts, facets, reply_ref, client)
+
+ # Send the post
+ post = client.send_post(
+ text=text,
+ facets=facets if facets else None,
+ embed=embed,
+ reply_to=reply_ref,
+ )
+
+ return PostResult(
+ success=True,
+ uri=post.uri,
+ cid=post.cid,
+ text=text,
+ created_at=datetime.now().isoformat(),
+ error=None,
+ )
+ except Exception as e:
+ return PostResult(
+ success=False,
+ uri=None,
+ cid=None,
+ text=None,
+ created_at=None,
+ error=str(e),
+ )
+
+
+def _build_facets(
+ text: str,
+ links: list[RichTextLink] | None,
+ mentions: list[RichTextMention] | None,
+ client,
+):
+ """Build facets for rich text formatting."""
+ facets = []
+
+ # Process links
+ if links:
+ for link in links:
+ start = text.find(link["text"])
+ if start == -1:
+ continue
+ end = start + len(link["text"])
+
+ facets.append(
+ models.AppBskyRichtextFacet.Main(
+ features=[models.AppBskyRichtextFacet.Link(uri=link["url"])],
+ index=models.AppBskyRichtextFacet.ByteSlice(
+ byte_start=len(text[:start].encode("UTF-8")),
+ byte_end=len(text[:end].encode("UTF-8")),
+ ),
+ )
+ )
+
+ # Process mentions
+ if mentions:
+ for mention in mentions:
+ display_text = mention.get("display_text") or f"@{mention['handle']}"
+ start = text.find(display_text)
+ if start == -1:
+ continue
+ end = start + len(display_text)
+
+ # Resolve handle to DID
+ resolved = client.app.bsky.actor.search_actors(
+ params={"q": mention["handle"], "limit": 1}
+ )
+ if not resolved.actors:
+ continue
+
+ did = resolved.actors[0].did
+ facets.append(
+ models.AppBskyRichtextFacet.Main(
+ features=[models.AppBskyRichtextFacet.Mention(did=did)],
+ index=models.AppBskyRichtextFacet.ByteSlice(
+ byte_start=len(text[:start].encode("UTF-8")),
+ byte_end=len(text[:end].encode("UTF-8")),
+ ),
+ )
+ )
+
+ return facets
+
+
+def _build_reply_ref(reply_to: str, reply_root: str | None, client):
+ """Build reply reference."""
+ # Get parent post to extract CID
+ parent_post = client.app.bsky.feed.get_posts(params={"uris": [reply_to]})
+ if not parent_post.posts:
+ raise ValueError("Parent post not found")
+
+ parent_cid = parent_post.posts[0].cid
+ parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=reply_to, cid=parent_cid)
+
+ # If no root_uri provided, parent is the root
+ if reply_root is None:
+ root_ref = parent_ref
+ else:
+ # Get root post CID
+ root_post = client.app.bsky.feed.get_posts(params={"uris": [reply_root]})
+ if not root_post.posts:
+ raise ValueError("Root post not found")
+ root_cid = root_post.posts[0].cid
+ root_ref = models.ComAtprotoRepoStrongRef.Main(uri=reply_root, cid=root_cid)
+
+ return models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref)
+
+
+def _build_quote_embed(quote_uri: str, client):
+ """Build quote embed."""
+ # Get the post to quote
+ quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]})
+ if not quoted_post.posts:
+ raise ValueError("Quoted post not found")
+
+ # Create strong ref for the quoted post
+ quoted_cid = quoted_post.posts[0].cid
+ quoted_ref = models.ComAtprotoRepoStrongRef.Main(uri=quote_uri, cid=quoted_cid)
+
+ # Create the embed
+ return models.AppBskyEmbedRecord.Main(record=quoted_ref)
+
+
+def _build_quote_with_images_embed(
+ quote_uri: str, image_urls: list[str], image_alts: list[str] | None, client
+):
+ """Build quote embed with images."""
+ import httpx
+
+ # Get the quoted post
+ quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]})
+ if not quoted_post.posts:
+ raise ValueError("Quoted post not found")
+
+ quoted_cid = quoted_post.posts[0].cid
+ quoted_ref = models.ComAtprotoRepoStrongRef.Main(uri=quote_uri, cid=quoted_cid)
+
+ # Download and upload images
+ images = []
+ alts = image_alts or [""] * len(image_urls)
+
+ for i, url in enumerate(image_urls[:4]):
+ response = httpx.get(url, follow_redirects=True)
+ response.raise_for_status()
+
+ # Upload to blob storage
+ upload = client.upload_blob(response.content)
+ images.append(
+ models.AppBskyEmbedImages.Image(
+ alt=alts[i] if i < len(alts) else "",
+ image=upload.blob,
+ )
+ )
+
+ # Create record with media embed
+ return models.AppBskyEmbedRecordWithMedia.Main(
+ record=models.AppBskyEmbedRecord.Main(record=quoted_ref),
+ media=models.AppBskyEmbedImages.Main(images=images),
+ )
+
+
+def _send_images(
+ text: str,
+ image_urls: list[str],
+ image_alts: list[str] | None,
+ facets,
+ reply_ref,
+ client,
+):
+ """Send post with images using the client's send_images method."""
+ import httpx
+
+ # Ensure alt_texts has same length as images
+ if image_alts is None:
+ image_alts = [""] * len(image_urls)
+ elif len(image_alts) < len(image_urls):
+ image_alts.extend([""] * (len(image_urls) - len(image_alts)))
+
+ image_data = []
+ alts = []
+ for i, url in enumerate(image_urls[:4]): # Max 4 images
+ # Download image (follow redirects)
+ response = httpx.get(url, follow_redirects=True)
+ response.raise_for_status()
+
+ image_data.append(response.content)
+ alts.append(image_alts[i] if i < len(image_alts) else "")
+
+ # Send post with images
+ # Note: send_images doesn't support facets or reply_to directly
+ # So we need to use send_post with manual image upload if we have those
+ if facets or reply_ref:
+ # Manual image upload
+ images = []
+ for i, data in enumerate(image_data):
+ upload = client.upload_blob(data)
+ images.append(
+ models.AppBskyEmbedImages.Image(
+ alt=alts[i],
+ image=upload.blob,
+ )
+ )
+
+ embed = models.AppBskyEmbedImages.Main(images=images)
+ post = client.send_post(
+ text=text,
+ facets=facets if facets else None,
+ embed=embed,
+ reply_to=reply_ref,
+ )
+ else:
+ # Use simple send_images
+ post = client.send_images(
+ text=text,
+ images=image_data,
+ image_alts=alts,
+ )
+
+ return PostResult(
+ success=True,
+ uri=post.uri,
+ cid=post.cid,
+ text=text,
+ created_at=datetime.now().isoformat(),
+ error=None,
+ )
+
+
+def create_thread(posts: list[ThreadPost]) -> ThreadResult:
+ """Create a thread of posts with automatic linking.
+
+ Args:
+ posts: List of posts to create as a thread. First post is the root.
+ """
+ if not posts:
+ return ThreadResult(
+ success=False,
+ thread_uri=None,
+ post_uris=[],
+ post_count=0,
+ error="No posts provided",
+ )
+
+ try:
+ post_uris = []
+ root_uri = None
+ parent_uri = None
+
+ for i, post_data in enumerate(posts):
+ # First post is the root
+ if i == 0:
+ result = create_post(
+ text=post_data["text"],
+ images=post_data.get("images"),
+ image_alts=post_data.get("image_alts"),
+ links=post_data.get("links"),
+ mentions=post_data.get("mentions"),
+ quote=post_data.get("quote"),
+ )
+
+ if not result["success"]:
+ return ThreadResult(
+ success=False,
+ thread_uri=None,
+ post_uris=post_uris,
+ post_count=len(post_uris),
+ error=f"Failed to create root post: {result['error']}",
+ )
+
+ root_uri = result["uri"]
+ parent_uri = root_uri
+ post_uris.append(root_uri)
+
+ # Small delay to ensure post is indexed
+ time.sleep(0.5)
+ else:
+ # Subsequent posts reply to the previous one
+ result = create_post(
+ text=post_data["text"],
+ images=post_data.get("images"),
+ image_alts=post_data.get("image_alts"),
+ links=post_data.get("links"),
+ mentions=post_data.get("mentions"),
+ quote=post_data.get("quote"),
+ reply_to=parent_uri,
+ reply_root=root_uri,
+ )
+
+ if not result["success"]:
+ return ThreadResult(
+ success=False,
+ thread_uri=root_uri,
+ post_uris=post_uris,
+ post_count=len(post_uris),
+ error=f"Failed to create post {i + 1}: {result['error']}",
+ )
+
+ parent_uri = result["uri"]
+ post_uris.append(parent_uri)
+
+ # Small delay between posts
+ if i < len(posts) - 1:
+ time.sleep(0.5)
+
+ return ThreadResult(
+ success=True,
+ thread_uri=root_uri,
+ post_uris=post_uris,
+ post_count=len(post_uris),
+ error=None,
+ )
+
+ except Exception as e:
+ return ThreadResult(
+ success=False,
+ thread_uri=None,
+ post_uris=post_uris,
+ post_count=len(post_uris),
+ error=str(e),
+ )
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/_profile.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/_profile.py
new file mode 100644
index 0000000000000000000000000000000000000000..956ae5412ea92388353965bc00525c122fcc404c
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/_profile.py
@@ -0,0 +1,33 @@
+"""Profile-related operations."""
+
+from atproto_mcp.types import ProfileInfo
+
+from ._client import get_client
+
+
+def get_profile_info() -> ProfileInfo:
+ """Get profile information for the authenticated user."""
+ try:
+ client = get_client()
+ profile = client.get_profile(client.me.did)
+ return ProfileInfo(
+ connected=True,
+ handle=profile.handle,
+ display_name=profile.display_name,
+ did=client.me.did,
+ followers=profile.followers_count,
+ following=profile.follows_count,
+ posts=profile.posts_count,
+ error=None,
+ )
+ except Exception as e:
+ return ProfileInfo(
+ connected=False,
+ handle=None,
+ display_name=None,
+ did=None,
+ followers=None,
+ following=None,
+ posts=None,
+ error=str(e),
+ )
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/_read.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/_read.py
new file mode 100644
index 0000000000000000000000000000000000000000..189185a4a182556c5f60ef9e0a87c8a9019bf18d
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/_read.py
@@ -0,0 +1,124 @@
+"""Read-only operations for timeline, search, and notifications."""
+
+from atproto_mcp.types import (
+ Notification,
+ NotificationsResult,
+ Post,
+ SearchResult,
+ TimelineResult,
+)
+
+from ._client import get_client
+
+
+def fetch_timeline(limit: int = 10) -> TimelineResult:
+ """Fetch the authenticated user's timeline."""
+ try:
+ client = get_client()
+ timeline = client.get_timeline(limit=limit)
+
+ posts = []
+ for feed_view in timeline.feed:
+ post = feed_view.post
+ posts.append(
+ Post(
+ uri=post.uri,
+ cid=post.cid,
+ text=post.record.text if hasattr(post.record, "text") else "",
+ author=post.author.handle,
+ created_at=post.record.created_at,
+ likes=post.like_count or 0,
+ reposts=post.repost_count or 0,
+ replies=post.reply_count or 0,
+ )
+ )
+
+ return TimelineResult(
+ success=True,
+ posts=posts,
+ count=len(posts),
+ error=None,
+ )
+ except Exception as e:
+ return TimelineResult(
+ success=False,
+ posts=[],
+ count=0,
+ error=str(e),
+ )
+
+
+def search_for_posts(query: str, limit: int = 10) -> SearchResult:
+ """Search for posts containing specific text."""
+ try:
+ client = get_client()
+ search_results = client.app.bsky.feed.search_posts(
+ params={"q": query, "limit": limit}
+ )
+
+ posts = []
+ for post in search_results.posts:
+ posts.append(
+ Post(
+ uri=post.uri,
+ cid=post.cid,
+ text=post.record.text if hasattr(post.record, "text") else "",
+ author=post.author.handle,
+ created_at=post.record.created_at,
+ likes=post.like_count or 0,
+ reposts=post.repost_count or 0,
+ replies=post.reply_count or 0,
+ )
+ )
+
+ return SearchResult(
+ success=True,
+ query=query,
+ posts=posts,
+ count=len(posts),
+ error=None,
+ )
+ except Exception as e:
+ return SearchResult(
+ success=False,
+ query=query,
+ posts=[],
+ count=0,
+ error=str(e),
+ )
+
+
+def fetch_notifications(limit: int = 10) -> NotificationsResult:
+ """Fetch recent notifications."""
+ try:
+ client = get_client()
+ notifs = client.app.bsky.notification.list_notifications(
+ params={"limit": limit}
+ )
+
+ notifications = []
+ for notif in notifs.notifications:
+ notifications.append(
+ Notification(
+ uri=notif.uri,
+ cid=notif.cid,
+ author=notif.author.handle,
+ reason=notif.reason,
+ is_read=notif.is_read,
+ indexed_at=notif.indexed_at,
+ )
+ )
+
+ return NotificationsResult(
+ success=True,
+ notifications=notifications,
+ count=len(notifications),
+ error=None,
+ )
+ except Exception as e:
+ return NotificationsResult(
+ success=False,
+ notifications=[],
+ count=0,
+ error=str(e),
+ )
diff --git a/examples/atproto_mcp/src/atproto_mcp/_atproto/_social.py b/examples/atproto_mcp/src/atproto_mcp/_atproto/_social.py
new file mode 100644
index 0000000000000000000000000000000000000000..87bd0297607b6496b557d05cd39d12536c341132
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/_atproto/_social.py
@@ -0,0 +1,108 @@
+"""Social actions like follow, like, and repost."""
+
+from atproto_mcp.types import FollowResult, LikeResult, RepostResult
+
+from ._client import get_client
+
+
+def follow_user_by_handle(handle: str) -> FollowResult:
+ """Follow a user by their handle."""
+ try:
+ client = get_client()
+ # Search for the user to get their DID
+ results = client.app.bsky.actor.search_actors(params={"q": handle, "limit": 1})
+ if not results.actors:
+ return FollowResult(
+ success=False,
+ did=None,
+ handle=None,
+ uri=None,
+ error=f"User @{handle} not found",
+ )
+
+ actor = results.actors[0]
+ # Create the follow
+ follow = client.follow(actor.did)
+ return FollowResult(
+ success=True,
+ did=actor.did,
+ handle=actor.handle,
+ uri=follow.uri,
+ error=None,
+ )
+ except Exception as e:
+ return FollowResult(
+ success=False,
+ did=None,
+ handle=None,
+ uri=None,
+ error=str(e),
+ )
+
+
+def like_post_by_uri(uri: str) -> LikeResult:
+ """Like a post by its AT URI."""
+ try:
+ client = get_client()
+ # Parse the URI to get the components
+ # URI format: at://did:plc:xxx/app.bsky.feed.post/yyy
+ parts = uri.replace("at://", "").split("/")
+ if len(parts) != 3 or parts[1] != "app.bsky.feed.post":
+ raise ValueError("Invalid post URI format")
+
+ # Get the post to retrieve its CID
+ post = client.app.bsky.feed.get_posts(params={"uris": [uri]})
+ if not post.posts:
+ raise ValueError("Post not found")
+
+ cid = post.posts[0].cid
+
+ # Now like the post with both URI and CID
+ like = client.like(uri, cid)
+ return LikeResult(
+ success=True,
+ liked_uri=uri,
+ like_uri=like.uri,
+ error=None,
+ )
+ except Exception as e:
+ return LikeResult(
+ success=False,
+ liked_uri=None,
+ like_uri=None,
+ error=str(e),
+ )
+
+
+def repost_by_uri(uri: str) -> RepostResult:
+ """Repost a post by its AT URI."""
+ try:
+ client = get_client()
+ # Parse the URI to get the components
+ # URI format: at://did:plc:xxx/app.bsky.feed.post/yyy
+ parts = uri.replace("at://", "").split("/")
+ if len(parts) != 3 or parts[1] != "app.bsky.feed.post":
+ raise ValueError("Invalid post URI format")
+
+ # Get the post to retrieve its CID
+ post = client.app.bsky.feed.get_posts(params={"uris": [uri]})
+ if not post.posts:
+ raise ValueError("Post not found")
+
+ cid = post.posts[0].cid
+
+ # Now repost with both URI and CID
+ repost = client.repost(uri, cid)
+ return RepostResult(
+ success=True,
+ reposted_uri=uri,
+ repost_uri=repost.uri,
+ error=None,
+ )
+ except Exception as e:
+ return RepostResult(
+ success=False,
+ reposted_uri=None,
+ repost_uri=None,
+ error=str(e),
+ )
diff --git a/examples/atproto_mcp/src/atproto_mcp/py.typed b/examples/atproto_mcp/src/atproto_mcp/py.typed
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/examples/atproto_mcp/src/atproto_mcp/server.py b/examples/atproto_mcp/src/atproto_mcp/server.py
new file mode 100644
index 0000000000000000000000000000000000000000..c81a8ce5e1ae80849c1069a5492d8a96a399bc0d
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/server.py
@@ -0,0 +1,154 @@
+"""ATProto MCP Server - Public API exposing Bluesky tools and resources."""
+
+from typing import Annotated
+
+from pydantic import Field
+
+from atproto_mcp import _atproto
+from atproto_mcp.settings import settings
+from atproto_mcp.types import (
+ FollowResult,
+ LikeResult,
+ NotificationsResult,
+ PostResult,
+ ProfileInfo,
+ RepostResult,
+ RichTextLink,
+ RichTextMention,
+ SearchResult,
+ ThreadPost,
+ ThreadResult,
+ TimelineResult,
+)
+from fastmcp import FastMCP
+
+atproto_mcp = FastMCP(
+ "ATProto MCP Server",
+ dependencies=[
+ "atproto_mcp@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/atproto_mcp",
+ ],
+)
+
+
+# Resources - read-only operations
+@atproto_mcp.resource("atproto://profile/status")
+def atproto_status() -> ProfileInfo:
+ """Check the status of the ATProto connection and current user profile."""
+ return _atproto.get_profile_info()
+
+
+@atproto_mcp.resource("atproto://timeline")
+def get_timeline() -> TimelineResult:
+ """Get the authenticated user's timeline feed."""
+ return _atproto.fetch_timeline(settings.atproto_timeline_default_limit)
+
+
+@atproto_mcp.resource("atproto://notifications")
+def get_notifications() -> NotificationsResult:
+ """Get recent notifications for the authenticated user."""
+ return _atproto.fetch_notifications(settings.atproto_notifications_default_limit)
+
+
+# Tools - actions that modify state
+@atproto_mcp.tool
+def post(
+ text: Annotated[
+ str, Field(max_length=300, description="The text content of the post")
+ ],
+ images: Annotated[
+ list[str] | None,
+ Field(max_length=4, description="URLs of images to attach (max 4)"),
+ ] = None,
+ image_alts: Annotated[
+ list[str] | None, Field(description="Alt text for each image")
+ ] = None,
+ links: Annotated[
+ list[RichTextLink] | None, Field(description="Links to embed in the text")
+ ] = None,
+ mentions: Annotated[
+ list[RichTextMention] | None, Field(description="User mentions to embed")
+ ] = None,
+ reply_to: Annotated[
+ str | None, Field(description="AT URI of post to reply to")
+ ] = None,
+ reply_root: Annotated[
+ str | None, Field(description="AT URI of thread root (defaults to reply_to)")
+ ] = None,
+ quote: Annotated[str | None, Field(description="AT URI of post to quote")] = None,
+) -> PostResult:
+ """Create a post with optional rich features like images, quotes, replies, and rich text.
+
+ Examples:
+ - Simple post: post("Hello world!")
+ - With image: post("Check this out!", images=["https://example.com/img.jpg"])
+ - Reply: post("I agree!", reply_to="at://did/app.bsky.feed.post/123")
+ - Quote: post("Great point!", quote="at://did/app.bsky.feed.post/456")
+ - Rich text: post("Check out example.com", links=[{"text": "example.com", "url": "https://example.com"}])
+ """
+ return _atproto.create_post(
+ text, images, image_alts, links, mentions, reply_to, reply_root, quote
+ )
+
+
+@atproto_mcp.tool
+def follow(
+ handle: Annotated[
+ str,
+ Field(
+ description="The handle of the user to follow (e.g., 'user.bsky.social')"
+ ),
+ ],
+) -> FollowResult:
+ """Follow a user by their handle."""
+ return _atproto.follow_user_by_handle(handle)
+
+
+@atproto_mcp.tool
+def like(
+ uri: Annotated[str, Field(description="The AT URI of the post to like")],
+) -> LikeResult:
+ """Like a post by its AT URI."""
+ return _atproto.like_post_by_uri(uri)
+
+
+@atproto_mcp.tool
+def repost(
+ uri: Annotated[str, Field(description="The AT URI of the post to repost")],
+) -> RepostResult:
+ """Repost a post by its AT URI."""
+ return _atproto.repost_by_uri(uri)
+
+
+@atproto_mcp.tool
+def search(
+ query: Annotated[str, Field(description="Search query for posts")],
+ limit: Annotated[
+ int, Field(ge=1, le=100, description="Number of results to return")
+ ] = settings.atproto_search_default_limit,
+) -> SearchResult:
+ """Search for posts containing specific text."""
+ return _atproto.search_for_posts(query, limit)
+
+
+@atproto_mcp.tool
+def create_thread(
+ posts: Annotated[
+ list[ThreadPost],
+ Field(
+ description="List of posts to create as a thread. Each post can have text, images, links, mentions, and quotes."
+ ),
+ ],
+) -> ThreadResult:
+ """Create a thread of posts with automatic linking.
+
+ The first post becomes the root of the thread, and each subsequent post
+ replies to the previous one, maintaining the thread structure.
+
+ Example:
+ create_thread([
+ {"text": "Starting a thread about Python š§µ"},
+ {"text": "Python is great for rapid development"},
+ {"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]}
+ ])
+ """
+ return _atproto.create_thread(posts)
diff --git a/examples/atproto_mcp/src/atproto_mcp/settings.py b/examples/atproto_mcp/src/atproto_mcp/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..9eed4083754230f694c78215086c28c5c0345361
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/settings.py
@@ -0,0 +1,17 @@
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=[".env"], extra="ignore")
+
+ atproto_handle: str = Field(default=...)
+ atproto_password: str = Field(default=...)
+ atproto_pds_url: str = Field(default="https://bsky.social")
+
+ atproto_notifications_default_limit: int = Field(default=10)
+ atproto_timeline_default_limit: int = Field(default=10)
+ atproto_search_default_limit: int = Field(default=10)
+
+
+settings = Settings()
diff --git a/examples/atproto_mcp/src/atproto_mcp/types.py b/examples/atproto_mcp/src/atproto_mcp/types.py
new file mode 100644
index 0000000000000000000000000000000000000000..e95fc21197bfc5681e5b225e0c8971de2b7e00fd
--- /dev/null
+++ b/examples/atproto_mcp/src/atproto_mcp/types.py
@@ -0,0 +1,142 @@
+"""Type definitions for ATProto MCP server."""
+
+from typing import TypedDict
+
+
+class ProfileInfo(TypedDict):
+ """Profile information response."""
+
+ connected: bool
+ handle: str | None
+ display_name: str | None
+ did: str | None
+ followers: int | None
+ following: int | None
+ posts: int | None
+ error: str | None
+
+
+class PostResult(TypedDict):
+ """Result of creating a post."""
+
+ success: bool
+ uri: str | None
+ cid: str | None
+ text: str | None
+ created_at: str | None
+ error: str | None
+
+
+class Post(TypedDict):
+ """A single post."""
+
+ author: str
+ text: str | None
+ created_at: str | None
+ likes: int
+ reposts: int
+ replies: int
+ uri: str
+ cid: str
+
+
+class TimelineResult(TypedDict):
+ """Timeline fetch result."""
+
+ success: bool
+ count: int
+ posts: list[Post]
+ error: str | None
+
+
+class SearchResult(TypedDict):
+ """Search result."""
+
+ success: bool
+ query: str
+ count: int
+ posts: list[Post]
+ error: str | None
+
+
+class Notification(TypedDict):
+ """A single notification."""
+
+ reason: str
+ author: str | None
+ is_read: bool
+ indexed_at: str
+ uri: str
+ cid: str
+
+
+class NotificationsResult(TypedDict):
+ """Notifications fetch result."""
+
+ success: bool
+ count: int
+ notifications: list[Notification]
+ error: str | None
+
+
+class FollowResult(TypedDict):
+ """Result of following a user."""
+
+ success: bool
+ handle: str | None
+ did: str | None
+ uri: str | None
+ error: str | None
+
+
+class LikeResult(TypedDict):
+ """Result of liking a post."""
+
+ success: bool
+ liked_uri: str | None
+ like_uri: str | None
+ error: str | None
+
+
+class RepostResult(TypedDict):
+ """Result of reposting."""
+
+ success: bool
+ reposted_uri: str | None
+ repost_uri: str | None
+ error: str | None
+
+
+class RichTextLink(TypedDict):
+ """A link in rich text."""
+
+ text: str
+ url: str
+
+
+class RichTextMention(TypedDict):
+ """A mention in rich text."""
+
+ handle: str
+ display_text: str | None
+
+
+class ThreadPost(TypedDict, total=False):
+ """A post in a thread."""
+
+ text: str # Required
+ images: list[str] | None
+ image_alts: list[str] | None
+ links: list[RichTextLink] | None
+ mentions: list[RichTextMention] | None
+ quote: str | None
+
+
+class ThreadResult(TypedDict):
+ """Result of creating a thread."""
+
+ success: bool
+ thread_uri: str | None # URI of the first post
+ post_uris: list[str]
+ post_count: int
+ error: str | None
diff --git a/examples/mount_example.py b/examples/mount_example.py
index 7720f0eb2270103467c111a2e5abcb73c64daf13..b6061954f05e78baf448c3da45c1e8865a47213f 100644
--- a/examples/mount_example.py
+++ b/examples/mount_example.py
@@ -9,6 +9,7 @@ the ToolManager's import_tools functionality. It shows how to:
"""
import asyncio
+from urllib.parse import urlparse
from fastmcp import FastMCP
@@ -65,17 +66,17 @@ def check_app_status() -> dict[str, str]:
# Mount sub-applications
-app.mount("weather", weather_app)
+app.mount(server=weather_app, prefix="weather")
-app.mount("news", news_app)
+app.mount(server=news_app, prefix="news")
async def get_server_details():
"""Print information about mounted resources."""
# Print available tools
- tools = app._tool_manager.list_tools()
+ tools = await app.get_tools()
print(f"\nAvailable tools ({len(tools)}):")
- for tool in tools:
+ for _, tool in tools.items():
print(f" - {tool.name}: {tool.description}")
# Print available resources
@@ -83,18 +84,21 @@ async def get_server_details():
# Distinguish between native and imported resources
# Native resources would be those directly in the main app (not prefixed)
+
+ resources = await app.get_resources()
+
native_resources = [
uri
- for uri in app._resource_manager._resources
- if not (uri.startswith("weather+") or uri.startswith("news+"))
+ for uri, _ in resources.items()
+ if urlparse(uri).netloc not in ("weather", "news")
]
# Imported resources - categorized by source app
weather_resources = [
- uri for uri in app._resource_manager._resources if uri.startswith("weather+")
+ uri for uri, _ in resources.items() if urlparse(uri).netloc == "weather"
]
news_resources = [
- uri for uri in app._resource_manager._resources if uri.startswith("news+")
+ uri for uri, _ in resources.items() if urlparse(uri).netloc == "news"
]
print(f" - Native app resources: {native_resources}")
@@ -102,7 +106,7 @@ async def get_server_details():
print(f" - Imported from news app: {news_resources}")
# Let's try to access resources using the prefixed URI
- weather_data = await app.read_resource("weather+weather://forecast")
+ weather_data = await app._mcp_read_resource(uri="weather://weather/forecast")
print(f"\nWeather data from prefixed URI: {weather_data}")
diff --git a/justfile b/justfile
index fc2f335013f6f865d00d428bf074068381921d23..f24c24c2c1959f93f37886b51be5a3188488543d 100644
--- a/justfile
+++ b/justfile
@@ -16,11 +16,11 @@ docs:
# Generate API reference documentation for all modules
api-ref-all:
- uvx --with-editable . --refresh-package mdxify mdxify@latest --all --root-module fastmcp --anchor-name "SDK Reference"
+ uvx --with-editable . --refresh-package mdxify mdxify@latest --all --root-module fastmcp --anchor-name "Python SDK" --exclude fastmcp.contrib
# Generate API reference for specific modules (e.g., just api-ref prefect.flows prefect.tasks)
api-ref *MODULES:
- uvx --with-editable . --refresh-package mdxify mdxify@latest {{MODULES}} --root-module fastmcp --anchor-name "SDK Reference"
+ uvx --with-editable . --refresh-package mdxify mdxify@latest {{MODULES}} --root-module fastmcp --anchor-name "Python SDK"
# Clean up API reference documentation
api-ref-clean:
diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py
index d3e34524e2b56c1f2c42424ef5d38a3954dcebb9..cef0ecb6bfb0ace26732e737788f8690430613b7 100644
--- a/src/fastmcp/cli/cli.py
+++ b/src/fastmcp/cli/cli.py
@@ -235,7 +235,7 @@ def run(
typer.Option(
"--transport",
"-t",
- help="Transport protocol to use (stdio, streamable-http, or sse)",
+ help="Transport protocol to use (stdio, http, or sse)",
),
] = None,
host: Annotated[
diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py
index 2cb790c04835301b2c76b52d84c874f10ed9851c..d8c96f2dd8007aa892d2f857564f1e6c858e3a45 100644
--- a/src/fastmcp/cli/run.py
+++ b/src/fastmcp/cli/run.py
@@ -4,14 +4,12 @@ import importlib.util
import re
import sys
from pathlib import Path
-from typing import Any, Literal
+from typing import Any
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli.run")
-TransportType = Literal["stdio", "streamable-http", "sse"]
-
def is_url(path: str) -> bool:
"""Check if a string is a URL."""
diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py
index 15349102952d77ee01e6ca8e04396f3c55cf1e76..b0571618d9ff3d3f110c10e1c6e169174335cbc7 100644
--- a/src/fastmcp/client/transports.py
+++ b/src/fastmcp/client/transports.py
@@ -736,11 +736,11 @@ class MCPConfigTransport(ClientTransport):
"mcpServers": {
"weather": {
"url": "https://weather-api.example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
},
"calendar": {
"url": "https://calendar-api.example.com/mcp",
- "transport": "streamable-http"
+ "transport": "http"
}
}
}
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index a7103be8a9d061a028ceeb2b7d237048215d45f3..073d128976e072aa479dd462be32178c4dd9cac3 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -338,6 +338,6 @@ class FunctionPrompt(Prompt):
raise PromptError("Could not convert prompt result to message.")
return messages
- except Exception as e:
- logger.exception(f"Error rendering prompt {self.name}: {e}")
+ except Exception:
+ logger.exception(f"Error rendering prompt {self.name}")
raise PromptError(f"Error rendering prompt {self.name}.")
diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py
index 3436e71c4629817d4343dcbb058cd842ee972c57..0f7d216f80d2c9a7e7e3c8380db3cd9a2dbb4994 100644
--- a/src/fastmcp/prompts/prompt_manager.py
+++ b/src/fastmcp/prompts/prompt_manager.py
@@ -172,12 +172,12 @@ class PromptManager:
# Pass through PromptErrors as-is
except PromptError as e:
- logger.exception(f"Error rendering prompt {name!r}: {e}")
+ logger.exception(f"Error rendering prompt {name!r}")
raise e
# Handle other exceptions
except Exception as e:
- logger.exception(f"Error rendering prompt {name!r}: {e}")
+ logger.exception(f"Error rendering prompt {name!r}")
if self.mask_error_details:
# Mask internal details
raise PromptError(f"Error rendering prompt {name!r}") from e
diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py
index 8741837baad9277c3d8952cff8d0dfba9f3501e8..8620d4114ee9b8bfd522a26d71edc5d604487456 100644
--- a/src/fastmcp/resources/resource_manager.py
+++ b/src/fastmcp/resources/resource_manager.py
@@ -422,12 +422,12 @@ class ResourceManager:
# raise ResourceErrors as-is
except ResourceError as e:
- logger.exception(f"Error reading resource {uri_str!r}: {e}")
+ logger.exception(f"Error reading resource {uri_str!r}")
raise e
# Handle other exceptions
except Exception as e:
- logger.exception(f"Error reading resource {uri_str!r}: {e}")
+ logger.exception(f"Error reading resource {uri_str!r}")
if self.mask_error_details:
# Mask internal details
raise ResourceError(f"Error reading resource {uri_str!r}") from e
@@ -445,12 +445,12 @@ class ResourceManager:
return await resource.read()
except ResourceError as e:
logger.exception(
- f"Error reading resource from template {uri_str!r}: {e}"
+ f"Error reading resource from template {uri_str!r}"
)
raise e
except Exception as e:
logger.exception(
- f"Error reading resource from template {uri_str!r}: {e}"
+ f"Error reading resource from template {uri_str!r}"
)
if self.mask_error_details:
raise ResourceError(
diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py
index 6ffffa909f35125b3a0781c09703c4c0de076262..c6e9024bc371be9475d756bab0efb2e6431cb750 100644
--- a/src/fastmcp/server/auth/providers/bearer.py
+++ b/src/fastmcp/server/auth/providers/bearer.py
@@ -24,6 +24,7 @@ from fastmcp.server.auth.auth import (
OAuthProvider,
RevocationOptions,
)
+from fastmcp.utilities.logging import get_logger
class JWKData(TypedDict, total=False):
@@ -199,6 +200,7 @@ class BearerAuthProvider(OAuthProvider):
self.public_key = public_key
self.jwks_uri = jwks_uri
self.jwt = JsonWebToken(["RS256"])
+ self.logger = get_logger(__name__)
# Simple JWKS cache
self._jwks_cache: dict[str, str] = {}
@@ -265,6 +267,9 @@ class BearerAuthProvider(OAuthProvider):
# Select the appropriate key
if kid:
if kid not in self._jwks_cache:
+ self.logger.debug(
+ "JWKS key lookup failed: key ID '%s' not found", kid
+ )
raise ValueError(f"Key ID '{kid}' not found in JWKS")
return self._jwks_cache[kid]
else:
@@ -279,6 +284,7 @@ class BearerAuthProvider(OAuthProvider):
raise ValueError("No keys found in JWKS")
except Exception as e:
+ self.logger.debug("JWKS fetch failed: %s", str(e))
raise ValueError(f"Failed to fetch JWKS: {e}")
async def load_access_token(self, token: str) -> AccessToken | None:
@@ -298,15 +304,27 @@ class BearerAuthProvider(OAuthProvider):
# Decode and verify the JWT token
claims = self.jwt.decode(token, verification_key)
+ # Extract client ID early for logging
+ client_id = claims.get("client_id") or claims.get("sub") or "unknown"
+
# Validate expiration
exp = claims.get("exp")
if exp and exp < time.time():
+ self.logger.debug(
+ "Token validation failed: expired token for client %s", client_id
+ )
+ self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Validate issuer - note we use issuer instead of issuer_url here because
# issuer is optional, allowing users to make this check optional
if self.issuer:
if claims.get("iss") != self.issuer:
+ self.logger.debug(
+ "Token validation failed: issuer mismatch for client %s",
+ client_id,
+ )
+ self.logger.info("Bearer token rejected for client %s", client_id)
return None
# Validate audience if configured
@@ -314,26 +332,33 @@ class BearerAuthProvider(OAuthProvider):
aud = claims.get("aud")
# Handle different combinations of audience types
+ audience_valid = False
if isinstance(self.audience, list):
# self.audience is a list - check if any expected audience is present
if isinstance(aud, list):
# Both are lists - check for intersection
- if not any(expected in aud for expected in self.audience):
- return None
+ audience_valid = any(
+ expected in aud for expected in self.audience
+ )
else:
# aud is a string - check if it's in our expected list
- if aud not in self.audience:
- return None
+ audience_valid = aud in self.audience
else:
# self.audience is a string - use original logic
if isinstance(aud, list):
- if self.audience not in aud:
- return None
- elif aud != self.audience:
- return None
+ audience_valid = self.audience in aud
+ else:
+ audience_valid = aud == self.audience
- # Extract claims - prefer client_id over sub for OAuth application identification
- client_id = claims.get("client_id") or claims.get("sub") or "unknown"
+ if not audience_valid:
+ self.logger.debug(
+ "Token validation failed: audience mismatch for client %s",
+ client_id,
+ )
+ self.logger.info("Bearer token rejected for client %s", client_id)
+ return None
+
+ # Extract scopes
scopes = self._extract_scopes(claims)
return AccessToken(
@@ -344,8 +369,10 @@ class BearerAuthProvider(OAuthProvider):
)
except JoseError:
+ self.logger.debug("Token validation failed: JWT signature/format invalid")
return None
- except Exception:
+ except Exception as e:
+ self.logger.debug("Token validation failed: %s", str(e))
return None
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
diff --git a/src/fastmcp/server/middleware/__init__.py b/src/fastmcp/server/middleware/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..548a61bd91ddb52be9b8ac64f3e340d25f1b434c
--- /dev/null
+++ b/src/fastmcp/server/middleware/__init__.py
@@ -0,0 +1,6 @@
+from .middleware import Middleware, MiddlewareContext
+
+__all__ = [
+ "Middleware",
+ "MiddlewareContext",
+]
diff --git a/src/fastmcp/server/middleware/error_handling.py b/src/fastmcp/server/middleware/error_handling.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a71a24ea1281085353b0d45bcab32aded309256
--- /dev/null
+++ b/src/fastmcp/server/middleware/error_handling.py
@@ -0,0 +1,206 @@
+"""Error handling middleware for consistent error responses and tracking."""
+
+import asyncio
+import logging
+import traceback
+from collections.abc import Callable
+from typing import Any
+
+from mcp import McpError
+from mcp.types import ErrorData
+
+from .middleware import CallNext, Middleware, MiddlewareContext
+
+
+class ErrorHandlingMiddleware(Middleware):
+ """Middleware that provides consistent error handling and logging.
+
+ Catches exceptions, logs them appropriately, and converts them to
+ proper MCP error responses. Also tracks error patterns for monitoring.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
+ import logging
+
+ # Configure logging to see error details
+ logging.basicConfig(level=logging.ERROR)
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(ErrorHandlingMiddleware())
+ ```
+ """
+
+ def __init__(
+ self,
+ logger: logging.Logger | None = None,
+ include_traceback: bool = False,
+ error_callback: Callable[[Exception, MiddlewareContext], None] | None = None,
+ transform_errors: bool = True,
+ ):
+ """Initialize error handling middleware.
+
+ Args:
+ logger: Logger instance for error logging. If None, uses 'fastmcp.errors'
+ include_traceback: Whether to include full traceback in error logs
+ error_callback: Optional callback function called for each error
+ transform_errors: Whether to transform non-MCP errors to McpError
+ """
+ self.logger = logger or logging.getLogger("fastmcp.errors")
+ self.include_traceback = include_traceback
+ self.error_callback = error_callback
+ self.transform_errors = transform_errors
+ self.error_counts = {}
+
+ def _log_error(self, error: Exception, context: MiddlewareContext) -> None:
+ """Log error with appropriate detail level."""
+ error_type = type(error).__name__
+ method = context.method or "unknown"
+
+ # Track error counts
+ error_key = f"{error_type}:{method}"
+ self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
+
+ base_message = f"Error in {method}: {error_type}: {str(error)}"
+
+ if self.include_traceback:
+ self.logger.error(f"{base_message}\n{traceback.format_exc()}")
+ else:
+ self.logger.error(base_message)
+
+ # Call custom error callback if provided
+ if self.error_callback:
+ try:
+ self.error_callback(error, context)
+ except Exception as callback_error:
+ self.logger.error(f"Error in error callback: {callback_error}")
+
+ def _transform_error(self, error: Exception) -> Exception:
+ """Transform non-MCP errors to proper MCP errors."""
+ if isinstance(error, McpError):
+ return error
+
+ if not self.transform_errors:
+ return error
+
+ # Map common exceptions to appropriate MCP error codes
+ error_type = type(error)
+
+ if error_type in (ValueError, TypeError):
+ return McpError(
+ ErrorData(code=-32602, message=f"Invalid params: {str(error)}")
+ )
+ elif error_type in (FileNotFoundError, KeyError):
+ return McpError(
+ ErrorData(code=-32001, message=f"Resource not found: {str(error)}")
+ )
+ elif error_type is PermissionError:
+ return McpError(
+ ErrorData(code=-32000, message=f"Permission denied: {str(error)}")
+ )
+ elif error_type in (TimeoutError, asyncio.TimeoutError):
+ return McpError(
+ ErrorData(code=-32000, message=f"Request timeout: {str(error)}")
+ )
+ else:
+ return McpError(
+ ErrorData(code=-32603, message=f"Internal error: {str(error)}")
+ )
+
+ async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Handle errors for all messages."""
+ try:
+ return await call_next(context)
+ except Exception as error:
+ self._log_error(error, context)
+
+ # Transform and re-raise
+ transformed_error = self._transform_error(error)
+ raise transformed_error
+
+ def get_error_stats(self) -> dict[str, int]:
+ """Get error statistics for monitoring."""
+ return self.error_counts.copy()
+
+
+class RetryMiddleware(Middleware):
+ """Middleware that implements automatic retry logic for failed requests.
+
+ Retries requests that fail with transient errors, using exponential
+ backoff to avoid overwhelming the server or external dependencies.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.error_handling import RetryMiddleware
+
+ # Retry up to 3 times with exponential backoff
+ retry_middleware = RetryMiddleware(
+ max_retries=3,
+ retry_exceptions=(ConnectionError, TimeoutError)
+ )
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(retry_middleware)
+ ```
+ """
+
+ def __init__(
+ self,
+ max_retries: int = 3,
+ base_delay: float = 1.0,
+ max_delay: float = 60.0,
+ backoff_multiplier: float = 2.0,
+ retry_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError),
+ logger: logging.Logger | None = None,
+ ):
+ """Initialize retry middleware.
+
+ Args:
+ max_retries: Maximum number of retry attempts
+ base_delay: Initial delay between retries in seconds
+ max_delay: Maximum delay between retries in seconds
+ backoff_multiplier: Multiplier for exponential backoff
+ retry_exceptions: Tuple of exception types that should trigger retries
+ logger: Logger for retry attempts
+ """
+ self.max_retries = max_retries
+ self.base_delay = base_delay
+ self.max_delay = max_delay
+ self.backoff_multiplier = backoff_multiplier
+ self.retry_exceptions = retry_exceptions
+ self.logger = logger or logging.getLogger("fastmcp.retry")
+
+ def _should_retry(self, error: Exception) -> bool:
+ """Determine if an error should trigger a retry."""
+ return isinstance(error, self.retry_exceptions)
+
+ def _calculate_delay(self, attempt: int) -> float:
+ """Calculate delay for the given attempt number."""
+ delay = self.base_delay * (self.backoff_multiplier**attempt)
+ return min(delay, self.max_delay)
+
+ async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Implement retry logic for requests."""
+ last_error = None
+
+ for attempt in range(self.max_retries + 1):
+ try:
+ return await call_next(context)
+ except Exception as error:
+ last_error = error
+
+ # Don't retry on the last attempt or if it's not a retryable error
+ if attempt == self.max_retries or not self._should_retry(error):
+ break
+
+ delay = self._calculate_delay(attempt)
+ self.logger.warning(
+ f"Request {context.method} failed (attempt {attempt + 1}/{self.max_retries + 1}): "
+ f"{type(error).__name__}: {str(error)}. Retrying in {delay:.1f}s..."
+ )
+
+ await asyncio.sleep(delay)
+
+ # Re-raise the last error if all retries failed
+ if last_error:
+ raise last_error
diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcd961327fa883a297b94486dbc37aa72ab4d8d6
--- /dev/null
+++ b/src/fastmcp/server/middleware/logging.py
@@ -0,0 +1,165 @@
+"""Comprehensive logging middleware for FastMCP servers."""
+
+import json
+import logging
+from typing import Any
+
+from .middleware import CallNext, Middleware, MiddlewareContext
+
+
+class LoggingMiddleware(Middleware):
+ """Middleware that provides comprehensive request and response logging.
+
+ Logs all MCP messages with configurable detail levels. Useful for debugging,
+ monitoring, and understanding server usage patterns.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.logging import LoggingMiddleware
+ import logging
+
+ # Configure logging
+ logging.basicConfig(level=logging.INFO)
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(LoggingMiddleware())
+ ```
+ """
+
+ def __init__(
+ self,
+ logger: logging.Logger | None = None,
+ log_level: int = logging.INFO,
+ include_payloads: bool = False,
+ max_payload_length: int = 1000,
+ ):
+ """Initialize logging middleware.
+
+ Args:
+ logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests'
+ log_level: Log level for messages (default: INFO)
+ include_payloads: Whether to include message payloads in logs
+ max_payload_length: Maximum length of payload to log (prevents huge logs)
+ """
+ self.logger = logger or logging.getLogger("fastmcp.requests")
+ self.log_level = log_level
+ self.include_payloads = include_payloads
+ self.max_payload_length = max_payload_length
+
+ def _format_message(self, context: MiddlewareContext) -> str:
+ """Format a message for logging."""
+ parts = [
+ f"source={context.source}",
+ f"type={context.type}",
+ f"method={context.method or 'unknown'}",
+ ]
+
+ if self.include_payloads and hasattr(context.message, "__dict__"):
+ try:
+ payload = json.dumps(context.message.__dict__, default=str)
+ if len(payload) > self.max_payload_length:
+ payload = payload[: self.max_payload_length] + "..."
+ parts.append(f"payload={payload}")
+ except (TypeError, ValueError):
+ parts.append("payload=")
+
+ return " ".join(parts)
+
+ async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Log all messages."""
+ message_info = self._format_message(context)
+
+ self.logger.log(self.log_level, f"Processing message: {message_info}")
+
+ try:
+ result = await call_next(context)
+ self.logger.log(
+ self.log_level, f"Completed message: {context.method or 'unknown'}"
+ )
+ return result
+ except Exception as e:
+ self.logger.log(
+ logging.ERROR, f"Failed message: {context.method or 'unknown'} - {e}"
+ )
+ raise
+
+
+class StructuredLoggingMiddleware(Middleware):
+ """Middleware that provides structured JSON logging for better log analysis.
+
+ Outputs structured logs that are easier to parse and analyze with log
+ aggregation tools like ELK stack, Splunk, or cloud logging services.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.logging import StructuredLoggingMiddleware
+ import logging
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(StructuredLoggingMiddleware())
+ ```
+ """
+
+ def __init__(
+ self,
+ logger: logging.Logger | None = None,
+ log_level: int = logging.INFO,
+ include_payloads: bool = False,
+ ):
+ """Initialize structured logging middleware.
+
+ Args:
+ logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured'
+ log_level: Log level for messages (default: INFO)
+ include_payloads: Whether to include message payloads in logs
+ """
+ self.logger = logger or logging.getLogger("fastmcp.structured")
+ self.log_level = log_level
+ self.include_payloads = include_payloads
+
+ def _create_log_entry(
+ self, context: MiddlewareContext, event: str, **extra_fields
+ ) -> dict:
+ """Create a structured log entry."""
+ entry = {
+ "event": event,
+ "timestamp": context.timestamp.isoformat(),
+ "source": context.source,
+ "type": context.type,
+ "method": context.method,
+ **extra_fields,
+ }
+
+ if self.include_payloads and hasattr(context.message, "__dict__"):
+ try:
+ entry["payload"] = context.message.__dict__
+ except (TypeError, ValueError):
+ entry["payload"] = ""
+
+ return entry
+
+ async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Log structured message information."""
+ start_entry = self._create_log_entry(context, "request_start")
+ self.logger.log(self.log_level, json.dumps(start_entry))
+
+ try:
+ result = await call_next(context)
+
+ success_entry = self._create_log_entry(
+ context,
+ "request_success",
+ result_type=type(result).__name__ if result else None,
+ )
+ self.logger.log(self.log_level, json.dumps(success_entry))
+
+ return result
+ except Exception as e:
+ error_entry = self._create_log_entry(
+ context,
+ "request_error",
+ error_type=type(e).__name__,
+ error_message=str(e),
+ )
+ self.logger.log(logging.ERROR, json.dumps(error_entry))
+ raise
diff --git a/src/fastmcp/server/middleware.py b/src/fastmcp/server/middleware/middleware.py
similarity index 100%
rename from src/fastmcp/server/middleware.py
rename to src/fastmcp/server/middleware/middleware.py
diff --git a/src/fastmcp/server/middleware/rate_limiting.py b/src/fastmcp/server/middleware/rate_limiting.py
new file mode 100644
index 0000000000000000000000000000000000000000..42a0533f78702233438f156f8aebc67d461699ce
--- /dev/null
+++ b/src/fastmcp/server/middleware/rate_limiting.py
@@ -0,0 +1,231 @@
+"""Rate limiting middleware for protecting FastMCP servers from abuse."""
+
+import asyncio
+import time
+from collections import defaultdict, deque
+from collections.abc import Callable
+from typing import Any
+
+from mcp import McpError
+from mcp.types import ErrorData
+
+from .middleware import CallNext, Middleware, MiddlewareContext
+
+
+class RateLimitError(McpError):
+ """Error raised when rate limit is exceeded."""
+
+ def __init__(self, message: str = "Rate limit exceeded"):
+ super().__init__(ErrorData(code=-32000, message=message))
+
+
+class TokenBucketRateLimiter:
+ """Token bucket implementation for rate limiting."""
+
+ def __init__(self, capacity: int, refill_rate: float):
+ """Initialize token bucket.
+
+ Args:
+ capacity: Maximum number of tokens in the bucket
+ refill_rate: Tokens added per second
+ """
+ self.capacity = capacity
+ self.refill_rate = refill_rate
+ self.tokens = capacity
+ self.last_refill = time.time()
+ self._lock = asyncio.Lock()
+
+ async def consume(self, tokens: int = 1) -> bool:
+ """Try to consume tokens from the bucket.
+
+ Args:
+ tokens: Number of tokens to consume
+
+ Returns:
+ True if tokens were available and consumed, False otherwise
+ """
+ async with self._lock:
+ now = time.time()
+ elapsed = now - self.last_refill
+
+ # Add tokens based on elapsed time
+ self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
+ self.last_refill = now
+
+ if self.tokens >= tokens:
+ self.tokens -= tokens
+ return True
+ return False
+
+
+class SlidingWindowRateLimiter:
+ """Sliding window rate limiter implementation."""
+
+ def __init__(self, max_requests: int, window_seconds: int):
+ """Initialize sliding window rate limiter.
+
+ Args:
+ max_requests: Maximum requests allowed in the time window
+ window_seconds: Time window in seconds
+ """
+ self.max_requests = max_requests
+ self.window_seconds = window_seconds
+ self.requests = deque()
+ self._lock = asyncio.Lock()
+
+ async def is_allowed(self) -> bool:
+ """Check if a request is allowed."""
+ async with self._lock:
+ now = time.time()
+ cutoff = now - self.window_seconds
+
+ # Remove old requests outside the window
+ while self.requests and self.requests[0] < cutoff:
+ self.requests.popleft()
+
+ if len(self.requests) < self.max_requests:
+ self.requests.append(now)
+ return True
+ return False
+
+
+class RateLimitingMiddleware(Middleware):
+ """Middleware that implements rate limiting to prevent server abuse.
+
+ Uses a token bucket algorithm by default, allowing for burst traffic
+ while maintaining a sustainable long-term rate.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware
+
+ # Allow 10 requests per second with bursts up to 20
+ rate_limiter = RateLimitingMiddleware(
+ max_requests_per_second=10,
+ burst_capacity=20
+ )
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(rate_limiter)
+ ```
+ """
+
+ def __init__(
+ self,
+ max_requests_per_second: float = 10.0,
+ burst_capacity: int | None = None,
+ get_client_id: Callable[[MiddlewareContext], str] | None = None,
+ global_limit: bool = False,
+ ):
+ """Initialize rate limiting middleware.
+
+ Args:
+ max_requests_per_second: Sustained requests per second allowed
+ burst_capacity: Maximum burst capacity. If None, defaults to 2x max_requests_per_second
+ get_client_id: Function to extract client ID from context. If None, uses global limiting
+ global_limit: If True, apply limit globally; if False, per-client
+ """
+ self.max_requests_per_second = max_requests_per_second
+ self.burst_capacity = burst_capacity or int(max_requests_per_second * 2)
+ self.get_client_id = get_client_id
+ self.global_limit = global_limit
+
+ # Storage for rate limiters per client
+ self.limiters: dict[str, TokenBucketRateLimiter] = defaultdict(
+ lambda: TokenBucketRateLimiter(
+ self.burst_capacity, self.max_requests_per_second
+ )
+ )
+
+ # Global rate limiter
+ if self.global_limit:
+ self.global_limiter = TokenBucketRateLimiter(
+ self.burst_capacity, self.max_requests_per_second
+ )
+
+ def _get_client_identifier(self, context: MiddlewareContext) -> str:
+ """Get client identifier for rate limiting."""
+ if self.get_client_id:
+ return self.get_client_id(context)
+ return "global"
+
+ async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Apply rate limiting to requests."""
+ if self.global_limit:
+ # Global rate limiting
+ allowed = await self.global_limiter.consume()
+ if not allowed:
+ raise RateLimitError("Global rate limit exceeded")
+ else:
+ # Per-client rate limiting
+ client_id = self._get_client_identifier(context)
+ limiter = self.limiters[client_id]
+ allowed = await limiter.consume()
+ if not allowed:
+ raise RateLimitError(f"Rate limit exceeded for client: {client_id}")
+
+ return await call_next(context)
+
+
+class SlidingWindowRateLimitingMiddleware(Middleware):
+ """Middleware that implements sliding window rate limiting.
+
+ Uses a sliding window approach which provides more precise rate limiting
+ but uses more memory to track individual request timestamps.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware
+
+ # Allow 100 requests per minute
+ rate_limiter = SlidingWindowRateLimitingMiddleware(
+ max_requests=100,
+ window_minutes=1
+ )
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(rate_limiter)
+ ```
+ """
+
+ def __init__(
+ self,
+ max_requests: int,
+ window_minutes: int = 1,
+ get_client_id: Callable[[MiddlewareContext], str] | None = None,
+ ):
+ """Initialize sliding window rate limiting middleware.
+
+ Args:
+ max_requests: Maximum requests allowed in the time window
+ window_minutes: Time window in minutes
+ get_client_id: Function to extract client ID from context
+ """
+ self.max_requests = max_requests
+ self.window_seconds = window_minutes * 60
+ self.get_client_id = get_client_id
+
+ # Storage for rate limiters per client
+ self.limiters: dict[str, SlidingWindowRateLimiter] = defaultdict(
+ lambda: SlidingWindowRateLimiter(self.max_requests, self.window_seconds)
+ )
+
+ def _get_client_identifier(self, context: MiddlewareContext) -> str:
+ """Get client identifier for rate limiting."""
+ if self.get_client_id:
+ return self.get_client_id(context)
+ return "global"
+
+ async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Apply sliding window rate limiting to requests."""
+ client_id = self._get_client_identifier(context)
+ limiter = self.limiters[client_id]
+
+ allowed = await limiter.is_allowed()
+ if not allowed:
+ raise RateLimitError(
+ f"Rate limit exceeded: {self.max_requests} requests per "
+ f"{self.window_seconds // 60} minutes for client: {client_id}"
+ )
+
+ return await call_next(context)
diff --git a/src/fastmcp/server/middleware/timing.py b/src/fastmcp/server/middleware/timing.py
new file mode 100644
index 0000000000000000000000000000000000000000..178b3b25068a9a9a3c29b7ca86812c4acb35a6d2
--- /dev/null
+++ b/src/fastmcp/server/middleware/timing.py
@@ -0,0 +1,156 @@
+"""Timing middleware for measuring and logging request performance."""
+
+import logging
+import time
+from typing import Any
+
+from .middleware import CallNext, Middleware, MiddlewareContext
+
+
+class TimingMiddleware(Middleware):
+ """Middleware that logs the execution time of requests.
+
+ Only measures and logs timing for request messages (not notifications).
+ Provides insights into performance characteristics of your MCP server.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.timing import TimingMiddleware
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(TimingMiddleware())
+
+ # Now all requests will be timed and logged
+ ```
+ """
+
+ def __init__(
+ self, logger: logging.Logger | None = None, log_level: int = logging.INFO
+ ):
+ """Initialize timing middleware.
+
+ Args:
+ logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing'
+ log_level: Log level for timing messages (default: INFO)
+ """
+ self.logger = logger or logging.getLogger("fastmcp.timing")
+ self.log_level = log_level
+
+ async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ """Time request execution and log the results."""
+ method = context.method or "unknown"
+
+ start_time = time.perf_counter()
+ try:
+ result = await call_next(context)
+ duration_ms = (time.perf_counter() - start_time) * 1000
+ self.logger.log(
+ self.log_level, f"Request {method} completed in {duration_ms:.2f}ms"
+ )
+ return result
+ except Exception as e:
+ duration_ms = (time.perf_counter() - start_time) * 1000
+ self.logger.log(
+ self.log_level,
+ f"Request {method} failed after {duration_ms:.2f}ms: {e}",
+ )
+ raise
+
+
+class DetailedTimingMiddleware(Middleware):
+ """Enhanced timing middleware with per-operation breakdowns.
+
+ Provides detailed timing information for different types of MCP operations,
+ allowing you to identify performance bottlenecks in specific operations.
+
+ Example:
+ ```python
+ from fastmcp.server.middleware.timing import DetailedTimingMiddleware
+ import logging
+
+ # Configure logging to see the output
+ logging.basicConfig(level=logging.INFO)
+
+ mcp = FastMCP("MyServer")
+ mcp.add_middleware(DetailedTimingMiddleware())
+ ```
+ """
+
+ def __init__(
+ self, logger: logging.Logger | None = None, log_level: int = logging.INFO
+ ):
+ """Initialize detailed timing middleware.
+
+ Args:
+ logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing.detailed'
+ log_level: Log level for timing messages (default: INFO)
+ """
+ self.logger = logger or logging.getLogger("fastmcp.timing.detailed")
+ self.log_level = log_level
+
+ async def _time_operation(
+ self, context: MiddlewareContext, call_next: CallNext, operation_name: str
+ ) -> Any:
+ """Helper method to time any operation."""
+ start_time = time.perf_counter()
+ try:
+ result = await call_next(context)
+ duration_ms = (time.perf_counter() - start_time) * 1000
+ self.logger.log(
+ self.log_level, f"{operation_name} completed in {duration_ms:.2f}ms"
+ )
+ return result
+ except Exception as e:
+ duration_ms = (time.perf_counter() - start_time) * 1000
+ self.logger.log(
+ self.log_level,
+ f"{operation_name} failed after {duration_ms:.2f}ms: {e}",
+ )
+ raise
+
+ async def on_call_tool(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time tool execution."""
+ tool_name = getattr(context.message, "name", "unknown")
+ return await self._time_operation(context, call_next, f"Tool '{tool_name}'")
+
+ async def on_read_resource(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time resource reading."""
+ resource_uri = getattr(context.message, "uri", "unknown")
+ return await self._time_operation(
+ context, call_next, f"Resource '{resource_uri}'"
+ )
+
+ async def on_get_prompt(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time prompt retrieval."""
+ prompt_name = getattr(context.message, "name", "unknown")
+ return await self._time_operation(context, call_next, f"Prompt '{prompt_name}'")
+
+ async def on_list_tools(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time tool listing."""
+ return await self._time_operation(context, call_next, "List tools")
+
+ async def on_list_resources(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time resource listing."""
+ return await self._time_operation(context, call_next, "List resources")
+
+ async def on_list_resource_templates(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time resource template listing."""
+ return await self._time_operation(context, call_next, "List resource templates")
+
+ async def on_list_prompts(
+ self, context: MiddlewareContext, call_next: CallNext
+ ) -> Any:
+ """Time prompt listing."""
+ return await self._time_operation(context, call_next, "List prompts")
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 6f52a7c8a7b4b20ad479dc3571c1d5744104989f..ad19bcd9b02be0ae985eba4dc47655147c893f9b 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -74,6 +74,7 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
+Transport = Literal["stdio", "http", "sse", "streamable-http"]
# Compiled URI parsing regex to split a URI into protocol and path components
URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
@@ -280,7 +281,7 @@ class FastMCP(Generic[LifespanResultT]):
async def run_async(
self,
- transport: Literal["stdio", "streamable-http", "sse"] | None = None,
+ transport: Transport | None = None,
**transport_kwargs: Any,
) -> None:
"""Run the FastMCP server asynchronously.
@@ -290,19 +291,19 @@ class FastMCP(Generic[LifespanResultT]):
"""
if transport is None:
transport = "stdio"
- if transport not in {"stdio", "streamable-http", "sse"}:
+ if transport not in {"stdio", "http", "sse", "streamable-http"}:
raise ValueError(f"Unknown transport: {transport}")
if transport == "stdio":
await self.run_stdio_async(**transport_kwargs)
- elif transport in {"streamable-http", "sse"}:
+ elif transport in {"http", "sse", "streamable-http"}:
await self.run_http_async(transport=transport, **transport_kwargs)
else:
raise ValueError(f"Unknown transport: {transport}")
def run(
self,
- transport: Literal["stdio", "streamable-http", "sse"] | None = None,
+ transport: Transport | None = None,
**transport_kwargs: Any,
) -> None:
"""Run the FastMCP server. Note this is a synchronous function.
@@ -362,6 +363,7 @@ class FastMCP(Generic[LifespanResultT]):
return await self._resource_manager.get_resource_templates()
async def get_resource_template(self, key: str) -> ResourceTemplate:
+ """Get a registered resource template by key."""
templates = await self.get_resource_templates()
if key not in templates:
raise NotFoundError(f"Unknown resource template: {key}")
@@ -402,9 +404,12 @@ class FastMCP(Generic[LifespanResultT]):
include_in_schema: Whether to include in OpenAPI schema, defaults to True
Example:
+ Register a custom HTTP route for a health check endpoint:
+ ```python
@server.custom_route("/health", methods=["GET"])
async def health_check(request: Request) -> Response:
return JSONResponse({"status": "ok"})
+ ```
"""
def decorator(
@@ -815,15 +820,18 @@ class FastMCP(Generic[LifespanResultT]):
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
description: Optional description of what the tool does
tags: Optional set of tags for categorizing the tool
- annotations: Optional annotations about the tool's behavior (e.g. {"is_async": True})
+ annotations: Optional annotations about the tool's behavior
exclude_args: Optional list of argument names to exclude from the tool schema
enabled: Optional boolean to enable or disable the tool
- Example:
+ Examples:
+ Register a tool with a custom name:
+ ```python
@server.tool
def my_tool(x: int) -> str:
return str(x)
+ # Register a tool with a custom name
@server.tool
def my_tool(x: int) -> str:
return str(x)
@@ -838,6 +846,7 @@ class FastMCP(Generic[LifespanResultT]):
# Direct function call
server.tool(my_function, name="custom_name")
+ ```
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
@@ -992,7 +1001,9 @@ class FastMCP(Generic[LifespanResultT]):
tags: Optional set of tags for categorizing the resource
enabled: Optional boolean to enable or disable the resource
- Example:
+ Examples:
+ Register a resource with a custom name:
+ ```python
@server.resource("resource://my-resource")
def get_data() -> str:
return "Hello, world!"
@@ -1015,6 +1026,7 @@ class FastMCP(Generic[LifespanResultT]):
async def get_weather(city: str) -> str:
data = await fetch_weather(city)
return f"Weather for {city}: {data}"
+ ```
"""
# Check if user passed function directly instead of calling decorator
if inspect.isroutine(uri):
@@ -1139,7 +1151,9 @@ class FastMCP(Generic[LifespanResultT]):
tags: Optional set of tags for categorizing the prompt
enabled: Optional boolean to enable or disable the prompt
- Example:
+ Examples:
+
+ ```python
@server.prompt
def analyze_table(table_name: str) -> list[Message]:
schema = read_table_schema(table_name)
@@ -1183,6 +1197,7 @@ class FastMCP(Generic[LifespanResultT]):
# Direct function call
server.prompt(my_function, name="custom_name")
+ ```
"""
if isinstance(name_or_fn, classmethod):
@@ -1255,7 +1270,7 @@ class FastMCP(Generic[LifespanResultT]):
async def run_http_async(
self,
- transport: Literal["streamable-http", "sse"] = "streamable-http",
+ transport: Literal["http", "streamable-http", "sse"] = "http",
host: str | None = None,
port: int | None = None,
log_level: str | None = None,
@@ -1386,7 +1401,7 @@ class FastMCP(Generic[LifespanResultT]):
middleware: list[ASGIMiddleware] | None = None,
json_response: bool | None = None,
stateless_http: bool | None = None,
- transport: Literal["streamable-http", "sse"] = "streamable-http",
+ transport: Literal["http", "streamable-http", "sse"] = "http",
) -> StarletteWithLifespan:
"""Create a Starlette app using the specified HTTP transport.
@@ -1399,7 +1414,7 @@ class FastMCP(Generic[LifespanResultT]):
A Starlette application configured with the specified transport
"""
- if transport == "streamable-http":
+ if transport in ("streamable-http", "http"):
return create_streamable_http_app(
server=self,
streamable_http_path=path
@@ -1446,7 +1461,7 @@ class FastMCP(Generic[LifespanResultT]):
stacklevel=2,
)
await self.run_http_async(
- transport="streamable-http",
+ transport="http",
host=host,
port=port,
log_level=log_level,
@@ -1788,10 +1803,10 @@ class FastMCP(Generic[LifespanResultT]):
) -> FastMCPProxy:
"""Create a FastMCP proxy server for the given backend.
- The ``backend`` argument can be either an existing :class:`~fastmcp.client.Client`
- instance or any value accepted as the ``transport`` argument of
- :class:`~fastmcp.client.Client`. This mirrors the convenience of the
- ``Client`` constructor.
+ The `backend` argument can be either an existing `fastmcp.client.Client`
+ instance or any value accepted as the `transport` argument of
+ `fastmcp.client.Client`. This mirrors the convenience of the
+ `fastmcp.client.Client` constructor.
"""
from fastmcp.client.client import Client
from fastmcp.server.proxy import FastMCPProxy
@@ -1828,14 +1843,14 @@ class FastMCP(Generic[LifespanResultT]):
Given a component, determine if it should be enabled. Returns True if it should be enabled; False if it should not.
Rules:
- ⢠If the component's enabled property is False, always return False.
- ⢠If both include_tags and exclude_tags are None, return True.
- ⢠If exclude_tags is provided, check each exclude tag:
+ - If the component's enabled property is False, always return False.
+ - If both include_tags and exclude_tags are None, return True.
+ - If exclude_tags is provided, check each exclude tag:
- If the exclude tag is a string, it must be present in the input tags to exclude.
- ⢠If include_tags is provided, check each include tag:
+ - If include_tags is provided, check each include tag:
- If the include tag is a string, it must be present in the input tags to include.
- ⢠If include_tags is provided and none of the include tags match, return False.
- ⢠If include_tags is not provided, return True.
+ - If include_tags is provided and none of the include tags match, return False.
+ - If include_tags is not provided, return True.
"""
if not component.enabled:
return False
@@ -1876,12 +1891,21 @@ def add_resource_prefix(
The resource URI with the prefix added
Examples:
- >>> add_resource_prefix("resource://path/to/resource", "prefix")
- "resource://prefix/path/to/resource" # with new style
- >>> add_resource_prefix("resource://path/to/resource", "prefix")
- "prefix+resource://path/to/resource" # with legacy style
- >>> add_resource_prefix("resource:///absolute/path", "prefix")
- "resource://prefix//absolute/path" # with new style
+ With new style:
+ ```python
+ add_resource_prefix("resource://path/to/resource", "prefix")
+ "resource://prefix/path/to/resource"
+ ```
+ With legacy style:
+ ```python
+ add_resource_prefix("resource://path/to/resource", "prefix")
+ "prefix+resource://path/to/resource"
+ ```
+ With absolute path:
+ ```python
+ add_resource_prefix("resource:///absolute/path", "prefix")
+ "resource://prefix//absolute/path"
+ ```
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
@@ -1927,12 +1951,21 @@ def remove_resource_prefix(
The resource URI with the prefix removed
Examples:
- >>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
- "resource://path/to/resource" # with new style
- >>> remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
- "resource://path/to/resource" # with legacy style
- >>> remove_resource_prefix("resource://prefix//absolute/path", "prefix")
- "resource:///absolute/path" # with new style
+ With new style:
+ ```python
+ remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
+ "resource://path/to/resource"
+ ```
+ With legacy style:
+ ```python
+ remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
+ "resource://path/to/resource"
+ ```
+ With absolute path:
+ ```python
+ remove_resource_prefix("resource://prefix//absolute/path", "prefix")
+ "resource:///absolute/path"
+ ```
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
@@ -1985,12 +2018,21 @@ def has_resource_prefix(
True if the URI has the specified prefix, False otherwise
Examples:
- >>> has_resource_prefix("resource://prefix/path/to/resource", "prefix")
- True # with new style
- >>> has_resource_prefix("prefix+resource://path/to/resource", "prefix")
- True # with legacy style
- >>> has_resource_prefix("resource://other/path/to/resource", "prefix")
+ With new style:
+ ```python
+ has_resource_prefix("resource://prefix/path/to/resource", "prefix")
+ True
+ ```
+ With legacy style:
+ ```python
+ has_resource_prefix("prefix+resource://path/to/resource", "prefix")
+ True
+ ```
+ With other path:
+ ```python
+ has_resource_prefix("resource://other/path/to/resource", "prefix")
False
+ ```
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index facf5fbba55a9ed5e0fa3038ecac7eb477d5e34a..ee577a450f1de224315c0293926daf44d914cb66 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -187,12 +187,12 @@ class ToolManager:
# raise ToolErrors as-is
except ToolError as e:
- logger.exception(f"Error calling tool {key!r}: {e}")
+ logger.exception(f"Error calling tool {key!r}")
raise e
# Handle other exceptions
except Exception as e:
- logger.exception(f"Error calling tool {key!r}: {e}")
+ logger.exception(f"Error calling tool {key!r}")
if self.mask_error_details:
# Mask internal details
raise ToolError(f"Error calling tool {key!r}") from e
diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py
index 43fa369d71ff8abc0d80366d1dbb5f6a0158e788..b2b871513a791bde6333e0ca532257accf54bcd0 100644
--- a/src/fastmcp/tools/tool_transform.py
+++ b/src/fastmcp/tools/tool_transform.py
@@ -100,35 +100,55 @@ class ArgTransform:
examples: Examples for the argument. Use ... for no change.
Examples:
- # Rename argument 'old_name' to 'new_name'
+ Rename argument 'old_name' to 'new_name'
+ ```python
ArgTransform(name="new_name")
+ ```
- # Change description only
+ Change description only
+ ```python
ArgTransform(description="Updated description")
+ ```
- # Add a default value (makes argument optional)
+ Add a default value (makes argument optional)
+ ```python
ArgTransform(default=42)
+ ```
- # Add a default factory (makes argument optional)
+ Add a default factory (makes argument optional)
+ ```python
ArgTransform(default_factory=lambda: time.time())
+ ```
- # Change the type
+ Change the type
+ ```python
ArgTransform(type=str)
+ ```
- # Hide the argument entirely from clients
+ Hide the argument entirely from clients
+ ```python
ArgTransform(hide=True)
+ ```
- # Hide argument but pass a constant value to parent
+ Hide argument but pass a constant value to parent
+ ```python
ArgTransform(hide=True, default="constant_value")
+ ```
- # Hide argument but pass a factory-generated value to parent
+ Hide argument but pass a factory-generated value to parent
+ ```python
ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex)
+ ```
- # Make an optional parameter required (removes any default)
+ Make an optional parameter required (removes any default)
+ ```python
ArgTransform(required=True)
+ ```
- # Combine multiple transformations
+ Combine multiple transformations
+ ```python
ArgTransform(name="new_name", description="New desc", default=None, type=int)
+ ```
"""
name: str | EllipsisType = NotSet
@@ -279,9 +299,9 @@ class TransformedTool(Tool):
name: New name for the tool. Defaults to parent tool's name.
transform_args: Optional transformations for parent tool arguments.
Only specified arguments are transformed, others pass through unchanged:
- - str: Simple rename
- - ArgTransform: Complex transformation (rename/description/default/drop)
- - None: Drop the argument
+ - Simple rename (str)
+ - Complex transformation (rename/description/default/drop) (ArgTransform)
+ - Drop the argument (None)
description: New description. Defaults to parent's description.
tags: New tags. Defaults to parent's tags.
annotations: New annotations. Defaults to parent's annotations.
@@ -290,23 +310,29 @@ class TransformedTool(Tool):
Returns:
TransformedTool with the specified transformations.
- Examples:
+ Examples:
# Transform specific arguments only
+ ```python
Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged
+ ```
# Custom function with partial transforms
+ ```python
async def custom(x: int, y: int) -> str:
result = await forward(x=x, y=y)
return f"Custom: {result}"
Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"})
+ ```
# Using **kwargs (gets all args, transformed and untransformed)
+ ```python
async def flexible(**kwargs) -> str:
result = await forward(**kwargs)
return f"Got: {kwargs}"
Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"})
+ ```
"""
transform_args = transform_args or {}
@@ -423,8 +449,8 @@ class TransformedTool(Tool):
Returns:
A tuple containing:
- - dict: The new JSON schema for the transformed tool
- - Callable: Async function that validates and forwards calls to the parent tool
+ - The new JSON schema for the transformed tool as a dictionary
+ - Async function that validates and forwards calls to the parent tool
"""
# Build transformed schema and mapping
diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py
index 40300d7ebc1a023a9bf4ff481130021a9259af03..de7aad8f1155092a05632d4224d80f40c1b89d86 100644
--- a/src/fastmcp/utilities/mcp_config.py
+++ b/src/fastmcp/utilities/mcp_config.py
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
def infer_transport_type_from_url(
url: str | AnyUrl,
-) -> Literal["streamable-http", "sse"]:
+) -> Literal["http", "sse"]:
"""
Infer the appropriate transport type from the given URL.
"""
@@ -34,7 +34,7 @@ def infer_transport_type_from_url(
if re.search(r"/sse(/|\?|&|$)", path):
return "sse"
else:
- return "streamable-http"
+ return "http"
class StdioMCPServer(FastMCPBaseModel):
@@ -58,7 +58,7 @@ class StdioMCPServer(FastMCPBaseModel):
class RemoteMCPServer(FastMCPBaseModel):
url: str
headers: dict[str, str] = Field(default_factory=dict)
- transport: Literal["streamable-http", "sse"] | None = None
+ transport: Literal["http", "streamable-http", "sse"] | None = None
auth: Annotated[
str | Literal["oauth"] | httpx.Auth | None,
Field(
@@ -79,6 +79,7 @@ class RemoteMCPServer(FastMCPBaseModel):
if transport == "sse":
return SSETransport(self.url, headers=self.headers, auth=self.auth)
else:
+ # Both "http" and "streamable-http" map to StreamableHttpTransport
return StreamableHttpTransport(
self.url, headers=self.headers, auth=self.auth
)
diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py
index 0cae85cb24ee577f12da0130a40b8cf7fd5064e7..c29b20661ceee5e041b3f1f7298f187fbf58b1a3 100644
--- a/src/fastmcp/utilities/openapi.py
+++ b/src/fastmcp/utilities/openapi.py
@@ -274,6 +274,12 @@ class OpenAPIParser(
result = {}
return _replace_ref_with_defs(result)
+ except ValueError as e:
+ # Re-raise ValueError for external reference errors and other validation issues
+ if "External or non-local reference not supported" in str(e):
+ raise
+ logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
+ return {}
except Exception as e:
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
return {}
@@ -302,11 +308,17 @@ class OpenAPIParser(
# Extract parameter info - handle both 3.0 and 3.1 parameter models
param_in = parameter.param_in # Both use param_in
- param_location = self._convert_to_parameter_location(param_in)
+ # Handle enum or string parameter locations
+ from enum import Enum
+
+ param_in_str = (
+ param_in.value if isinstance(param_in, Enum) else param_in
+ )
+ param_location = self._convert_to_parameter_location(param_in_str)
param_schema_obj = parameter.param_schema # Both use param_schema
# Skip duplicate parameters (same name and location)
- param_key = (parameter.name, param_in)
+ param_key = (parameter.name, param_in_str)
if param_key in seen_params:
continue
seen_params[param_key] = True
@@ -400,12 +412,30 @@ class OpenAPIParser(
request_body_info.content_schema[media_type_str] = (
schema_dict
)
+ except ValueError as e:
+ # Re-raise ValueError for external reference errors
+ if "External or non-local reference not supported" in str(
+ e
+ ):
+ raise
+ logger.error(
+ f"Failed to extract schema for media type '{media_type_str}': {e}"
+ )
except Exception as e:
logger.error(
f"Failed to extract schema for media type '{media_type_str}': {e}"
)
return request_body_info
+ except ValueError as e:
+ # Re-raise ValueError for external reference errors
+ if "External or non-local reference not supported" in str(e):
+ raise
+ ref_name = getattr(request_body_or_ref, "ref", "unknown")
+ logger.error(
+ f"Failed to extract request body '{ref_name}': {e}", exc_info=False
+ )
+ return None
except Exception as e:
ref_name = getattr(request_body_or_ref, "ref", "unknown")
logger.error(
@@ -449,6 +479,17 @@ class OpenAPIParser(
media_type_obj.media_type_schema
)
resp_info.content_schema[media_type_str] = schema_dict
+ except ValueError as e:
+ # Re-raise ValueError for external reference errors
+ if (
+ "External or non-local reference not supported"
+ in str(e)
+ ):
+ raise
+ logger.error(
+ f"Failed to extract schema for media type '{media_type_str}' "
+ f"in response {status_code}: {e}"
+ )
except Exception as e:
logger.error(
f"Failed to extract schema for media type '{media_type_str}' "
@@ -456,6 +497,16 @@ class OpenAPIParser(
)
extracted_responses[str(status_code)] = resp_info
+ except ValueError as e:
+ # Re-raise ValueError for external reference errors
+ if "External or non-local reference not supported" in str(e):
+ raise
+ ref_name = getattr(resp_or_ref, "ref", "unknown")
+ logger.error(
+ f"Failed to extract response for status code {status_code} "
+ f"from reference '{ref_name}': {e}",
+ exc_info=False,
+ )
except Exception as e:
ref_name = getattr(resp_or_ref, "ref", "unknown")
logger.error(
@@ -556,6 +607,17 @@ class OpenAPIParser(
logger.info(
f"Successfully extracted route: {method_upper} {path_str}"
)
+ except ValueError as op_error:
+ # Re-raise ValueError for external reference errors
+ if "External or non-local reference not supported" in str(
+ op_error
+ ):
+ raise
+ op_id = getattr(operation, "operationId", "unknown")
+ logger.error(
+ f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
+ exc_info=True,
+ )
except Exception as op_error:
op_id = getattr(operation, "operationId", "unknown")
logger.error(
@@ -901,6 +963,12 @@ def _replace_ref_with_defs(
if ref_path.startswith("#/components/schemas/"):
schema_name = ref_path.split("/")[-1]
schema["$ref"] = f"#/$defs/{schema_name}"
+ elif not ref_path.startswith("#/"):
+ raise ValueError(
+ f"External or non-local reference not supported: {ref_path}. "
+ f"FastMCP only supports local schema references starting with '#/'. "
+ f"Please include all schema definitions within the OpenAPI document."
+ )
elif properties := schema.get("properties"):
if "$ref" in properties:
schema["properties"] = _replace_ref_with_defs(properties)
diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py
index 9b0084fc17aeee5c8485a1a003976009cd9c20c8..113163718144fb30ddebbc353b7feca37233bc75 100644
--- a/src/fastmcp/utilities/tests.py
+++ b/src/fastmcp/utilities/tests.py
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
@contextmanager
def temporary_settings(**kwargs: Any):
"""
- Temporarily override ControlFlow setting values.
+ Temporarily override FastMCP setting values.
Args:
**kwargs: The settings to override, including nested settings.
diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py
index ac7e529b12ce51f47442a64210491340f18a1c1d..07790ef48dfa99affe8a780b89027045c2888892 100644
--- a/tests/auth/providers/test_bearer.py
+++ b/tests/auth/providers/test_bearer.py
@@ -65,7 +65,7 @@ def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
with run_server_in_process(
run_mcp_server,
public_key=rsa_key_pair.public_key,
- run_kwargs=dict(transport="streamable-http"),
+ run_kwargs=dict(transport="http"),
) as url:
yield f"{url}/mcp/"
@@ -696,7 +696,7 @@ class TestFastMCPBearerAuth:
run_mcp_server,
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(required_scopes=["read", "write"]),
- run_kwargs=dict(transport="streamable-http"),
+ run_kwargs=dict(transport="http"),
) as url:
mcp_server_url = f"{url}/mcp/"
with pytest.raises(httpx.HTTPStatusError) as exc_info:
@@ -719,7 +719,7 @@ class TestFastMCPBearerAuth:
run_mcp_server,
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(required_scopes=["read", "write"]),
- run_kwargs=dict(transport="streamable-http"),
+ run_kwargs=dict(transport="http"),
) as url:
mcp_server_url = f"{url}/mcp/"
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py
index f36cf4c916d22fa1e460469eaf8ab8cb3f63c146..cb7204818ab8d317c014b70a64115d2b70121b5d 100644
--- a/tests/auth/test_oauth_client.py
+++ b/tests/auth/test_oauth_client.py
@@ -43,7 +43,7 @@ def run_server(host: str, port: int, **kwargs) -> None:
@pytest.fixture(scope="module")
def streamable_http_server() -> Generator[str, None, None]:
- with run_server_in_process(run_server, transport="streamable-http") as url:
+ with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp/"
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index 4897cc6dd02c393b43b1b5773793f8cc450844d8..a199a24c1cbd0623db8ac52c11bbe1c851b52cc5 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -328,6 +328,41 @@ class TestRunCommand:
assert result.exit_code == 0
mock_server.run.assert_called_once_with(transport="sse")
+ def test_run_command_with_http_transports(self, temp_python_file):
+ """Test run command with both http and streamable-http transport options."""
+ # Test "http" transport
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app, ["run", str(temp_python_file), "--transport", "http"]
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(transport="http")
+
+ # Test "streamable-http" transport (alias for http)
+ with (
+ patch("fastmcp.cli.run.parse_file_path") as mock_parse,
+ patch("fastmcp.cli.run.import_server") as mock_import,
+ ):
+ mock_parse.return_value = (temp_python_file, None)
+ mock_server = MagicMock()
+ mock_server.name = "test_server"
+ mock_import.return_value = mock_server
+
+ result = runner.invoke(
+ cli.app,
+ ["run", str(temp_python_file), "--transport", "streamable-http"],
+ )
+ assert result.exit_code == 0
+ mock_server.run.assert_called_once_with(transport="streamable-http")
+
def test_run_command_with_host(self, temp_python_file):
"""Test run command with host option."""
with (
diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py
index 2ee4727a90fc09fcd77ef43d1ea1abd8f8bc5941..6f662e927fc6fce540f8c7e63cacb03c718fb5a1 100644
--- a/tests/client/test_openapi.py
+++ b/tests/client/test_openapi.py
@@ -56,7 +56,7 @@ def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
class TestClientHeaders:
@pytest.fixture(scope="class")
def shttp_server(self) -> Generator[str, None, None]:
- with run_server_in_process(run_server, transport="streamable-http") as url:
+ with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp/"
@pytest.fixture(scope="class")
@@ -69,7 +69,7 @@ class TestClientHeaders:
with run_server_in_process(
run_proxy_server,
shttp_url=shttp_server,
- transport="streamable-http",
+ transport="http",
) as url:
yield f"{url}/mcp/"
diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py
index 5b182c9333951c46693d8493487d0fa60d0076f9..efcb79f162bf3cc0b589ecce15df47438095d974 100644
--- a/tests/client/test_streamable_http.py
+++ b/tests/client/test_streamable_http.py
@@ -103,13 +103,24 @@ async def streamable_http_server(
stateless_http: bool = False,
) -> AsyncGenerator[str, None]:
with run_server_in_process(
- run_server, stateless_http=stateless_http, transport="streamable-http"
+ run_server, stateless_http=stateless_http, transport="http"
) as url:
async with Client(transport=StreamableHttpTransport(f"{url}/mcp/")) as client:
assert await client.ping()
yield f"{url}/mcp/"
+@pytest.fixture()
+async def streamable_http_server_with_streamable_http_alias() -> AsyncGenerator[
+ str, None
+]:
+ """Test that the "streamable-http" transport alias works."""
+ with run_server_in_process(run_server, transport="streamable-http") as url:
+ async with Client(transport=StreamableHttpTransport(f"{url}/mcp/")) as client:
+ assert await client.ping()
+ yield f"{url}/mcp/"
+
+
async def test_ping(streamable_http_server: str):
"""Test pinging the server."""
async with Client(
@@ -119,6 +130,19 @@ async def test_ping(streamable_http_server: str):
assert result is True
+async def test_ping_with_streamable_http_alias(
+ streamable_http_server_with_streamable_http_alias: str,
+):
+ """Test pinging the server."""
+ async with Client(
+ transport=StreamableHttpTransport(
+ streamable_http_server_with_streamable_http_alias
+ )
+ ) as client:
+ result = await client.ping()
+ assert result is True
+
+
async def test_http_headers(streamable_http_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
diff --git a/tests/deprecated/test_deprecated.py b/tests/deprecated/test_deprecated.py
index f71161a98bf98ab8f6597acd48e0c7617582cb68..92b28cda805652e42aece97f16b7e9e0b635b05a 100644
--- a/tests/deprecated/test_deprecated.py
+++ b/tests/deprecated/test_deprecated.py
@@ -85,7 +85,7 @@ async def test_run_streamable_http_async_deprecation_warning():
# Verify the mock was called with the right transport
mock_run.assert_called_once()
call_kwargs = mock_run.call_args.kwargs
- assert call_kwargs.get("transport") == "streamable-http"
+ assert call_kwargs.get("transport") == "http"
def test_http_app_with_sse_transport():
diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py
index 514f0a9d35d9f33815900834e35890a6f30c8825..ff88e1e7a1e31e964883f176b22b7100eee9e777 100644
--- a/tests/server/http/test_http_dependencies.py
+++ b/tests/server/http/test_http_dependencies.py
@@ -44,7 +44,7 @@ def run_server(host: str, port: int, **kwargs) -> None:
@pytest.fixture(autouse=True, scope="module")
def shttp_server() -> Generator[str, None, None]:
- with run_server_in_process(run_server, transport="streamable-http") as url:
+ with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp/"
diff --git a/tests/server/http/test_http_middleware.py b/tests/server/http/test_http_middleware.py
index 0c36d0522b4cbbfc2068fb0bc0b20eab4a1be7e4..6fbe143637be3d740ed1e85dbdee80a55f5d63d5 100644
--- a/tests/server/http/test_http_middleware.py
+++ b/tests/server/http/test_http_middleware.py
@@ -96,7 +96,7 @@ async def test_streamable_http_app_with_custom_middleware():
server._additional_http_routes = routes
# Create the app with custom middleware
- app = server.http_app(transport="streamable-http", middleware=custom_middleware)
+ app = server.http_app(transport="http", middleware=custom_middleware)
# Create a test client
transport = ASGITransport(app=app)
diff --git a/tests/server/middleware/test_error_handling.py b/tests/server/middleware/test_error_handling.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee61ba9b61302a8d34192c8677f7599ef7187502
--- /dev/null
+++ b/tests/server/middleware/test_error_handling.py
@@ -0,0 +1,601 @@
+"""Tests for error handling middleware."""
+
+import logging
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from mcp import McpError
+
+from fastmcp.server.middleware.error_handling import (
+ ErrorHandlingMiddleware,
+ RetryMiddleware,
+)
+from fastmcp.server.middleware.middleware import MiddlewareContext
+
+
+@pytest.fixture
+def mock_context():
+ """Create a mock middleware context."""
+ context = MagicMock(spec=MiddlewareContext)
+ context.method = "test_method"
+ return context
+
+
+@pytest.fixture
+def mock_call_next():
+ """Create a mock call_next function."""
+ return AsyncMock(return_value="test_result")
+
+
+class TestErrorHandlingMiddleware:
+ """Test error handling middleware functionality."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = ErrorHandlingMiddleware()
+ assert middleware.logger.name == "fastmcp.errors"
+ assert middleware.include_traceback is False
+ assert middleware.error_callback is None
+ assert middleware.transform_errors is True
+ assert middleware.error_counts == {}
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+ logger = logging.getLogger("custom")
+ callback = MagicMock()
+
+ middleware = ErrorHandlingMiddleware(
+ logger=logger,
+ include_traceback=True,
+ error_callback=callback,
+ transform_errors=False,
+ )
+ assert middleware.logger is logger
+ assert middleware.include_traceback is True
+ assert middleware.error_callback is callback
+ assert middleware.transform_errors is False
+
+ def test_log_error_basic(self, mock_context, caplog):
+ """Test basic error logging."""
+ middleware = ErrorHandlingMiddleware()
+ error = ValueError("test error")
+
+ with caplog.at_level(logging.ERROR):
+ middleware._log_error(error, mock_context)
+
+ assert "Error in test_method: ValueError: test error" in caplog.text
+ assert "ValueError:test_method" in middleware.error_counts
+ assert middleware.error_counts["ValueError:test_method"] == 1
+
+ def test_log_error_with_traceback(self, mock_context, caplog):
+ """Test error logging with traceback."""
+ middleware = ErrorHandlingMiddleware(include_traceback=True)
+ error = ValueError("test error")
+
+ with caplog.at_level(logging.ERROR):
+ middleware._log_error(error, mock_context)
+
+ assert "Error in test_method: ValueError: test error" in caplog.text
+ # The traceback is added to the log message
+ assert "Error in test_method: ValueError: test error" in caplog.text
+
+ def test_log_error_with_callback(self, mock_context):
+ """Test error logging with callback."""
+ callback = MagicMock()
+ middleware = ErrorHandlingMiddleware(error_callback=callback)
+ error = ValueError("test error")
+
+ middleware._log_error(error, mock_context)
+
+ callback.assert_called_once_with(error, mock_context)
+
+ def test_log_error_callback_exception(self, mock_context, caplog):
+ """Test error logging when callback raises exception."""
+ callback = MagicMock(side_effect=RuntimeError("callback error"))
+ middleware = ErrorHandlingMiddleware(error_callback=callback)
+ error = ValueError("test error")
+
+ with caplog.at_level(logging.ERROR):
+ middleware._log_error(error, mock_context)
+
+ assert "Error in error callback: callback error" in caplog.text
+
+ def test_transform_error_mcp_error(self):
+ """Test that MCP errors are not transformed."""
+ middleware = ErrorHandlingMiddleware()
+ from mcp.types import ErrorData
+
+ error = McpError(ErrorData(code=-32001, message="test error"))
+
+ result = middleware._transform_error(error)
+
+ assert result is error
+
+ def test_transform_error_disabled(self):
+ """Test error transformation when disabled."""
+ middleware = ErrorHandlingMiddleware(transform_errors=False)
+ error = ValueError("test error")
+
+ result = middleware._transform_error(error)
+
+ assert result is error
+
+ def test_transform_error_value_error(self):
+ """Test transforming ValueError."""
+ middleware = ErrorHandlingMiddleware()
+ error = ValueError("test error")
+
+ result = middleware._transform_error(error)
+
+ assert isinstance(result, McpError)
+ assert result.error.code == -32602
+ assert "Invalid params: test error" in result.error.message
+
+ def test_transform_error_file_not_found(self):
+ """Test transforming FileNotFoundError."""
+ middleware = ErrorHandlingMiddleware()
+ error = FileNotFoundError("test error")
+
+ result = middleware._transform_error(error)
+
+ assert isinstance(result, McpError)
+ assert result.error.code == -32001
+ assert "Resource not found: test error" in result.error.message
+
+ def test_transform_error_permission_error(self):
+ """Test transforming PermissionError."""
+ middleware = ErrorHandlingMiddleware()
+ error = PermissionError("test error")
+
+ result = middleware._transform_error(error)
+
+ assert isinstance(result, McpError)
+ assert result.error.code == -32000
+ assert "Permission denied: test error" in result.error.message
+
+ def test_transform_error_timeout_error(self):
+ """Test transforming TimeoutError."""
+ middleware = ErrorHandlingMiddleware()
+ error = TimeoutError("test error")
+
+ result = middleware._transform_error(error)
+
+ assert isinstance(result, McpError)
+ assert result.error.code == -32000
+ assert "Request timeout: test error" in result.error.message
+
+ def test_transform_error_generic(self):
+ """Test transforming generic error."""
+ middleware = ErrorHandlingMiddleware()
+ error = RuntimeError("test error")
+
+ result = middleware._transform_error(error)
+
+ assert isinstance(result, McpError)
+ assert result.error.code == -32603
+ assert "Internal error: test error" in result.error.message
+
+ async def test_on_message_success(self, mock_context, mock_call_next):
+ """Test successful message handling."""
+ middleware = ErrorHandlingMiddleware()
+
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.called
+
+ async def test_on_message_error_transform(self, mock_context, caplog):
+ """Test error handling with transformation."""
+ middleware = ErrorHandlingMiddleware()
+ mock_call_next = AsyncMock(side_effect=ValueError("test error"))
+
+ with caplog.at_level(logging.ERROR):
+ with pytest.raises(McpError) as exc_info:
+ await middleware.on_message(mock_context, mock_call_next)
+
+ assert exc_info.value.error.code == -32602
+ assert "Invalid params: test error" in exc_info.value.error.message
+ assert "Error in test_method: ValueError: test error" in caplog.text
+
+ def test_get_error_stats(self, mock_context):
+ """Test getting error statistics."""
+ middleware = ErrorHandlingMiddleware()
+ error1 = ValueError("error1")
+ error2 = ValueError("error2")
+ error3 = RuntimeError("error3")
+
+ middleware._log_error(error1, mock_context)
+ middleware._log_error(error2, mock_context)
+ middleware._log_error(error3, mock_context)
+
+ stats = middleware.get_error_stats()
+ assert stats["ValueError:test_method"] == 2
+ assert stats["RuntimeError:test_method"] == 1
+
+
+class TestRetryMiddleware:
+ """Test retry middleware functionality."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = RetryMiddleware()
+ assert middleware.max_retries == 3
+ assert middleware.base_delay == 1.0
+ assert middleware.max_delay == 60.0
+ assert middleware.backoff_multiplier == 2.0
+ assert middleware.retry_exceptions == (ConnectionError, TimeoutError)
+ assert middleware.logger.name == "fastmcp.retry"
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+ logger = logging.getLogger("custom")
+ middleware = RetryMiddleware(
+ max_retries=5,
+ base_delay=2.0,
+ max_delay=120.0,
+ backoff_multiplier=3.0,
+ retry_exceptions=(ValueError, RuntimeError),
+ logger=logger,
+ )
+ assert middleware.max_retries == 5
+ assert middleware.base_delay == 2.0
+ assert middleware.max_delay == 120.0
+ assert middleware.backoff_multiplier == 3.0
+ assert middleware.retry_exceptions == (ValueError, RuntimeError)
+ assert middleware.logger is logger
+
+ def test_should_retry_true(self):
+ """Test retry decision for retryable errors."""
+ middleware = RetryMiddleware()
+
+ assert middleware._should_retry(ConnectionError()) is True
+ assert middleware._should_retry(TimeoutError()) is True
+
+ def test_should_retry_false(self):
+ """Test retry decision for non-retryable errors."""
+ middleware = RetryMiddleware()
+
+ assert middleware._should_retry(ValueError()) is False
+ assert middleware._should_retry(RuntimeError()) is False
+
+ def test_calculate_delay(self):
+ """Test delay calculation."""
+ middleware = RetryMiddleware(
+ base_delay=1.0, backoff_multiplier=2.0, max_delay=10.0
+ )
+
+ assert middleware._calculate_delay(0) == 1.0
+ assert middleware._calculate_delay(1) == 2.0
+ assert middleware._calculate_delay(2) == 4.0
+ assert middleware._calculate_delay(3) == 8.0
+ assert middleware._calculate_delay(4) == 10.0 # capped at max_delay
+
+ async def test_on_request_success_first_try(self, mock_context, mock_call_next):
+ """Test successful request on first try."""
+ middleware = RetryMiddleware()
+
+ result = await middleware.on_request(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.call_count == 1
+
+ async def test_on_request_success_after_retries(self, mock_context, caplog):
+ """Test successful request after retries."""
+ middleware = RetryMiddleware(base_delay=0.01) # Fast retry for testing
+
+ # Fail first two attempts, succeed on third
+ mock_call_next = AsyncMock(
+ side_effect=[
+ ConnectionError("connection failed"),
+ ConnectionError("connection failed"),
+ "test_result",
+ ]
+ )
+
+ with caplog.at_level(logging.WARNING):
+ result = await middleware.on_request(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.call_count == 3
+ assert "Retrying in" in caplog.text
+
+ async def test_on_request_max_retries_exceeded(self, mock_context, caplog):
+ """Test request failing after max retries."""
+ middleware = RetryMiddleware(max_retries=2, base_delay=0.01)
+
+ # Fail all attempts
+ mock_call_next = AsyncMock(side_effect=ConnectionError("connection failed"))
+
+ with caplog.at_level(logging.WARNING):
+ with pytest.raises(ConnectionError):
+ await middleware.on_request(mock_context, mock_call_next)
+
+ assert mock_call_next.call_count == 3 # initial + 2 retries
+ assert "Retrying in" in caplog.text
+
+ async def test_on_request_non_retryable_error(self, mock_context):
+ """Test non-retryable error is not retried."""
+ middleware = RetryMiddleware()
+ mock_call_next = AsyncMock(side_effect=ValueError("non-retryable"))
+
+ with pytest.raises(ValueError):
+ await middleware.on_request(mock_context, mock_call_next)
+
+ assert mock_call_next.call_count == 1 # No retries
+
+
+@pytest.fixture
+def error_handling_server():
+ """Create a FastMCP server specifically for error handling middleware tests."""
+ from fastmcp import FastMCP
+
+ mcp = FastMCP("ErrorHandlingTestServer")
+
+ @mcp.tool
+ def reliable_operation(data: str) -> str:
+ """A reliable operation that always succeeds."""
+ return f"Success: {data}"
+
+ @mcp.tool
+ def failing_operation(error_type: str = "value") -> str:
+ """An operation that fails with different error types."""
+ if error_type == "value":
+ raise ValueError("Value error occurred")
+ elif error_type == "file":
+ raise FileNotFoundError("File not found")
+ elif error_type == "permission":
+ raise PermissionError("Permission denied")
+ elif error_type == "timeout":
+ raise TimeoutError("Operation timed out")
+ elif error_type == "generic":
+ raise RuntimeError("Generic runtime error")
+ else:
+ return "Operation completed"
+
+ @mcp.tool
+ def intermittent_operation(fail_rate: float = 0.5) -> str:
+ """An operation that fails intermittently."""
+ import random
+
+ if random.random() < fail_rate:
+ raise ConnectionError("Random connection failure")
+ return "Operation succeeded"
+
+ @mcp.tool
+ def retryable_operation(attempt_count: int = 0) -> str:
+ """An operation that succeeds after a few attempts."""
+ # This is a simple way to simulate retry behavior
+ # In a real scenario, you might use external state
+ if attempt_count < 2:
+ raise ConnectionError("Temporary connection error")
+ return "Operation succeeded after retries"
+
+ return mcp
+
+
+class TestErrorHandlingMiddlewareIntegration:
+ """Integration tests for error handling middleware with real FastMCP server."""
+
+ async def test_error_handling_middleware_logs_real_errors(
+ self, error_handling_server, caplog
+ ):
+ """Test that error handling middleware logs real errors from tools."""
+ from fastmcp.client import Client
+
+ error_handling_server.add_middleware(ErrorHandlingMiddleware())
+
+ with caplog.at_level(logging.ERROR):
+ async with Client(error_handling_server) as client:
+ # Test different types of errors
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "file"})
+
+ log_text = caplog.text
+
+ # Should have error logs for both failures
+ assert "Error in tools/call: ToolError:" in log_text
+ # Should have captured both error instances
+ error_count = log_text.count("Error in tools/call:")
+ assert error_count == 2
+
+ async def test_error_handling_middleware_tracks_error_statistics(
+ self, error_handling_server
+ ):
+ """Test that error handling middleware accurately tracks error statistics."""
+ from fastmcp.client import Client
+
+ error_middleware = ErrorHandlingMiddleware()
+ error_handling_server.add_middleware(error_middleware)
+
+ async with Client(error_handling_server) as client:
+ # Generate different types of errors
+ for _ in range(3):
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ for _ in range(2):
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "file"})
+
+ # Try some intermittent operations (some may succeed)
+ for _ in range(5):
+ try:
+ await client.call_tool("intermittent_operation", {"fail_rate": 0.8})
+ except Exception:
+ pass # Expected failures
+
+ # Check error statistics
+ stats = error_middleware.get_error_stats()
+
+ # Should have tracked the ToolError wrapper
+ assert "ToolError:tools/call" in stats
+ assert stats["ToolError:tools/call"] >= 5 # At least the 5 deliberate failures
+
+ async def test_error_handling_middleware_with_success_and_failure(
+ self, error_handling_server, caplog
+ ):
+ """Test error handling middleware with mix of successful and failed operations."""
+ from fastmcp.client import Client
+
+ error_handling_server.add_middleware(ErrorHandlingMiddleware())
+
+ with caplog.at_level(logging.ERROR):
+ async with Client(error_handling_server) as client:
+ # Successful operation (should not generate error logs)
+ await client.call_tool("reliable_operation", {"data": "test"})
+
+ # Failed operation (should generate error log)
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ # Another successful operation
+ await client.call_tool("reliable_operation", {"data": "test2"})
+
+ log_text = caplog.text
+
+ # Should only have one error log (for the failed operation)
+ error_count = log_text.count("Error in tools/call:")
+ assert error_count == 1
+
+ async def test_error_handling_middleware_custom_callback(
+ self, error_handling_server
+ ):
+ """Test error handling middleware with custom error callback."""
+ from fastmcp.client import Client
+
+ captured_errors = []
+
+ def error_callback(error, context):
+ captured_errors.append(
+ {
+ "error_type": type(error).__name__,
+ "message": str(error),
+ "method": context.method,
+ }
+ )
+
+ error_handling_server.add_middleware(
+ ErrorHandlingMiddleware(error_callback=error_callback)
+ )
+
+ async with Client(error_handling_server) as client:
+ # Generate some errors
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "timeout"})
+
+ # Check that callback was called
+ assert len(captured_errors) == 2
+ assert captured_errors[0]["error_type"] == "ToolError"
+ assert captured_errors[1]["error_type"] == "ToolError"
+ assert all(error["method"] == "tools/call" for error in captured_errors)
+
+ async def test_error_handling_middleware_transform_errors(
+ self, error_handling_server
+ ):
+ """Test error transformation functionality."""
+ from fastmcp.client import Client
+
+ error_handling_server.add_middleware(
+ ErrorHandlingMiddleware(transform_errors=True)
+ )
+
+ async with Client(error_handling_server) as client:
+ # All errors should still be raised, but potentially transformed
+ with pytest.raises(Exception) as exc_info:
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ # Error should still exist (may be wrapped by FastMCP)
+ assert exc_info.value is not None
+
+
+class TestRetryMiddlewareIntegration:
+ """Integration tests for retry middleware with real FastMCP server."""
+
+ async def test_retry_middleware_with_transient_failures(
+ self, error_handling_server, caplog
+ ):
+ """Test retry middleware with operations that have transient failures."""
+ from fastmcp.client import Client
+
+ # Configure retry middleware to retry connection errors
+ error_handling_server.add_middleware(
+ RetryMiddleware(
+ max_retries=3,
+ base_delay=0.01, # Very short delay for testing
+ retry_exceptions=(ConnectionError,),
+ )
+ )
+
+ with caplog.at_level(logging.WARNING):
+ async with Client(error_handling_server) as client:
+ # This operation fails intermittently - try several times
+ success_count = 0
+ for _ in range(5):
+ try:
+ await client.call_tool(
+ "intermittent_operation", {"fail_rate": 0.7}
+ )
+ success_count += 1
+ except Exception:
+ pass # Some failures expected even with retries
+
+ # Should have some retry log messages
+ # Note: Retry logs might not appear if the underlying errors are wrapped by FastMCP
+ # The key is that some operations should succeed due to retries
+
+ async def test_retry_middleware_with_permanent_failures(
+ self, error_handling_server
+ ):
+ """Test that retry middleware doesn't retry non-retryable errors."""
+ from fastmcp.client import Client
+
+ # Configure retry middleware for connection errors only
+ error_handling_server.add_middleware(
+ RetryMiddleware(
+ max_retries=3, base_delay=0.01, retry_exceptions=(ConnectionError,)
+ )
+ )
+
+ async with Client(error_handling_server) as client:
+ # Value errors should not be retried
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ # Should fail immediately without retries
+
+ async def test_combined_error_handling_and_retry_middleware(
+ self, error_handling_server, caplog
+ ):
+ """Test error handling and retry middleware working together."""
+ from fastmcp.client import Client
+
+ # Add both middleware
+ error_handling_server.add_middleware(ErrorHandlingMiddleware())
+ error_handling_server.add_middleware(
+ RetryMiddleware(
+ max_retries=2, base_delay=0.01, retry_exceptions=(ConnectionError,)
+ )
+ )
+
+ with caplog.at_level(logging.ERROR):
+ async with Client(error_handling_server) as client:
+ # Try intermittent operation
+ try:
+ await client.call_tool("intermittent_operation", {"fail_rate": 0.9})
+ except Exception:
+ pass # May still fail even with retries
+
+ # Try permanent failure
+ with pytest.raises(Exception):
+ await client.call_tool("failing_operation", {"error_type": "value"})
+
+ log_text = caplog.text
+
+ # Should have error logs from error handling middleware
+ assert "Error in tools/call:" in log_text
diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c5879e08776a3768e3c62f13b6777582552610c
--- /dev/null
+++ b/tests/server/middleware/test_logging.py
@@ -0,0 +1,426 @@
+"""Tests for logging middleware."""
+
+import json
+import logging
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from fastmcp.server.middleware.logging import (
+ LoggingMiddleware,
+ StructuredLoggingMiddleware,
+)
+from fastmcp.server.middleware.middleware import MiddlewareContext
+
+
+@pytest.fixture
+def mock_context():
+ """Create a mock middleware context."""
+ context = MagicMock(spec=MiddlewareContext)
+ context.method = "test_method"
+ context.source = "client"
+ context.type = "request"
+ context.message = MagicMock()
+ context.message.__dict__ = {"param": "value"}
+ context.timestamp = MagicMock()
+ context.timestamp.isoformat.return_value = "2023-01-01T00:00:00Z"
+ return context
+
+
+@pytest.fixture
+def mock_call_next():
+ """Create a mock call_next function."""
+ return AsyncMock(return_value="test_result")
+
+
+class TestLoggingMiddleware:
+ """Test logging middleware functionality."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = LoggingMiddleware()
+ assert middleware.logger.name == "fastmcp.requests"
+ assert middleware.log_level == logging.INFO
+ assert middleware.include_payloads is False
+ assert middleware.max_payload_length == 1000
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+ logger = logging.getLogger("custom")
+ middleware = LoggingMiddleware(
+ logger=logger,
+ log_level=logging.DEBUG,
+ include_payloads=True,
+ max_payload_length=500,
+ )
+ assert middleware.logger is logger
+ assert middleware.log_level == logging.DEBUG
+ assert middleware.include_payloads is True
+ assert middleware.max_payload_length == 500
+
+ def test_format_message_without_payloads(self, mock_context):
+ """Test message formatting without payloads."""
+ middleware = LoggingMiddleware()
+ formatted = middleware._format_message(mock_context)
+
+ assert "source=client" in formatted
+ assert "type=request" in formatted
+ assert "method=test_method" in formatted
+ assert "payload=" not in formatted
+
+ def test_format_message_with_payloads(self, mock_context):
+ """Test message formatting with payloads."""
+ middleware = LoggingMiddleware(include_payloads=True)
+ formatted = middleware._format_message(mock_context)
+
+ assert "source=client" in formatted
+ assert "type=request" in formatted
+ assert "method=test_method" in formatted
+ assert 'payload={"param": "value"}' in formatted
+
+ def test_format_message_long_payload(self, mock_context):
+ """Test message formatting with long payload truncation."""
+ middleware = LoggingMiddleware(include_payloads=True, max_payload_length=10)
+ formatted = middleware._format_message(mock_context)
+
+ assert "payload=" in formatted
+ assert "..." in formatted
+
+ async def test_on_message_success(self, mock_context, mock_call_next, caplog):
+ """Test logging successful messages."""
+ middleware = LoggingMiddleware()
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.called
+ assert "Processing message:" in caplog.text
+ assert "Completed message: test_method" in caplog.text
+
+ async def test_on_message_failure(self, mock_context, caplog):
+ """Test logging failed messages."""
+ middleware = LoggingMiddleware()
+ mock_call_next = AsyncMock(side_effect=ValueError("test error"))
+
+ with caplog.at_level(logging.INFO):
+ with pytest.raises(ValueError):
+ await middleware.on_message(mock_context, mock_call_next)
+
+ assert "Processing message:" in caplog.text
+ assert "Failed message: test_method - test error" in caplog.text
+
+
+class TestStructuredLoggingMiddleware:
+ """Test structured logging middleware functionality."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = StructuredLoggingMiddleware()
+ assert middleware.logger.name == "fastmcp.structured"
+ assert middleware.log_level == logging.INFO
+ assert middleware.include_payloads is False
+
+ def test_create_log_entry_basic(self, mock_context):
+ """Test creating basic log entry."""
+ middleware = StructuredLoggingMiddleware()
+ entry = middleware._create_log_entry(mock_context, "test_event")
+
+ assert entry["event"] == "test_event"
+ assert entry["timestamp"] == "2023-01-01T00:00:00Z"
+ assert entry["source"] == "client"
+ assert entry["type"] == "request"
+ assert entry["method"] == "test_method"
+ assert "payload" not in entry
+
+ def test_create_log_entry_with_payload(self, mock_context):
+ """Test creating log entry with payload."""
+ middleware = StructuredLoggingMiddleware(include_payloads=True)
+ entry = middleware._create_log_entry(mock_context, "test_event")
+
+ assert entry["payload"] == {"param": "value"}
+
+ def test_create_log_entry_with_extra_fields(self, mock_context):
+ """Test creating log entry with extra fields."""
+ middleware = StructuredLoggingMiddleware()
+ entry = middleware._create_log_entry(
+ mock_context, "test_event", extra_field="extra_value"
+ )
+
+ assert entry["extra_field"] == "extra_value"
+
+ async def test_on_message_success(self, mock_context, mock_call_next, caplog):
+ """Test structured logging of successful messages."""
+ middleware = StructuredLoggingMiddleware()
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+
+ # Check that we have structured JSON logs
+ log_lines = [record.message for record in caplog.records]
+ assert len(log_lines) == 2 # start and success entries
+
+ start_entry = json.loads(log_lines[0])
+ assert start_entry["event"] == "request_start"
+ assert start_entry["method"] == "test_method"
+
+ success_entry = json.loads(log_lines[1])
+ assert success_entry["event"] == "request_success"
+ assert success_entry["result_type"] == "str"
+
+ async def test_on_message_failure(self, mock_context, caplog):
+ """Test structured logging of failed messages."""
+ middleware = StructuredLoggingMiddleware()
+ mock_call_next = AsyncMock(side_effect=ValueError("test error"))
+
+ with caplog.at_level(logging.INFO):
+ with pytest.raises(ValueError):
+ await middleware.on_message(mock_context, mock_call_next)
+
+ # Check that we have structured JSON logs
+ log_lines = [record.message for record in caplog.records]
+ assert len(log_lines) == 2 # start and error entries
+
+ start_entry = json.loads(log_lines[0])
+ assert start_entry["event"] == "request_start"
+
+ error_entry = json.loads(log_lines[1])
+ assert error_entry["event"] == "request_error"
+ assert error_entry["error_type"] == "ValueError"
+ assert error_entry["error_message"] == "test error"
+
+
+@pytest.fixture
+def logging_server():
+ """Create a FastMCP server specifically for logging middleware tests."""
+ from fastmcp import FastMCP
+
+ mcp = FastMCP("LoggingTestServer")
+
+ @mcp.tool
+ def simple_operation(data: str) -> str:
+ """A simple operation for testing logging."""
+ return f"Processed: {data}"
+
+ @mcp.tool
+ def complex_operation(items: list[str], mode: str = "default") -> dict:
+ """A complex operation with structured data."""
+ return {"processed_items": len(items), "mode": mode, "result": "success"}
+
+ @mcp.tool
+ def operation_with_error(should_fail: bool = False) -> str:
+ """An operation that can be made to fail."""
+ if should_fail:
+ raise ValueError("Operation failed intentionally")
+ return "Operation completed successfully"
+
+ @mcp.resource("log://test")
+ def test_resource() -> str:
+ """A test resource for logging."""
+ return "Test resource content"
+
+ @mcp.prompt
+ def test_prompt() -> str:
+ """A test prompt for logging."""
+ return "Test prompt content"
+
+ return mcp
+
+
+class TestLoggingMiddlewareIntegration:
+ """Integration tests for logging middleware with real FastMCP server."""
+
+ async def test_logging_middleware_logs_successful_operations(
+ self, logging_server, caplog
+ ):
+ """Test that logging middleware captures successful operations."""
+ from fastmcp.client import Client
+
+ logging_server.add_middleware(LoggingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(logging_server) as client:
+ await client.call_tool("simple_operation", {"data": "test_data"})
+ await client.call_tool(
+ "complex_operation", {"items": ["a", "b", "c"], "mode": "batch"}
+ )
+
+ log_text = caplog.text
+
+ # Should have processing and completion logs for both operations
+ assert "Processing message:" in log_text
+ assert "Completed message: tools/call" in log_text
+
+ # Should have captured both tool calls
+ processing_count = log_text.count("Processing message:")
+ completion_count = log_text.count("Completed message:")
+ assert processing_count == 2
+ assert completion_count == 2
+
+ async def test_logging_middleware_logs_failures(self, logging_server, caplog):
+ """Test that logging middleware captures failed operations."""
+ from fastmcp.client import Client
+
+ logging_server.add_middleware(LoggingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(logging_server) as client:
+ # This should fail and be logged
+ with pytest.raises(Exception):
+ await client.call_tool(
+ "operation_with_error", {"should_fail": True}
+ )
+
+ log_text = caplog.text
+
+ # Should have processing and failure logs
+ assert "Processing message:" in log_text
+ assert "Failed message: tools/call" in log_text
+
+ async def test_logging_middleware_with_payloads(self, logging_server, caplog):
+ """Test logging middleware when configured to include payloads."""
+ from fastmcp.client import Client
+
+ logging_server.add_middleware(
+ LoggingMiddleware(include_payloads=True, max_payload_length=500)
+ )
+
+ with caplog.at_level(logging.INFO):
+ async with Client(logging_server) as client:
+ await client.call_tool("simple_operation", {"data": "payload_test"})
+
+ log_text = caplog.text
+
+ # Should include payload information
+ assert "Processing message:" in log_text
+ assert "payload=" in log_text
+
+ async def test_structured_logging_middleware_produces_json(
+ self, logging_server, caplog
+ ):
+ """Test that structured logging middleware produces parseable JSON logs."""
+ import json
+
+ from fastmcp.client import Client
+
+ logging_server.add_middleware(
+ StructuredLoggingMiddleware(include_payloads=True)
+ )
+
+ with caplog.at_level(logging.INFO):
+ async with Client(logging_server) as client:
+ await client.call_tool("simple_operation", {"data": "json_test"})
+
+ # Extract JSON log entries
+ log_lines = [
+ record.message
+ for record in caplog.records
+ if record.name == "fastmcp.structured"
+ ]
+
+ assert len(log_lines) >= 2 # Should have start and success entries
+
+ # Each log line should be valid JSON
+ for line in log_lines:
+ log_entry = json.loads(line)
+ assert "event" in log_entry
+ assert "timestamp" in log_entry
+ assert "source" in log_entry
+ assert "type" in log_entry
+ assert "method" in log_entry
+
+ async def test_structured_logging_middleware_handles_errors(
+ self, logging_server, caplog
+ ):
+ """Test structured logging of errors with JSON format."""
+ import json
+
+ from fastmcp.client import Client
+
+ logging_server.add_middleware(StructuredLoggingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(logging_server) as client:
+ with pytest.raises(Exception):
+ await client.call_tool(
+ "operation_with_error", {"should_fail": True}
+ )
+
+ # Extract JSON log entries
+ log_lines = [
+ record.message
+ for record in caplog.records
+ if record.name == "fastmcp.structured"
+ ]
+
+ # Should have start and error entries
+ assert len(log_lines) >= 2
+
+ # Find the error entry
+ error_entries = []
+ for line in log_lines:
+ log_entry = json.loads(line)
+ if log_entry.get("event") == "request_error":
+ error_entries.append(log_entry)
+
+ assert len(error_entries) == 1
+ error_entry = error_entries[0]
+ assert "error_type" in error_entry
+ assert "error_message" in error_entry
+
+ async def test_logging_middleware_with_different_operations(
+ self, logging_server, caplog
+ ):
+ """Test logging middleware with various MCP operations."""
+ from fastmcp.client import Client
+
+ logging_server.add_middleware(LoggingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(logging_server) as client:
+ # Test different operation types
+ await client.call_tool("simple_operation", {"data": "test"})
+ await client.read_resource("log://test")
+ await client.get_prompt("test_prompt")
+ await client.list_tools()
+
+ log_text = caplog.text
+
+ # Should have logs for all different operation types
+ # Note: Different operations may have different method names
+ processing_count = log_text.count("Processing message:")
+ completion_count = log_text.count("Completed message:")
+
+ # Should have processed all 4 operations
+ assert processing_count == 4
+ assert completion_count == 4
+
+ async def test_logging_middleware_custom_configuration(self, logging_server):
+ """Test logging middleware with custom logger configuration."""
+ import io
+ import logging
+
+ from fastmcp.client import Client
+
+ # Create custom logger
+ log_buffer = io.StringIO()
+ handler = logging.StreamHandler(log_buffer)
+ custom_logger = logging.getLogger("custom_logging_test")
+ custom_logger.addHandler(handler)
+ custom_logger.setLevel(logging.DEBUG)
+
+ logging_server.add_middleware(
+ LoggingMiddleware(
+ logger=custom_logger, log_level=logging.DEBUG, include_payloads=True
+ )
+ )
+
+ async with Client(logging_server) as client:
+ await client.call_tool("simple_operation", {"data": "custom_test"})
+
+ # Check that our custom logger captured the logs
+ log_output = log_buffer.getvalue()
+ assert "Processing message:" in log_output
+ assert "payload=" in log_output
diff --git a/tests/server/middleware/test_rate_limiting.py b/tests/server/middleware/test_rate_limiting.py
new file mode 100644
index 0000000000000000000000000000000000000000..94f7bf5d9a51b9752dcfe8b530ee2e6c1afd4779
--- /dev/null
+++ b/tests/server/middleware/test_rate_limiting.py
@@ -0,0 +1,448 @@
+"""Tests for rate limiting middleware."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.exceptions import ToolError
+from fastmcp.server.middleware.middleware import MiddlewareContext
+from fastmcp.server.middleware.rate_limiting import (
+ RateLimitError,
+ RateLimitingMiddleware,
+ SlidingWindowRateLimiter,
+ SlidingWindowRateLimitingMiddleware,
+ TokenBucketRateLimiter,
+)
+
+
+@pytest.fixture
+def mock_context():
+ """Create a mock middleware context."""
+ context = MagicMock(spec=MiddlewareContext)
+ context.method = "test_method"
+ return context
+
+
+@pytest.fixture
+def mock_call_next():
+ """Create a mock call_next function."""
+ return AsyncMock(return_value="test_result")
+
+
+class TestTokenBucketRateLimiter:
+ """Test token bucket rate limiter."""
+
+ def test_init(self):
+ """Test initialization."""
+ limiter = TokenBucketRateLimiter(capacity=10, refill_rate=5.0)
+ assert limiter.capacity == 10
+ assert limiter.refill_rate == 5.0
+ assert limiter.tokens == 10
+
+ async def test_consume_success(self):
+ """Test successful token consumption."""
+ limiter = TokenBucketRateLimiter(capacity=10, refill_rate=5.0)
+
+ # Should be able to consume tokens initially
+ assert await limiter.consume(5) is True
+ assert await limiter.consume(3) is True
+
+ async def test_consume_failure(self):
+ """Test failed token consumption."""
+ limiter = TokenBucketRateLimiter(capacity=5, refill_rate=1.0)
+
+ # Consume all tokens
+ assert await limiter.consume(5) is True
+
+ # Should fail to consume more
+ assert await limiter.consume(1) is False
+
+ async def test_refill(self):
+ """Test token refill over time."""
+ limiter = TokenBucketRateLimiter(
+ capacity=10, refill_rate=10.0
+ ) # 10 tokens per second
+
+ # Consume all tokens
+ assert await limiter.consume(10) is True
+ assert await limiter.consume(1) is False
+
+ # Wait for refill (0.2 seconds = 2 tokens at 10/sec)
+ await asyncio.sleep(0.2)
+ assert await limiter.consume(2) is True
+
+
+class TestSlidingWindowRateLimiter:
+ """Test sliding window rate limiter."""
+
+ def test_init(self):
+ """Test initialization."""
+ limiter = SlidingWindowRateLimiter(max_requests=10, window_seconds=60)
+ assert limiter.max_requests == 10
+ assert limiter.window_seconds == 60
+ assert len(limiter.requests) == 0
+
+ async def test_is_allowed_success(self):
+ """Test allowing requests within limit."""
+ limiter = SlidingWindowRateLimiter(max_requests=3, window_seconds=60)
+
+ # Should allow requests up to the limit
+ assert await limiter.is_allowed() is True
+ assert await limiter.is_allowed() is True
+ assert await limiter.is_allowed() is True
+
+ async def test_is_allowed_failure(self):
+ """Test rejecting requests over limit."""
+ limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=60)
+
+ # Should allow up to limit
+ assert await limiter.is_allowed() is True
+ assert await limiter.is_allowed() is True
+
+ # Should reject over limit
+ assert await limiter.is_allowed() is False
+
+ async def test_sliding_window(self):
+ """Test sliding window behavior."""
+ limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=1)
+
+ # Use up requests
+ assert await limiter.is_allowed() is True
+ assert await limiter.is_allowed() is True
+ assert await limiter.is_allowed() is False
+
+ # Wait for window to pass
+ await asyncio.sleep(1.1)
+
+ # Should be able to make requests again
+ assert await limiter.is_allowed() is True
+
+
+class TestRateLimitingMiddleware:
+ """Test rate limiting middleware."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = RateLimitingMiddleware()
+ assert middleware.max_requests_per_second == 10.0
+ assert middleware.burst_capacity == 20
+ assert middleware.get_client_id is None
+ assert middleware.global_limit is False
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+
+ def get_client_id(ctx):
+ return "test_client"
+
+ middleware = RateLimitingMiddleware(
+ max_requests_per_second=5.0,
+ burst_capacity=10,
+ get_client_id=get_client_id,
+ global_limit=True,
+ )
+ assert middleware.max_requests_per_second == 5.0
+ assert middleware.burst_capacity == 10
+ assert middleware.get_client_id is get_client_id
+ assert middleware.global_limit is True
+
+ def test_get_client_identifier_default(self, mock_context):
+ """Test default client identifier."""
+ middleware = RateLimitingMiddleware()
+ assert middleware._get_client_identifier(mock_context) == "global"
+
+ def test_get_client_identifier_custom(self, mock_context):
+ """Test custom client identifier."""
+
+ def get_client_id(ctx):
+ return "custom_client"
+
+ middleware = RateLimitingMiddleware(get_client_id=get_client_id)
+ assert middleware._get_client_identifier(mock_context) == "custom_client"
+
+ async def test_on_request_success(self, mock_context, mock_call_next):
+ """Test successful request within rate limit."""
+ middleware = RateLimitingMiddleware(max_requests_per_second=100.0) # High limit
+
+ result = await middleware.on_request(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.called
+
+ async def test_on_request_rate_limited(self, mock_context, mock_call_next):
+ """Test request rejection due to rate limiting."""
+ middleware = RateLimitingMiddleware(
+ max_requests_per_second=1.0, burst_capacity=1
+ )
+
+ # First request should succeed
+ await middleware.on_request(mock_context, mock_call_next)
+
+ # Second request should be rate limited
+ with pytest.raises(RateLimitError, match="Rate limit exceeded"):
+ await middleware.on_request(mock_context, mock_call_next)
+
+ async def test_global_rate_limiting(self, mock_context, mock_call_next):
+ """Test global rate limiting."""
+ middleware = RateLimitingMiddleware(
+ max_requests_per_second=1.0, burst_capacity=1, global_limit=True
+ )
+
+ # First request should succeed
+ await middleware.on_request(mock_context, mock_call_next)
+
+ # Second request should be rate limited
+ with pytest.raises(RateLimitError, match="Global rate limit exceeded"):
+ await middleware.on_request(mock_context, mock_call_next)
+
+
+class TestSlidingWindowRateLimitingMiddleware:
+ """Test sliding window rate limiting middleware."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = SlidingWindowRateLimitingMiddleware(max_requests=100)
+ assert middleware.max_requests == 100
+ assert middleware.window_seconds == 60
+ assert middleware.get_client_id is None
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+
+ def get_client_id(ctx):
+ return "test_client"
+
+ middleware = SlidingWindowRateLimitingMiddleware(
+ max_requests=50, window_minutes=5, get_client_id=get_client_id
+ )
+ assert middleware.max_requests == 50
+ assert middleware.window_seconds == 300 # 5 minutes
+ assert middleware.get_client_id is get_client_id
+
+ async def test_on_request_success(self, mock_context, mock_call_next):
+ """Test successful request within rate limit."""
+ middleware = SlidingWindowRateLimitingMiddleware(max_requests=100)
+
+ result = await middleware.on_request(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.called
+
+ async def test_on_request_rate_limited(self, mock_context, mock_call_next):
+ """Test request rejection due to rate limiting."""
+ middleware = SlidingWindowRateLimitingMiddleware(max_requests=1)
+
+ # First request should succeed
+ await middleware.on_request(mock_context, mock_call_next)
+
+ # Second request should be rate limited
+ with pytest.raises(RateLimitError, match="Rate limit exceeded"):
+ await middleware.on_request(mock_context, mock_call_next)
+
+
+class TestRateLimitError:
+ """Test rate limit error."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ error = RateLimitError()
+ assert error.error.code == -32000
+ assert error.error.message == "Rate limit exceeded"
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+ error = RateLimitError("Custom message")
+ assert error.error.code == -32000
+ assert error.error.message == "Custom message"
+
+
+@pytest.fixture
+def rate_limit_server():
+ """Create a FastMCP server specifically for rate limiting tests."""
+ mcp = FastMCP("RateLimitTestServer")
+
+ @mcp.tool
+ def quick_action(message: str) -> str:
+ """A quick action for testing rate limits."""
+ return f"Processed: {message}"
+
+ @mcp.tool
+ def batch_process(items: list[str]) -> str:
+ """Process multiple items."""
+ return f"Processed {len(items)} items"
+
+ @mcp.tool
+ def heavy_computation() -> str:
+ """A heavy computation that might need rate limiting."""
+ # Simulate some work
+ import time
+
+ time.sleep(0.01) # Very short delay
+ return "Heavy computation complete"
+
+ return mcp
+
+
+class TestRateLimitingMiddlewareIntegration:
+ """Integration tests for rate limiting middleware with real FastMCP server."""
+
+ async def test_rate_limiting_allows_normal_usage(self, rate_limit_server):
+ """Test that normal usage patterns are allowed through rate limiting."""
+ # Generous rate limit
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(max_requests_per_second=50.0, burst_capacity=10)
+ )
+
+ async with Client(rate_limit_server) as client:
+ # Normal usage should be fine
+ for i in range(5):
+ result = await client.call_tool(
+ "quick_action", {"message": f"task_{i}"}
+ )
+ assert f"Processed: task_{i}" in str(result)
+
+ async def test_rate_limiting_blocks_rapid_requests(self, rate_limit_server):
+ """Test that rate limiting blocks rapid successive requests."""
+ # Very restrictive rate limit
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(max_requests_per_second=2.0, burst_capacity=3)
+ )
+
+ async with Client(rate_limit_server) as client:
+ # First few should succeed (within burst capacity)
+ await client.call_tool("quick_action", {"message": "1"})
+ await client.call_tool("quick_action", {"message": "2"})
+ await client.call_tool("quick_action", {"message": "3"})
+
+ # Next should be rate limited
+ with pytest.raises(ToolError, match="Rate limit exceeded"):
+ await client.call_tool("quick_action", {"message": "4"})
+
+ async def test_rate_limiting_with_concurrent_requests(self, rate_limit_server):
+ """Test rate limiting behavior with concurrent requests."""
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(max_requests_per_second=5.0, burst_capacity=3)
+ )
+
+ async with Client(rate_limit_server) as client:
+ # Fire off many concurrent requests
+ tasks = []
+ for i in range(8):
+ task = asyncio.create_task(
+ client.call_tool("quick_action", {"message": f"concurrent_{i}"})
+ )
+ tasks.append(task)
+
+ # Gather results, allowing exceptions
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ # Some should succeed, some should be rate limited
+ successes = [r for r in results if not isinstance(r, Exception)]
+ failures = [r for r in results if isinstance(r, ToolError)]
+
+ assert len(successes) > 0, "Some requests should succeed"
+ assert len(failures) > 0, "Some requests should be rate limited"
+ assert len(successes) + len(failures) == 8
+
+ async def test_sliding_window_rate_limiting(self, rate_limit_server):
+ """Test sliding window rate limiting implementation."""
+ rate_limit_server.add_middleware(
+ SlidingWindowRateLimitingMiddleware(
+ max_requests=3,
+ window_minutes=1, # 1 minute window
+ )
+ )
+
+ async with Client(rate_limit_server) as client:
+ # Should allow up to the limit
+ await client.call_tool("quick_action", {"message": "1"})
+ await client.call_tool("quick_action", {"message": "2"})
+ await client.call_tool("quick_action", {"message": "3"})
+
+ # Fourth should be blocked
+ with pytest.raises(ToolError, match="Rate limit exceeded"):
+ await client.call_tool("quick_action", {"message": "4"})
+
+ async def test_rate_limiting_with_different_operations(self, rate_limit_server):
+ """Test that rate limiting applies to all types of operations."""
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(max_requests_per_second=3.0, burst_capacity=2)
+ )
+
+ async with Client(rate_limit_server) as client:
+ # Mix different operations
+ await client.call_tool("quick_action", {"message": "test"})
+ await client.call_tool("heavy_computation")
+
+ # Should be rate limited regardless of operation type
+ with pytest.raises(ToolError, match="Rate limit exceeded"):
+ await client.call_tool("batch_process", {"items": ["a", "b", "c"]})
+
+ async def test_custom_client_identification(self, rate_limit_server):
+ """Test rate limiting with custom client identification."""
+
+ def get_client_id(context):
+ # In a real scenario, this might extract from headers or context
+ return "test_client_123"
+
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(
+ max_requests_per_second=2.0,
+ burst_capacity=1,
+ get_client_id=get_client_id,
+ )
+ )
+
+ async with Client(rate_limit_server) as client:
+ # First request should succeed
+ await client.call_tool("quick_action", {"message": "first"})
+
+ # Second should be rate limited for this specific client
+ with pytest.raises(
+ ToolError, match="Rate limit exceeded for client: test_client_123"
+ ):
+ await client.call_tool("quick_action", {"message": "second"})
+
+ async def test_global_rate_limiting(self, rate_limit_server):
+ """Test global rate limiting across all clients."""
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(
+ max_requests_per_second=2.0, burst_capacity=2, global_limit=True
+ )
+ )
+
+ async with Client(rate_limit_server) as client:
+ # Use up the global capacity
+ await client.call_tool("quick_action", {"message": "1"})
+ await client.call_tool("quick_action", {"message": "2"})
+
+ # Should be globally rate limited
+ with pytest.raises(ToolError, match="Global rate limit exceeded"):
+ await client.call_tool("quick_action", {"message": "3"})
+
+ async def test_rate_limiting_recovery_over_time(self, rate_limit_server):
+ """Test that rate limiting allows requests again after time passes."""
+ rate_limit_server.add_middleware(
+ RateLimitingMiddleware(
+ max_requests_per_second=10.0, # 10 per second = 1 every 100ms
+ burst_capacity=1,
+ )
+ )
+
+ async with Client(rate_limit_server) as client:
+ # Use up capacity
+ await client.call_tool("quick_action", {"message": "first"})
+
+ # Should be rate limited immediately
+ with pytest.raises(ToolError):
+ await client.call_tool("quick_action", {"message": "second"})
+
+ # Wait for token bucket to refill (150ms should be enough for ~1.5 tokens)
+ await asyncio.sleep(0.15)
+
+ # Should be able to make another request
+ result = await client.call_tool("quick_action", {"message": "after_wait"})
+ assert "after_wait" in str(result)
diff --git a/tests/server/middleware/test_timing.py b/tests/server/middleware/test_timing.py
new file mode 100644
index 0000000000000000000000000000000000000000..26d68790417c94d2ece5ab9751abc517d565108d
--- /dev/null
+++ b/tests/server/middleware/test_timing.py
@@ -0,0 +1,312 @@
+"""Tests for timing middleware."""
+
+import asyncio
+import logging
+import time
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.server.middleware.middleware import MiddlewareContext
+from fastmcp.server.middleware.timing import DetailedTimingMiddleware, TimingMiddleware
+
+
+@pytest.fixture
+def mock_context():
+ """Create a mock middleware context."""
+ context = MagicMock(spec=MiddlewareContext)
+ context.method = "test_method"
+ return context
+
+
+@pytest.fixture
+def mock_call_next():
+ """Create a mock call_next function."""
+ return AsyncMock(return_value="test_result")
+
+
+class TestTimingMiddleware:
+ """Test timing middleware functionality."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = TimingMiddleware()
+ assert middleware.logger.name == "fastmcp.timing"
+ assert middleware.log_level == logging.INFO
+
+ def test_init_custom(self):
+ """Test custom initialization."""
+ logger = logging.getLogger("custom")
+ middleware = TimingMiddleware(logger=logger, log_level=logging.DEBUG)
+ assert middleware.logger is logger
+ assert middleware.log_level == logging.DEBUG
+
+ async def test_on_request_success(self, mock_context, mock_call_next, caplog):
+ """Test timing successful requests."""
+ middleware = TimingMiddleware()
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_request(mock_context, mock_call_next)
+
+ assert result == "test_result"
+ assert mock_call_next.called
+ assert "Request test_method completed in" in caplog.text
+ assert "ms" in caplog.text
+
+ async def test_on_request_failure(self, mock_context, caplog):
+ """Test timing failed requests."""
+ middleware = TimingMiddleware()
+ mock_call_next = AsyncMock(side_effect=ValueError("test error"))
+
+ with caplog.at_level(logging.INFO):
+ with pytest.raises(ValueError):
+ await middleware.on_request(mock_context, mock_call_next)
+
+ assert "Request test_method failed after" in caplog.text
+ assert "ms: test error" in caplog.text
+
+
+class TestDetailedTimingMiddleware:
+ """Test detailed timing middleware functionality."""
+
+ def test_init_default(self):
+ """Test default initialization."""
+ middleware = DetailedTimingMiddleware()
+ assert middleware.logger.name == "fastmcp.timing.detailed"
+ assert middleware.log_level == logging.INFO
+
+ async def test_on_call_tool(self, caplog):
+ """Test timing tool calls."""
+ middleware = DetailedTimingMiddleware()
+ context = MagicMock()
+ context.message.name = "test_tool"
+ mock_call_next = AsyncMock(return_value="tool_result")
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_call_tool(context, mock_call_next)
+
+ assert result == "tool_result"
+ assert "Tool 'test_tool' completed in" in caplog.text
+
+ async def test_on_read_resource(self, caplog):
+ """Test timing resource reads."""
+ middleware = DetailedTimingMiddleware()
+ context = MagicMock()
+ context.message.uri = "test://resource"
+ mock_call_next = AsyncMock(return_value="resource_result")
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_read_resource(context, mock_call_next)
+
+ assert result == "resource_result"
+ assert "Resource 'test://resource' completed in" in caplog.text
+
+ async def test_on_get_prompt(self, caplog):
+ """Test timing prompt retrieval."""
+ middleware = DetailedTimingMiddleware()
+ context = MagicMock()
+ context.message.name = "test_prompt"
+ mock_call_next = AsyncMock(return_value="prompt_result")
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_get_prompt(context, mock_call_next)
+
+ assert result == "prompt_result"
+ assert "Prompt 'test_prompt' completed in" in caplog.text
+
+ async def test_on_list_tools(self, caplog):
+ """Test timing tool listing."""
+ middleware = DetailedTimingMiddleware()
+ context = MagicMock()
+ mock_call_next = AsyncMock(return_value="tools_result")
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_list_tools(context, mock_call_next)
+
+ assert result == "tools_result"
+ assert "List tools completed in" in caplog.text
+
+ async def test_operation_failure(self, caplog):
+ """Test timing failed operations."""
+ middleware = DetailedTimingMiddleware()
+ context = MagicMock()
+ context.message.name = "failing_tool"
+ mock_call_next = AsyncMock(side_effect=RuntimeError("operation failed"))
+
+ with caplog.at_level(logging.INFO):
+ with pytest.raises(RuntimeError):
+ await middleware.on_call_tool(context, mock_call_next)
+
+ assert "Tool 'failing_tool' failed after" in caplog.text
+ assert "ms: operation failed" in caplog.text
+
+
+@pytest.fixture
+def timing_server():
+ """Create a FastMCP server specifically for timing middleware tests."""
+ mcp = FastMCP("TimingTestServer")
+
+ @mcp.tool
+ def instant_task() -> str:
+ """A task that completes instantly."""
+ return "Done instantly"
+
+ @mcp.tool
+ def short_task() -> str:
+ """A task that takes 0.1 seconds."""
+ time.sleep(0.1)
+ return "Done after 0.1s"
+
+ @mcp.tool
+ def medium_task() -> str:
+ """A task that takes 0.15 seconds."""
+ time.sleep(0.15)
+ return "Done after 0.15s"
+
+ @mcp.tool
+ def failing_task() -> str:
+ """A task that always fails."""
+ raise ValueError("Task failed as expected")
+
+ @mcp.resource("timer://test")
+ def test_resource() -> str:
+ """A resource that takes time to read."""
+ time.sleep(0.05)
+ return "Resource content after 0.05s"
+
+ @mcp.prompt
+ def test_prompt() -> str:
+ """A prompt that takes time to generate."""
+ time.sleep(0.08)
+ return "Prompt content after 0.08s"
+
+ return mcp
+
+
+class TestTimingMiddlewareIntegration:
+ """Integration tests for timing middleware with real FastMCP server."""
+
+ async def test_timing_middleware_measures_tool_execution(
+ self, timing_server, caplog
+ ):
+ """Test that timing middleware accurately measures tool execution times."""
+ timing_server.add_middleware(TimingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(timing_server) as client:
+ # Test instant task
+ await client.call_tool("instant_task")
+
+ # Test short task (0.1s)
+ await client.call_tool("short_task")
+
+ # Test medium task (0.15s)
+ await client.call_tool("medium_task")
+
+ log_text = caplog.text
+
+ # Should have timing logs for all three calls
+ timing_logs = [
+ line
+ for line in log_text.split("\n")
+ if "completed in" in line and "ms" in line
+ ]
+ assert len(timing_logs) == 3
+
+ # Verify that longer tasks show longer timing (roughly)
+ assert "tools/call completed in" in log_text
+ assert "ms" in log_text
+
+ async def test_timing_middleware_handles_failures(self, timing_server, caplog):
+ """Test that timing middleware measures time even for failed operations."""
+ timing_server.add_middleware(TimingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(timing_server) as client:
+ # This should fail but still be timed
+ with pytest.raises(Exception):
+ await client.call_tool("failing_task")
+
+ # Should log the failure with timing
+ assert "tools/call failed after" in caplog.text
+ assert "ms:" in caplog.text
+
+ async def test_detailed_timing_middleware_per_operation(
+ self, timing_server, caplog
+ ):
+ """Test that detailed timing middleware provides operation-specific timing."""
+ timing_server.add_middleware(DetailedTimingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(timing_server) as client:
+ # Test tool call
+ await client.call_tool("short_task")
+
+ # Test resource read
+ await client.read_resource("timer://test")
+
+ # Test prompt
+ await client.get_prompt("test_prompt")
+
+ # Test listing operations
+ await client.list_tools()
+ await client.list_resources()
+ await client.list_prompts()
+
+ log_text = caplog.text
+
+ # Should have specific timing logs for each operation type
+ assert "Tool 'short_task' completed in" in log_text
+ assert "Resource 'timer://test' completed in" in log_text
+ assert "Prompt 'test_prompt' completed in" in log_text
+ assert "List tools completed in" in log_text
+ assert "List resources completed in" in log_text
+ assert "List prompts completed in" in log_text
+
+ async def test_timing_middleware_concurrent_operations(self, timing_server, caplog):
+ """Test timing middleware with concurrent operations."""
+ timing_server.add_middleware(TimingMiddleware())
+
+ with caplog.at_level(logging.INFO):
+ async with Client(timing_server) as client:
+ # Run multiple operations concurrently
+ tasks = [
+ client.call_tool("instant_task"),
+ client.call_tool("short_task"),
+ client.call_tool("instant_task"),
+ ]
+
+ await asyncio.gather(*tasks)
+
+ log_text = caplog.text
+
+ # Should have timing logs for all concurrent operations
+ timing_logs = [line for line in log_text.split("\n") if "completed in" in line]
+ assert len(timing_logs) == 3
+
+ async def test_timing_middleware_custom_logger(self, timing_server):
+ """Test timing middleware with custom logger configuration."""
+ import io
+ import logging
+
+ # Create a custom logger that writes to a string buffer
+ log_buffer = io.StringIO()
+ handler = logging.StreamHandler(log_buffer)
+ custom_logger = logging.getLogger("custom_timing")
+ custom_logger.addHandler(handler)
+ custom_logger.setLevel(logging.DEBUG)
+
+ # Use custom logger and log level
+ timing_server.add_middleware(
+ TimingMiddleware(logger=custom_logger, log_level=logging.DEBUG)
+ )
+
+ async with Client(timing_server) as client:
+ await client.call_tool("instant_task")
+
+ # Check that our custom logger was used
+ log_output = log_buffer.getvalue()
+ assert "tools/call completed in" in log_output
+ assert "ms" in log_output
diff --git a/tests/server/openapi/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py
index 078e61a583932711b9e26f7937986a6590bf6873..7f977ea509884173e3311715556ebeba0d2c3045 100644
--- a/tests/server/openapi/test_openapi_path_parameters.py
+++ b/tests/server/openapi/test_openapi_path_parameters.py
@@ -455,3 +455,31 @@ async def test_array_query_parameter_exploded_format(mock_client):
json=None,
timeout=None,
)
+
+
+def test_parameter_location_enum_handling():
+ """Test that ParameterLocation enum values are handled correctly (issue #950)."""
+ from enum import Enum
+
+ # Create a mock ParameterLocation enum like the one from openapi_pydantic
+ class MockParameterLocation(Enum):
+ PATH = "path"
+ QUERY = "query"
+ HEADER = "header"
+ COOKIE = "cookie"
+
+ # Test the enum handling logic directly (reproduces the fix in openapi.py)
+ test_cases = [
+ (MockParameterLocation.PATH, "path"),
+ (MockParameterLocation.QUERY, "query"),
+ (MockParameterLocation.HEADER, "header"),
+ (MockParameterLocation.COOKIE, "cookie"),
+ ("path", "path"), # Also test that strings work
+ ("query", "query"),
+ ]
+
+ for param_in, expected_str in test_cases:
+ # This is the enum handling logic from the fix
+ param_in_str = param_in.value if isinstance(param_in, Enum) else param_in
+ assert param_in_str == expected_str
+ assert isinstance(param_in_str, str)
diff --git a/tests/utilities/openapi/test_openapi_advanced.py b/tests/utilities/openapi/test_openapi_advanced.py
index 979ca9b28974b6900062c89a033eee590b1f56d4..58a03143d03b6014e55aaa516d5324897294f8e3 100644
--- a/tests/utilities/openapi/test_openapi_advanced.py
+++ b/tests/utilities/openapi/test_openapi_advanced.py
@@ -614,3 +614,52 @@ def test_http_trace_method_path(parsed_http_methods_routes):
assert trace_route is not None
assert trace_route.path == "/resource"
+
+
+@pytest.fixture
+def schema_with_external_reference() -> dict[str, Any]:
+ """Fixture that returns a schema with external schema references like in issue #926."""
+ return {
+ "openapi": "3.0.0",
+ "info": {"title": "External Reference API", "version": "1.0.0"},
+ "paths": {
+ "/products": {
+ "post": {
+ "summary": "Create a product",
+ "operationId": "createProduct",
+ "requestBody": {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "obj": {
+ "$ref": "http://cyaninc.com/json-schemas/market-v1/product-constraints"
+ }
+ },
+ }
+ }
+ },
+ },
+ "responses": {"201": {"description": "Product created"}},
+ }
+ }
+ },
+ }
+
+
+# --- Tests for external schema reference handling --- #
+
+
+def test_external_reference_raises_clear_error(schema_with_external_reference):
+ """Test that external schema references raise a clear, helpful error message."""
+ with pytest.raises(ValueError) as exc_info:
+ parse_openapi_to_http_routes(schema_with_external_reference)
+
+ error_message = str(exc_info.value)
+ assert "External or non-local reference not supported" in error_message
+ assert (
+ "http://cyaninc.com/json-schemas/market-v1/product-constraints" in error_message
+ )
+ assert "FastMCP only supports local schema references" in error_message