diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..d75de49e9
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,15 @@
+fail_fast: true
+
+repos:
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: v3.1.0
+ hooks:
+ - id: prettier
+ types_or: [yaml, json5]
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.8.1
+ hooks:
+ - id: ruff-format
+ - id: ruff
+ args: [--fix, --exit-non-zero-on-fix]
diff --git a/README.md b/README.md
index 4ef08cfeb..335542c79 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,9 @@
# MCP Python SDK
+
+
+
+Python implementation of the Model Context Protocol (MCP)
+
[![PyPI][pypi-badge]][pypi-url]
[![MIT licensed][mit-badge]][mit-url]
[![Python Version][python-badge]][python-url]
@@ -6,6 +11,38 @@
[![Specification][spec-badge]][spec-url]
[![GitHub Discussions][discussions-badge]][discussions-url]
+
+
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Installation](#installation)
+- [Quickstart](#quickstart)
+- [What is MCP?](#what-is-mcp)
+- [Core Concepts](#core-concepts)
+ - [Server](#server)
+ - [Resources](#resources)
+ - [Tools](#tools)
+ - [Prompts](#prompts)
+ - [Images](#images)
+ - [Context](#context)
+- [Running Your Server](#running-your-server)
+ - [Development Mode](#development-mode)
+ - [Claude Desktop Integration](#claude-desktop-integration)
+ - [Direct Execution](#direct-execution)
+- [Examples](#examples)
+ - [Echo Server](#echo-server)
+ - [SQLite Explorer](#sqlite-explorer)
+- [Advanced Usage](#advanced-usage)
+ - [Low-Level Server](#low-level-server)
+ - [Writing MCP Clients](#writing-mcp-clients)
+ - [MCP Primitives](#mcp-primitives)
+ - [Server Capabilities](#server-capabilities)
+- [Documentation](#documentation)
+- [Contributing](#contributing)
+- [License](#license)
+
[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg
[pypi-url]: https://pypi.org/project/mcp/
[mit-badge]: https://img.shields.io/pypi/l/mcp.svg
@@ -19,8 +56,6 @@
[discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk
[discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions
-Python implementation of the [Model Context Protocol](https://modelcontextprotocol.io) (MCP), providing both client and server capabilities for integrating with LLM surfaces.
-
## Overview
The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to:
@@ -32,58 +67,277 @@ The Model Context Protocol allows applications to provide context for LLMs in a
## Installation
-We recommend the use of [uv](https://docs.astral.sh/uv/) to manage your Python projects:
+We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects:
```bash
-uv add mcp
+uv add "mcp[cli]"
```
-Alternatively, add mcp to your `requirements.txt`:
-```
+Alternatively:
+```bash
pip install mcp
-# or add to requirements.txt
-pip install -r requirements.txt
```
-## Overview
-MCP servers provide focused functionality like resources, tools, prompts, and other capabilities that can be reused across many client applications. These servers are designed to be easy to build, highly composable, and modular.
+## Quickstart
+
+Let's create a simple MCP server that exposes a calculator tool and some data:
+
+```python
+# server.py
+from mcp.server.fastmcp import FastMCP
+
+# Create an MCP server
+mcp = FastMCP("Demo")
+
+# Add an addition tool
+@mcp.tool()
+def add(a: int, b: int) -> int:
+ """Add two numbers"""
+ return a + b
+
+# Add a dynamic greeting resource
+@mcp.resource("greeting://{name}")
+def get_greeting(name: str) -> str:
+ """Get a personalized greeting"""
+ return f"Hello, {name}!"
+```
+
+You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running:
+```bash
+mcp install server.py
+```
+
+Alternatively, you can test it with the MCP Inspector:
+```bash
+mcp dev server.py
+```
+
+## What is MCP?
+
+The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can:
+
+- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context)
+- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect)
+- Define interaction patterns through **Prompts** (reusable templates for LLM interactions)
+- And more!
+
+## Core Concepts
+
+### Server
+
+The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing:
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+# Create a named server
+mcp = FastMCP("My App")
+
+# Specify dependencies for deployment and development
+mcp = FastMCP("My App", dependencies=["pandas", "numpy"])
+```
+
+### Resources
+
+Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects:
+
+```python
+@mcp.resource("config://app")
+def get_config() -> str:
+ """Static configuration data"""
+ return "App configuration here"
+
+@mcp.resource("users://{user_id}/profile")
+def get_user_profile(user_id: str) -> str:
+ """Dynamic user data"""
+ return f"Profile data for user {user_id}"
+```
-### Key design principles
-- Servers are extremely easy to build with clear, simple interfaces
-- Multiple servers can be composed seamlessly through a shared protocol
-- Each server operates in isolation and cannot access conversation context
-- Features can be added progressively through capability negotiation
+### Tools
-### Server provided primitives
-- [Prompts](https://modelcontextprotocol.io/docs/concepts/prompts): Templatable text
-- [Resources](https://modelcontextprotocol.io/docs/concepts/resources): File-like attachments
-- [Tools](https://modelcontextprotocol.io/docs/concepts/tools): Functions that models can call
-- Utilities:
- - Completion: Auto-completion provider for prompt arguments or resource URI templates
- - Logging: Logging to the client
- - Pagination*: Pagination for long results
+Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects:
-### Client provided primitives
- - [Sampling](https://modelcontextprotocol.io/docs/concepts/sampling): Allow servers to sample using client models
- - Roots: Information about locations to operate on (e.g., directories)
+```python
+@mcp.tool()
+def calculate_bmi(weight_kg: float, height_m: float) -> float:
+ """Calculate BMI given weight in kg and height in meters"""
+ return weight_kg / (height_m ** 2)
+
+@mcp.tool()
+async def fetch_weather(city: str) -> str:
+ """Fetch current weather for a city"""
+ async with httpx.AsyncClient() as client:
+ response = await client.get(f"https://api.weather.com/{city}")
+ return response.text
+```
+
+### Prompts
+
+Prompts are reusable templates that help LLMs interact with your server effectively:
+
+```python
+@mcp.prompt()
+def review_code(code: str) -> str:
+ return f"Please review this code:\n\n{code}"
+
+@mcp.prompt()
+def debug_error(error: str) -> list[Message]:
+ return [
+ UserMessage("I'm seeing this error:"),
+ UserMessage(error),
+ AssistantMessage("I'll help debug that. What have you tried so far?")
+ ]
+```
+
+### Images
+
+FastMCP provides an `Image` class that automatically handles image data:
+
+```python
+from mcp.server.fastmcp import FastMCP, Image
+from PIL import Image as PILImage
+
+@mcp.tool()
+def create_thumbnail(image_path: str) -> Image:
+ """Create a thumbnail from an image"""
+ img = PILImage.open(image_path)
+ img.thumbnail((100, 100))
+ return Image(data=img.tobytes(), format="png")
+```
+
+### Context
+
+The Context object gives your tools and resources access to MCP capabilities:
-Connections between clients and servers are established through transports like **stdio** or **SSE** (Note that most clients support stdio, but not SSE at the moment). The transport layer handles message framing, delivery, and error handling.
+```python
+from mcp.server.fastmcp import FastMCP, Context
+
+@mcp.tool()
+async def long_task(files: list[str], ctx: Context) -> str:
+ """Process multiple files with progress tracking"""
+ for i, file in enumerate(files):
+ ctx.info(f"Processing {file}")
+ await ctx.report_progress(i, len(files))
+ data = await ctx.read_resource(f"file://{file}")
+ return "Processing complete"
+```
-## Quick Start
+## Running Your Server
-### Creating a Server
+### Development Mode
-MCP servers follow a decorator approach to register handlers for MCP primitives like resources, prompts, and tools. The goal is to provide a simple interface for exposing capabilities to LLM clients.
+The fastest way to test and debug your server is with the MCP Inspector:
-**example_server.py**
+```bash
+mcp dev server.py
+
+# Add dependencies
+mcp dev server.py --with pandas --with numpy
+
+# Mount local code
+mcp dev server.py --with-editable .
+```
+
+### Claude Desktop Integration
+
+Once your server is ready, install it in Claude Desktop:
+
+```bash
+mcp install server.py
+
+# Custom name
+mcp install server.py --name "My Analytics Server"
+
+# Environment variables
+mcp install server.py -e API_KEY=abc123 -e DB_URL=postgres://...
+mcp install server.py -f .env
+```
+
+### Direct Execution
+
+For advanced scenarios like custom deployments:
```python
-# /// script
-# dependencies = [
-# "mcp"
-# ]
-# ///
-from mcp.server import Server, NotificationOptions
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("My App")
+
+if __name__ == "__main__":
+ mcp.run()
+```
+
+Run it with:
+```bash
+python server.py
+# or
+mcp run server.py
+```
+
+## Examples
+
+### Echo Server
+
+A simple server demonstrating resources, tools, and prompts:
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("Echo")
+
+@mcp.resource("echo://{message}")
+def echo_resource(message: str) -> str:
+ """Echo a message as a resource"""
+ return f"Resource echo: {message}"
+
+@mcp.tool()
+def echo_tool(message: str) -> str:
+ """Echo a message as a tool"""
+ return f"Tool echo: {message}"
+
+@mcp.prompt()
+def echo_prompt(message: str) -> str:
+ """Create an echo prompt"""
+ return f"Please process this message: {message}"
+```
+
+### SQLite Explorer
+
+A more complex example showing database integration:
+
+```python
+from mcp.server.fastmcp import FastMCP
+import sqlite3
+
+mcp = FastMCP("SQLite Explorer")
+
+@mcp.resource("schema://main")
+def get_schema() -> str:
+ """Provide the database schema as a resource"""
+ conn = sqlite3.connect("database.db")
+ schema = conn.execute(
+ "SELECT sql FROM sqlite_master WHERE type='table'"
+ ).fetchall()
+ return "\n".join(sql[0] for sql in schema if sql[0])
+
+@mcp.tool()
+def query_data(sql: str) -> str:
+ """Execute SQL queries safely"""
+ conn = sqlite3.connect("database.db")
+ try:
+ result = conn.execute(sql).fetchall()
+ return "\n".join(str(row) for row in result)
+ except Exception as e:
+ return f"Error: {str(e)}"
+```
+
+## Advanced Usage
+
+### Low-Level Server
+
+For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server:
+
+```python
+from mcp.server.lowlevel import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import mcp.types as types
@@ -91,7 +345,6 @@ import mcp.types as types
# Create a server instance
server = Server("example-server")
-# Add prompt capabilities
@server.list_prompts()
async def handle_list_prompts() -> list[types.Prompt]:
return [
@@ -130,7 +383,6 @@ async def handle_get_prompt(
)
async def run():
- # Run the server as STDIO
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
@@ -150,9 +402,9 @@ if __name__ == "__main__":
asyncio.run(run())
```
-### Creating a Client
+### Writing MCP Clients
-**example_client.py**
+The SDK provides a high-level client interface for connecting to MCP servers:
```python
from mcp import ClientSession, StdioServerParameters
@@ -171,17 +423,12 @@ async def run():
# Initialize the connection
await session.initialize()
- # The example server only supports prompt primitives:
-
# List available prompts
prompts = await session.list_prompts()
# Get a prompt
prompt = await session.get_prompt("example-prompt", arguments={"arg1": "value"})
- """
- Other example calls include:
-
# List available resources
resources = await session.list_resources()
@@ -193,16 +440,15 @@ async def run():
# Call a tool
result = await session.call_tool("tool-name", arguments={"arg1": "value"})
- """
if __name__ == "__main__":
import asyncio
asyncio.run(run())
```
-## Primitives
+### MCP Primitives
-The MCP Python SDK provides decorators that map to the core protocol primitives. Each primitive follows a different interaction pattern based on how it is controlled and used:
+The MCP protocol defines three core primitives that servers can implement:
| Primitive | Control | Description | Example Use |
|-----------|-----------------------|-----------------------------------------------------|------------------------------|
@@ -210,122 +456,17 @@ The MCP Python SDK provides decorators that map to the core protocol primitives.
| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses |
| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates |
-### User-Controlled Primitives
-
-**Prompts** are designed to be explicitly selected by users for their interactions with LLMs.
-
-| Decorator | Description |
-|--------------------------|----------------------------------------|
-| `@server.list_prompts()` | List available prompt templates |
-| `@server.get_prompt()` | Get a specific prompt with arguments |
-
-### Application-Controlled Primitives
-
-**Resources** are controlled by the client application, which decides how and when they should be used based on its own logic.
-
-| Decorator | Description |
-|--------------------------------|---------------------------------------|
-| `@server.list_resources()` | List available resources |
-| `@server.read_resource()` | Read a specific resource's content |
-| `@server.subscribe_resource()` | Subscribe to resource updates |
-
-### Model-Controlled Primitives
-
-**Tools** are exposed to LLMs to enable automated actions, with user approval.
-
-| Decorator | Description |
-|------------------------|------------------------------------|
-| `@server.list_tools()` | List available tools |
-| `@server.call_tool()` | Execute a tool with arguments |
+### Server Capabilities
-### Server Management
-
-Additional decorators for server functionality:
-
-| Decorator | Description |
-|-------------------------------|--------------------------------|
-| `@server.set_logging_level()` | Update server logging level |
-
-### Capabilities
-
-MCP servers declare capabilities during initialization. These map to specific decorators:
-
-| Capability | Feature Flag | Decorators | Description |
-|-------------|------------------------------|-----------------------------------------------------------------|-------------------------------------|
-| `prompts` | `listChanged` | `@list_prompts`
`@get_prompt` | Prompt template management |
-| `resources` | `subscribe`
`listChanged`| `@list_resources`
`@read_resource`
`@subscribe_resource`| Resource exposure and updates |
-| `tools` | `listChanged` | `@list_tools`
`@call_tool` | Tool discovery and execution |
-| `logging` | - | `@set_logging_level` | Server logging configuration |
-| `completion`| - | `@complete_argument` | Argument completion suggestions |
-
-Capabilities are negotiated during connection initialization. Servers only need to implement the decorators for capabilities they support.
-
-## Client Interaction
-
-The MCP Python SDK enables servers to interact with clients through request context and session management. This allows servers to perform operations like LLM sampling and progress tracking.
-
-### Request Context
-
-The Request Context provides access to the current request and client session. It can be accessed through `server.request_context` and enables:
-
-- Sampling from the client's LLM
-- Sending progress updates
-- Logging messages
-- Accessing request metadata
-
-Example using request context for LLM sampling:
-
-```python
-@server.call_tool()
-async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
- # Access the current request context
- context = server.request_context
-
- # Use the session to sample from the client's LLM
- result = await context.session.create_message(
- messages=[
- types.SamplingMessage(
- role="user",
- content=types.TextContent(
- type="text",
- text="Analyze this data: " + json.dumps(arguments)
- )
- )
- ],
- max_tokens=100
- )
-
- return [types.TextContent(type="text", text=result.content.text)]
-```
-
-Using request context for progress updates:
-
-```python
-@server.call_tool()
-async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
- context = server.request_context
-
- if progress_token := context.meta.progressToken:
- # Send progress notifications
- await context.session.send_progress_notification(
- progress_token=progress_token,
- progress=0.5,
- total=1.0
- )
-
- # Perform operation...
-
- if progress_token:
- await context.session.send_progress_notification(
- progress_token=progress_token,
- progress=1.0,
- total=1.0
- )
-
- return [types.TextContent(type="text", text="Operation complete")]
-```
+MCP servers declare capabilities during initialization:
-The request context is automatically set for each request and provides a safe way to access the current client session and request metadata.
+| Capability | Feature Flag | Description |
+|-------------|------------------------------|------------------------------------|
+| `prompts` | `listChanged` | Prompt template management |
+| `resources` | `subscribe`
`listChanged`| Resource exposure and updates |
+| `tools` | `listChanged` | Tool discovery and execution |
+| `logging` | - | Server logging configuration |
+| `completion`| - | Argument completion suggestions |
## Documentation
@@ -339,4 +480,4 @@ We are passionate about supporting contributors of all levels of experience and
## License
-This project is licensed under the MIT License - see the LICENSE file for details.
+This project is licensed under the MIT License - see the LICENSE file for details.
\ No newline at end of file
diff --git a/examples/fastmcp/complex_inputs.py b/examples/fastmcp/complex_inputs.py
new file mode 100644
index 000000000..e859165a9
--- /dev/null
+++ b/examples/fastmcp/complex_inputs.py
@@ -0,0 +1,30 @@
+"""
+FastMCP Complex inputs Example
+
+Demonstrates validation via pydantic with complex models.
+"""
+
+from typing import Annotated
+
+from pydantic import BaseModel, Field
+
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("Shrimp Tank")
+
+
+class ShrimpTank(BaseModel):
+ class Shrimp(BaseModel):
+ name: Annotated[str, Field(max_length=10)]
+
+ shrimp: list[Shrimp]
+
+
+@mcp.tool()
+def name_shrimp(
+ tank: ShrimpTank,
+ # You can use pydantic Field in function signatures for validation.
+ extra_names: Annotated[list[str], Field(max_length=10)],
+) -> list[str]:
+ """List all shrimp names in the tank"""
+ return [shrimp.name for shrimp in tank.shrimp] + extra_names
diff --git a/examples/fastmcp/desktop.py b/examples/fastmcp/desktop.py
new file mode 100644
index 000000000..8fd71b263
--- /dev/null
+++ b/examples/fastmcp/desktop.py
@@ -0,0 +1,25 @@
+"""
+FastMCP Desktop Example
+
+A simple example that exposes the desktop directory as a resource.
+"""
+
+from pathlib import Path
+
+from mcp.server.fastmcp import FastMCP
+
+# Create server
+mcp = FastMCP("Demo")
+
+
+@mcp.resource("dir://desktop")
+def desktop() -> list[str]:
+ """List the files in the user's desktop"""
+ desktop = Path.home() / "Desktop"
+ return [str(f) for f in desktop.iterdir()]
+
+
+@mcp.tool()
+def add(a: int, b: int) -> int:
+ """Add two numbers"""
+ return a + b
diff --git a/examples/fastmcp/echo.py b/examples/fastmcp/echo.py
new file mode 100644
index 000000000..7bdbcdce6
--- /dev/null
+++ b/examples/fastmcp/echo.py
@@ -0,0 +1,30 @@
+"""
+FastMCP Echo Server
+"""
+
+from mcp.server.fastmcp import FastMCP
+
+# Create server
+mcp = FastMCP("Echo Server")
+
+
+@mcp.tool()
+def echo_tool(text: str) -> str:
+ """Echo the input text"""
+ return text
+
+
+@mcp.resource("echo://static")
+def echo_resource() -> str:
+ return "Echo!"
+
+
+@mcp.resource("echo://{text}")
+def echo_template(text: str) -> str:
+ """Echo the input text"""
+ return f"Echo: {text}"
+
+
+@mcp.prompt("echo")
+def echo_prompt(text: str) -> str:
+ return text
diff --git a/examples/fastmcp/memory.py b/examples/fastmcp/memory.py
new file mode 100644
index 000000000..dbc890815
--- /dev/null
+++ b/examples/fastmcp/memory.py
@@ -0,0 +1,349 @@
+# /// script
+# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"]
+# ///
+
+# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector
+
+"""
+Recursive memory system inspired by the human brain's clustering of memories.
+Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient
+similarity search.
+"""
+
+import asyncio
+import math
+import os
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Annotated, Self
+
+import asyncpg
+import numpy as np
+from openai import AsyncOpenAI
+from pgvector.asyncpg import register_vector # Import register_vector
+from pydantic import BaseModel, Field
+from pydantic_ai import Agent
+
+from mcp.server.fastmcp import FastMCP
+
+MAX_DEPTH = 5
+SIMILARITY_THRESHOLD = 0.7
+DECAY_FACTOR = 0.99
+REINFORCEMENT_FACTOR = 1.1
+
+DEFAULT_LLM_MODEL = "openai:gpt-4o"
+DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
+
+mcp = FastMCP(
+ "memory",
+ dependencies=[
+ "pydantic-ai-slim[openai]",
+ "asyncpg",
+ "numpy",
+ "pgvector",
+ ],
+)
+
+DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db"
+# reset memory with rm ~/.fastmcp/{USER}/memory/*
+PROFILE_DIR = (
+ Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory"
+).resolve()
+PROFILE_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def cosine_similarity(a: list[float], b: list[float]) -> float:
+ a_array = np.array(a, dtype=np.float64)
+ b_array = np.array(b, dtype=np.float64)
+ return np.dot(a_array, b_array) / (
+ np.linalg.norm(a_array) * np.linalg.norm(b_array)
+ )
+
+
+async def do_ai[T](
+ user_prompt: str,
+ system_prompt: str,
+ result_type: type[T] | Annotated,
+ deps=None,
+) -> T:
+ agent = Agent(
+ DEFAULT_LLM_MODEL,
+ system_prompt=system_prompt,
+ result_type=result_type,
+ )
+ result = await agent.run(user_prompt, deps=deps)
+ return result.data
+
+
+@dataclass
+class Deps:
+ openai: AsyncOpenAI
+ pool: asyncpg.Pool
+
+
+async def get_db_pool() -> asyncpg.Pool:
+ async def init(conn):
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
+ await register_vector(conn)
+
+ pool = await asyncpg.create_pool(DB_DSN, init=init)
+ return pool
+
+
+class MemoryNode(BaseModel):
+ id: int | None = None
+ content: str
+ summary: str = ""
+ importance: float = 1.0
+ access_count: int = 0
+ timestamp: float = Field(
+ default_factory=lambda: datetime.now(timezone.utc).timestamp()
+ )
+ embedding: list[float]
+
+ @classmethod
+ async def from_content(cls, content: str, deps: Deps):
+ embedding = await get_embedding(content, deps)
+ return cls(content=content, embedding=embedding)
+
+ async def save(self, deps: Deps):
+ async with deps.pool.acquire() as conn:
+ if self.id is None:
+ result = await conn.fetchrow(
+ """
+ INSERT INTO memories (content, summary, importance, access_count,
+ timestamp, embedding)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id
+ """,
+ self.content,
+ self.summary,
+ self.importance,
+ self.access_count,
+ self.timestamp,
+ self.embedding,
+ )
+ self.id = result["id"]
+ else:
+ await conn.execute(
+ """
+ UPDATE memories
+ SET content = $1, summary = $2, importance = $3,
+ access_count = $4, timestamp = $5, embedding = $6
+ WHERE id = $7
+ """,
+ self.content,
+ self.summary,
+ self.importance,
+ self.access_count,
+ self.timestamp,
+ self.embedding,
+ self.id,
+ )
+
+ async def merge_with(self, other: Self, deps: Deps):
+ self.content = await do_ai(
+ f"{self.content}\n\n{other.content}",
+ "Combine the following two texts into a single, coherent text.",
+ str,
+ deps,
+ )
+ self.importance += other.importance
+ self.access_count += other.access_count
+ self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)]
+ self.summary = await do_ai(
+ self.content, "Summarize the following text concisely.", str, deps
+ )
+ await self.save(deps)
+ # Delete the merged node from the database
+ if other.id is not None:
+ await delete_memory(other.id, deps)
+
+ def get_effective_importance(self):
+ return self.importance * (1 + math.log(self.access_count + 1))
+
+
+async def get_embedding(text: str, deps: Deps) -> list[float]:
+ embedding_response = await deps.openai.embeddings.create(
+ input=text,
+ model=DEFAULT_EMBEDDING_MODEL,
+ )
+ return embedding_response.data[0].embedding
+
+
+async def delete_memory(memory_id: int, deps: Deps):
+ async with deps.pool.acquire() as conn:
+ await conn.execute("DELETE FROM memories WHERE id = $1", memory_id)
+
+
+async def add_memory(content: str, deps: Deps):
+ new_memory = await MemoryNode.from_content(content, deps)
+ await new_memory.save(deps)
+
+ similar_memories = await find_similar_memories(new_memory.embedding, deps)
+ for memory in similar_memories:
+ if memory.id != new_memory.id:
+ await new_memory.merge_with(memory, deps)
+
+ await update_importance(new_memory.embedding, deps)
+
+ await prune_memories(deps)
+
+ return f"Remembered: {content}"
+
+
+async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]:
+ async with deps.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT id, content, summary, importance, access_count, timestamp, embedding
+ FROM memories
+ ORDER BY embedding <-> $1
+ LIMIT 5
+ """,
+ embedding,
+ )
+ memories = [
+ MemoryNode(
+ id=row["id"],
+ content=row["content"],
+ summary=row["summary"],
+ importance=row["importance"],
+ access_count=row["access_count"],
+ timestamp=row["timestamp"],
+ embedding=row["embedding"],
+ )
+ for row in rows
+ ]
+ return memories
+
+
+async def update_importance(user_embedding: list[float], deps: Deps):
+ async with deps.pool.acquire() as conn:
+ rows = await conn.fetch(
+ "SELECT id, importance, access_count, embedding FROM memories"
+ )
+ for row in rows:
+ memory_embedding = row["embedding"]
+ similarity = cosine_similarity(user_embedding, memory_embedding)
+ if similarity > SIMILARITY_THRESHOLD:
+ new_importance = row["importance"] * REINFORCEMENT_FACTOR
+ new_access_count = row["access_count"] + 1
+ else:
+ new_importance = row["importance"] * DECAY_FACTOR
+ new_access_count = row["access_count"]
+ await conn.execute(
+ """
+ UPDATE memories
+ SET importance = $1, access_count = $2
+ WHERE id = $3
+ """,
+ new_importance,
+ new_access_count,
+ row["id"],
+ )
+
+
+async def prune_memories(deps: Deps):
+ async with deps.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT id, importance, access_count
+ FROM memories
+ ORDER BY importance DESC
+ OFFSET $1
+ """,
+ MAX_DEPTH,
+ )
+ for row in rows:
+ await conn.execute("DELETE FROM memories WHERE id = $1", row["id"])
+
+
+async def display_memory_tree(deps: Deps) -> str:
+ async with deps.pool.acquire() as conn:
+ rows = await conn.fetch(
+ """
+ SELECT content, summary, importance, access_count
+ FROM memories
+ ORDER BY importance DESC
+ LIMIT $1
+ """,
+ MAX_DEPTH,
+ )
+ result = ""
+ for row in rows:
+ effective_importance = row["importance"] * (
+ 1 + math.log(row["access_count"] + 1)
+ )
+ summary = row["summary"] or row["content"]
+ result += f"- {summary} (Importance: {effective_importance:.2f})\n"
+ return result
+
+
+@mcp.tool()
+async def remember(
+ contents: list[str] = Field(
+ description="List of observations or memories to store"
+ ),
+):
+ deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
+ try:
+ return "\n".join(
+ await asyncio.gather(*[add_memory(content, deps) for content in contents])
+ )
+ finally:
+ await deps.pool.close()
+
+
+@mcp.tool()
+async def read_profile() -> str:
+ deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
+ profile = await display_memory_tree(deps)
+ await deps.pool.close()
+ return profile
+
+
+async def initialize_database():
+ pool = await asyncpg.create_pool(
+ "postgresql://postgres:postgres@localhost:54320/postgres"
+ )
+ try:
+ async with pool.acquire() as conn:
+ await conn.execute("""
+ SELECT pg_terminate_backend(pg_stat_activity.pid)
+ FROM pg_stat_activity
+ WHERE pg_stat_activity.datname = 'memory_db'
+ AND pid <> pg_backend_pid();
+ """)
+ await conn.execute("DROP DATABASE IF EXISTS memory_db;")
+ await conn.execute("CREATE DATABASE memory_db;")
+ finally:
+ await pool.close()
+
+ pool = await asyncpg.create_pool(DB_DSN)
+ try:
+ async with pool.acquire() as conn:
+ await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
+
+ await register_vector(conn)
+
+ await conn.execute("""
+ CREATE TABLE IF NOT EXISTS memories (
+ id SERIAL PRIMARY KEY,
+ content TEXT NOT NULL,
+ summary TEXT,
+ importance REAL NOT NULL,
+ access_count INT NOT NULL,
+ timestamp DOUBLE PRECISION NOT NULL,
+ embedding vector(1536) NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories
+ USING hnsw (embedding vector_l2_ops);
+ """)
+ finally:
+ await pool.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(initialize_database())
diff --git a/examples/fastmcp/readme-quickstart.py b/examples/fastmcp/readme-quickstart.py
new file mode 100644
index 000000000..d1c522a81
--- /dev/null
+++ b/examples/fastmcp/readme-quickstart.py
@@ -0,0 +1,18 @@
+from mcp.server.fastmcp import FastMCP
+
+# Create an MCP server
+mcp = FastMCP("Demo")
+
+
+# Add an addition tool
+@mcp.tool()
+def add(a: int, b: int) -> int:
+ """Add two numbers"""
+ return a + b
+
+
+# Add a dynamic greeting resource
+@mcp.resource("greeting://{name}")
+def get_greeting(name: str) -> str:
+ """Get a personalized greeting"""
+ return f"Hello, {name}!"
diff --git a/examples/fastmcp/screenshot.py b/examples/fastmcp/screenshot.py
new file mode 100644
index 000000000..694b49f2f
--- /dev/null
+++ b/examples/fastmcp/screenshot.py
@@ -0,0 +1,29 @@
+"""
+FastMCP Screenshot Example
+
+Give Claude a tool to capture and view screenshots.
+"""
+
+import io
+
+from mcp.server.fastmcp import FastMCP
+from mcp.server.fastmcp.utilities.types import Image
+
+# Create server
+mcp = FastMCP("Screenshot Demo", dependencies=["pyautogui", "Pillow"])
+
+
+@mcp.tool()
+def take_screenshot() -> Image:
+ """
+ Take a screenshot of the user's screen and return it as an image. Use
+ this tool anytime the user wants you to look at something they're doing.
+ """
+ import pyautogui
+
+ buffer = io.BytesIO()
+
+ # if the file exceeds ~1MB, it will be rejected by Claude
+ screenshot = pyautogui.screenshot()
+ screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True)
+ return Image(data=buffer.getvalue(), format="jpeg")
diff --git a/examples/fastmcp/simple_echo.py b/examples/fastmcp/simple_echo.py
new file mode 100644
index 000000000..c26152646
--- /dev/null
+++ b/examples/fastmcp/simple_echo.py
@@ -0,0 +1,14 @@
+"""
+FastMCP Echo Server
+"""
+
+from mcp.server.fastmcp import FastMCP
+
+# Create server
+mcp = FastMCP("Echo Server")
+
+
+@mcp.tool()
+def echo(text: str) -> str:
+ """Echo the input text"""
+ return text
diff --git a/examples/fastmcp/text_me.py b/examples/fastmcp/text_me.py
new file mode 100644
index 000000000..8053c6cc5
--- /dev/null
+++ b/examples/fastmcp/text_me.py
@@ -0,0 +1,72 @@
+# /// script
+# dependencies = []
+# ///
+
+"""
+FastMCP Text Me Server
+--------------------------------
+This defines a simple FastMCP server that sends a text message to a phone number via https://surgemsg.com/.
+
+To run this example, create a `.env` file with the following values:
+
+SURGE_API_KEY=...
+SURGE_ACCOUNT_ID=...
+SURGE_MY_PHONE_NUMBER=...
+SURGE_MY_FIRST_NAME=...
+SURGE_MY_LAST_NAME=...
+
+Visit https://surgemsg.com/ and click "Get Started" to obtain these values.
+"""
+
+from typing import Annotated
+
+import httpx
+from pydantic import BeforeValidator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from mcp.server.fastmcp import FastMCP
+
+
+class SurgeSettings(BaseSettings):
+ model_config: SettingsConfigDict = SettingsConfigDict(
+ env_prefix="SURGE_", env_file=".env"
+ )
+
+ api_key: str
+ account_id: str
+ my_phone_number: Annotated[
+ str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)
+ ]
+ my_first_name: str
+ my_last_name: str
+
+
+# Create server
+mcp = FastMCP("Text me")
+surge_settings = SurgeSettings() # type: ignore
+
+
+@mcp.tool(name="textme", description="Send a text message to me")
+def text_me(text_content: str) -> str:
+ """Send a text message to a phone number via https://surgemsg.com/"""
+ with httpx.Client() as client:
+ response = client.post(
+ "https://api.surgemsg.com/messages",
+ headers={
+ "Authorization": f"Bearer {surge_settings.api_key}",
+ "Surge-Account": surge_settings.account_id,
+ "Content-Type": "application/json",
+ },
+ json={
+ "body": text_content,
+ "conversation": {
+ "contact": {
+ "first_name": surge_settings.my_first_name,
+ "last_name": surge_settings.my_last_name,
+ "phone_number": surge_settings.my_phone_number,
+ }
+ },
+ },
+ )
+ response.raise_for_status()
+ return f"Message sent: {text_content}"
diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py
index f22bdc5ec..8427b12f8 100644
--- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py
+++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py
@@ -1,7 +1,7 @@
import anyio
import click
import mcp.types as types
-from mcp.server import Server
+from mcp.server.lowlevel import Server
def create_messages(
diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py
index a7395e4f9..9864fc519 100644
--- a/examples/servers/simple-resource/mcp_simple_resource/server.py
+++ b/examples/servers/simple-resource/mcp_simple_resource/server.py
@@ -1,7 +1,8 @@
import anyio
import click
import mcp.types as types
-from mcp.server import AnyUrl, Server
+from mcp.server.lowlevel import Server
+from pydantic import AnyUrl
SAMPLE_RESOURCES = {
"greeting": "Hello! This is a sample text resource.",
diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py
index 4343f772e..79b0b8b52 100644
--- a/examples/servers/simple-tool/mcp_simple_tool/server.py
+++ b/examples/servers/simple-tool/mcp_simple_tool/server.py
@@ -2,7 +2,7 @@
import click
import httpx
import mcp.types as types
-from mcp.server import Server
+from mcp.server.lowlevel import Server
async def fetch_website(
diff --git a/pyproject.toml b/pyproject.toml
index efde945bf..8e487f403 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,6 @@
-[build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
-
[project]
name = "mcp"
-version = "1.1.2.dev0"
+version = "1.2.0.dev0"
description = "Model Context Protocol SDK"
readme = "README.md"
requires-python = ">=3.10"
@@ -29,11 +25,35 @@ dependencies = [
"anyio>=4.5",
"httpx>=0.27",
"httpx-sse>=0.4",
- "pydantic>=2.7.2",
+ "pydantic>=2.10.1,<3.0.0",
"starlette>=0.27",
"sse-starlette>=1.6.1",
+ "pydantic-settings>=2.6.1",
+ "uvicorn>=0.30",
+]
+
+[project.optional-dependencies]
+rich = ["rich>=13.9.4"]
+cli = ["typer>=0.12.4", "python-dotenv>=1.0.0"]
+
+[project.scripts]
+mcp = "mcp.cli:app [cli]"
+
+[tool.uv]
+resolution = "lowest-direct"
+dev-dependencies = [
+ "pyright>=1.1.378",
+ "pytest>=8.3.3",
+ "ruff>=0.8.1",
+ "trio>=0.26.2",
+ "pytest-flakefinder>=1.1.0",
+ "pytest-xdist>=3.6.1",
]
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
[project.urls]
Homepage = "https://modelcontextprotocol.io"
Repository = "https://github.com/modelcontextprotocol/python-sdk"
@@ -57,15 +77,7 @@ target-version = "py310"
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
-
-[tool.uv]
-resolution = "lowest-direct"
-dev-dependencies = [
- "pyright>=1.1.378",
- "pytest>=8.3.3",
- "ruff>=0.6.9",
- "trio>=0.26.2",
-]
+"tests/server/fastmcp/test_func_metadata.py" = ["E501"]
[tool.uv.workspace]
members = ["examples/servers/*"]
diff --git a/src/mcp/cli/__init__.py b/src/mcp/cli/__init__.py
new file mode 100644
index 000000000..3ef56d806
--- /dev/null
+++ b/src/mcp/cli/__init__.py
@@ -0,0 +1,6 @@
+"""FastMCP CLI package."""
+
+from .cli import app
+
+if __name__ == "__main__":
+ app()
diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py
new file mode 100644
index 000000000..9e2ef6c8f
--- /dev/null
+++ b/src/mcp/cli/claude.py
@@ -0,0 +1,137 @@
+"""Claude app integration utilities."""
+
+import json
+import sys
+from pathlib import Path
+
+from mcp.server.fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+def get_claude_config_path() -> Path | None:
+ """Get the Claude config directory based on platform."""
+ if sys.platform == "win32":
+ path = Path(Path.home(), "AppData", "Roaming", "Claude")
+ elif sys.platform == "darwin":
+ path = Path(Path.home(), "Library", "Application Support", "Claude")
+ else:
+ return None
+
+ if path.exists():
+ return path
+ return None
+
+
+def update_claude_config(
+ file_spec: str,
+ server_name: str,
+ *,
+ with_editable: Path | None = None,
+ with_packages: list[str] | None = None,
+ env_vars: dict[str, str] | None = None,
+) -> bool:
+ """Add or update a FastMCP server in Claude's configuration.
+
+ Args:
+ file_spec: Path to the server file, optionally with :object suffix
+ server_name: Name for the server in Claude's config
+ with_editable: Optional directory to install in editable mode
+ with_packages: Optional list of additional packages to install
+ env_vars: Optional dictionary of environment variables. These are merged with
+ any existing variables, with new values taking precedence.
+
+ Raises:
+ RuntimeError: If Claude Desktop's config directory is not found, indicating
+ Claude Desktop may not be installed or properly set up.
+ """
+ config_dir = get_claude_config_path()
+ if not config_dir:
+ raise RuntimeError(
+ "Claude Desktop config directory not found. Please ensure Claude Desktop"
+ " is installed and has been run at least once to initialize its config."
+ )
+
+ config_file = config_dir / "claude_desktop_config.json"
+ if not config_file.exists():
+ try:
+ config_file.write_text("{}")
+ except Exception as e:
+ logger.error(
+ "Failed to create Claude config file",
+ extra={
+ "error": str(e),
+ "config_file": str(config_file),
+ },
+ )
+ return False
+
+ try:
+ config = json.loads(config_file.read_text())
+ if "mcpServers" not in config:
+ config["mcpServers"] = {}
+
+ # Always preserve existing env vars and merge with new ones
+ if (
+ server_name in config["mcpServers"]
+ and "env" in config["mcpServers"][server_name]
+ ):
+ existing_env = config["mcpServers"][server_name]["env"]
+ if env_vars:
+ # New vars take precedence over existing ones
+ env_vars = {**existing_env, **env_vars}
+ else:
+ env_vars = existing_env
+
+ # Build uv run command
+ args = ["run"]
+
+ # Collect all packages in a set to deduplicate
+ packages = {"fastmcp"}
+ if with_packages:
+ packages.update(pkg for pkg in with_packages if pkg)
+
+ # Add all packages with --with
+ for pkg in sorted(packages):
+ args.extend(["--with", pkg])
+
+ if with_editable:
+ args.extend(["--with-editable", str(with_editable)])
+
+ # Convert file path to absolute before adding to command
+ # Split off any :object suffix first
+ if ":" in file_spec:
+ file_path, server_object = file_spec.rsplit(":", 1)
+ file_spec = f"{Path(file_path).resolve()}:{server_object}"
+ else:
+ file_spec = str(Path(file_spec).resolve())
+
+ # Add fastmcp run command
+ args.extend(["fastmcp", "run", file_spec])
+
+ server_config = {
+ "command": "uv",
+ "args": args,
+ }
+
+ # Add environment variables if specified
+ if env_vars:
+ server_config["env"] = env_vars
+
+ config["mcpServers"][server_name] = server_config
+
+ config_file.write_text(json.dumps(config, indent=2))
+ logger.info(
+ f"Added server '{server_name}' to Claude config",
+ extra={"config_file": str(config_file)},
+ )
+ return True
+ except Exception as e:
+ logger.error(
+ "Failed to update Claude config",
+ extra={
+ "error": str(e),
+ "config_file": str(config_file),
+ },
+ )
+ return False
diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py
new file mode 100644
index 000000000..3e164d9ff
--- /dev/null
+++ b/src/mcp/cli/cli.py
@@ -0,0 +1,471 @@
+"""MCP CLI tools."""
+
+import importlib.metadata
+import importlib.util
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+try:
+ import typer
+ from typing_extensions import Annotated
+except ImportError:
+ print("Error: typer is required. Install with 'pip install mcp[cli]'")
+ sys.exit(1)
+
+try:
+ from mcp.cli import claude
+ from mcp.server.fastmcp.utilities.logging import get_logger
+except ImportError:
+ print("Error: mcp.server.fastmcp is not installed or not in PYTHONPATH")
+ sys.exit(1)
+
+try:
+ import dotenv
+except ImportError:
+ dotenv = None
+
+logger = get_logger("cli")
+
+app = typer.Typer(
+ name="mcp",
+ help="MCP development tools",
+ add_completion=False,
+ no_args_is_help=True, # Show help if no args provided
+)
+
+
+def _get_npx_command():
+ """Get the correct npx command for the current platform."""
+ if sys.platform == "win32":
+ # Try both npx.cmd and npx.exe on Windows
+ for cmd in ["npx.cmd", "npx.exe", "npx"]:
+ try:
+ subprocess.run(
+ [cmd, "--version"], check=True, capture_output=True, shell=True
+ )
+ return cmd
+ except subprocess.CalledProcessError:
+ continue
+ return None
+ return "npx" # On Unix-like systems, just use npx
+
+
+def _parse_env_var(env_var: str) -> tuple[str, str]:
+ """Parse environment variable string in format KEY=VALUE."""
+ if "=" not in env_var:
+ logger.error(
+ f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
+ )
+ sys.exit(1)
+ key, value = env_var.split("=", 1)
+ return key.strip(), value.strip()
+
+
+def _build_uv_command(
+ file_spec: str,
+ with_editable: Path | None = None,
+ with_packages: list[str] | None = None,
+) -> list[str]:
+ """Build the uv run command that runs a MCP server through mcp run."""
+ cmd = ["uv"]
+
+ cmd.extend(["run", "--with", "mcp"])
+
+ if with_editable:
+ cmd.extend(["--with-editable", str(with_editable)])
+
+ if with_packages:
+ for pkg in with_packages:
+ if pkg:
+ cmd.extend(["--with", pkg])
+
+ # Add mcp run command
+ cmd.extend(["mcp", "run", file_spec])
+ return cmd
+
+
+def _parse_file_path(file_spec: str) -> tuple[Path, str | None]:
+ """Parse a file path that may include a server object specification.
+
+ Args:
+ file_spec: Path to file, optionally with :object suffix
+
+ Returns:
+ Tuple of (file_path, server_object)
+ """
+ # First check if we have a Windows path (e.g., C:\...)
+ has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":"
+
+ # Split on the last colon, but only if it's not part of the Windows drive letter
+ # and there's actually another colon in the string after the drive letter
+ if ":" in (file_spec[2:] if has_windows_drive else file_spec):
+ file_str, server_object = file_spec.rsplit(":", 1)
+ else:
+ file_str, server_object = file_spec, None
+
+ # Resolve the file path
+ file_path = Path(file_str).expanduser().resolve()
+ if not file_path.exists():
+ logger.error(f"File not found: {file_path}")
+ sys.exit(1)
+ if not file_path.is_file():
+ logger.error(f"Not a file: {file_path}")
+ sys.exit(1)
+
+ return file_path, server_object
+
+
+def _import_server(file: Path, server_object: str | None = None):
+ """Import a MCP server from a file.
+
+ Args:
+ file: Path to the file
+ server_object: Optional object name in format "module:object" or just "object"
+
+ Returns:
+ The server object
+ """
+ # Add parent directory to Python path so imports can be resolved
+ file_dir = str(file.parent)
+ if file_dir not in sys.path:
+ sys.path.insert(0, file_dir)
+
+ # Import the module
+ spec = importlib.util.spec_from_file_location("server_module", file)
+ if not spec or not spec.loader:
+ logger.error("Could not load module", extra={"file": str(file)})
+ sys.exit(1)
+
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ # If no object specified, try common server names
+ if not server_object:
+ # Look for the most common server object names
+ for name in ["mcp", "server", "app"]:
+ if hasattr(module, name):
+ return getattr(module, name)
+
+ logger.error(
+ f"No server object found in {file}. Please either:\n"
+ "1. Use a standard variable name (mcp, server, or app)\n"
+ "2. Specify the object name with file:object syntax",
+ extra={"file": str(file)},
+ )
+ sys.exit(1)
+
+ # Handle module:object syntax
+ if ":" in server_object:
+ module_name, object_name = server_object.split(":", 1)
+ try:
+ server_module = importlib.import_module(module_name)
+ server = getattr(server_module, object_name, None)
+ except ImportError:
+ logger.error(
+ f"Could not import module '{module_name}'",
+ extra={"file": str(file)},
+ )
+ sys.exit(1)
+ else:
+ # Just object name
+ server = getattr(module, server_object, None)
+
+ if server is None:
+ logger.error(
+ f"Server object '{server_object}' not found",
+ extra={"file": str(file)},
+ )
+ sys.exit(1)
+
+ return server
+
+
+@app.command()
+def version() -> None:
+ """Show the MCP version."""
+ try:
+ version = importlib.metadata.version("mcp")
+ print(f"MCP version {version}")
+ except importlib.metadata.PackageNotFoundError:
+ print("MCP version unknown (package not installed)")
+ sys.exit(1)
+
+
+@app.command()
+def dev(
+ file_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",
+ ),
+ ] = [],
+) -> None:
+ """Run a MCP server with the MCP Inspector."""
+ file, server_object = _parse_file_path(file_spec)
+
+ logger.debug(
+ "Starting dev server",
+ extra={
+ "file": str(file),
+ "server_object": server_object,
+ "with_editable": str(with_editable) if with_editable else None,
+ "with_packages": with_packages,
+ },
+ )
+
+ try:
+ # Import server to get dependencies
+ server = _import_server(file, server_object)
+ if hasattr(server, "dependencies"):
+ with_packages = list(set(with_packages + server.dependencies))
+
+ uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
+
+ # Get the correct npx command
+ npx_cmd = _get_npx_command()
+ if not npx_cmd:
+ logger.error(
+ "npx not found. Please ensure Node.js and npm are properly installed "
+ "and added to your system PATH."
+ )
+ sys.exit(1)
+
+ # Run the MCP Inspector command with shell=True on Windows
+ shell = sys.platform == "win32"
+ process = subprocess.run(
+ [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
+ check=True,
+ shell=shell,
+ env=dict(os.environ.items()), # Convert to list of tuples for env update
+ )
+ sys.exit(process.returncode)
+ except subprocess.CalledProcessError as e:
+ logger.error(
+ "Dev server failed",
+ extra={
+ "file": str(file),
+ "error": str(e),
+ "returncode": e.returncode,
+ },
+ )
+ sys.exit(e.returncode)
+ except FileNotFoundError:
+ logger.error(
+ "npx not found. Please ensure Node.js and npm are properly installed "
+ "and added to your system PATH. You may need to restart your terminal "
+ "after installation.",
+ extra={"file": str(file)},
+ )
+ sys.exit(1)
+
+
+@app.command()
+def run(
+ file_spec: str = typer.Argument(
+ ...,
+ help="Python file to run, optionally with :object suffix",
+ ),
+ transport: Annotated[
+ str | None,
+ typer.Option(
+ "--transport",
+ "-t",
+ help="Transport protocol to use (stdio or sse)",
+ ),
+ ] = None,
+) -> None:
+ """Run a MCP server.
+
+ The server can be specified in two ways:
+ 1. Module approach: server.py - runs the module directly, expecting a server.run()
+ call
+ 2. Import approach: server.py:app - imports and runs the specified server object
+
+ Note: This command runs the server directly. You are responsible for ensuring
+ all dependencies are available. For dependency management, use mcp install
+ or mcp dev instead.
+ """
+ file, server_object = _parse_file_path(file_spec)
+
+ logger.debug(
+ "Running server",
+ extra={
+ "file": str(file),
+ "server_object": server_object,
+ "transport": transport,
+ },
+ )
+
+ try:
+ # Import and get server object
+ server = _import_server(file, server_object)
+
+ # Run the server
+ kwargs = {}
+ if transport:
+ kwargs["transport"] = transport
+
+ server.run(**kwargs)
+
+ except Exception as e:
+ logger.error(
+ f"Failed to run server: {e}",
+ extra={
+ "file": str(file),
+ "error": str(e),
+ },
+ )
+ sys.exit(1)
+
+
+@app.command()
+def install(
+ file_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",
+ "-e",
+ 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:
+ """Install a MCP server in the Claude desktop app.
+
+ Environment variables are preserved once added and only updated if new values
+ are explicitly provided.
+ """
+ file, server_object = _parse_file_path(file_spec)
+
+ logger.debug(
+ "Installing server",
+ extra={
+ "file": str(file),
+ "server_name": server_name,
+ "server_object": server_object,
+ "with_editable": str(with_editable) if with_editable else None,
+ "with_packages": with_packages,
+ },
+ )
+
+ if not claude.get_claude_config_path():
+ logger.error("Claude app not found")
+ sys.exit(1)
+
+ # Try to import server to get its name, but fall back to file name if dependencies
+ # missing
+ name = server_name
+ server = None
+ if not name:
+ try:
+ server = _import_server(file, server_object)
+ name = server.name
+ except (ImportError, ModuleNotFoundError) as e:
+ logger.debug(
+ "Could not import server (likely missing dependencies), using file"
+ " name",
+ extra={"error": str(e)},
+ )
+ name = file.stem
+
+ # Get server dependencies if available
+ server_dependencies = getattr(server, "dependencies", []) if server else []
+ if server_dependencies:
+ with_packages = list(set(with_packages + server_dependencies))
+
+ # Process environment variables if provided
+ env_dict: dict[str, str] | None = None
+ if env_file or env_vars:
+ env_dict = {}
+ # Load from .env file if specified
+ if env_file:
+ if dotenv:
+ try:
+ env_dict |= {
+ k: v
+ for k, v in dotenv.dotenv_values(env_file).items()
+ if v is not None
+ }
+ except Exception as e:
+ logger.error(f"Failed to load .env file: {e}")
+ sys.exit(1)
+ else:
+ logger.error("python-dotenv is not installed. Cannot load .env file.")
+ sys.exit(1)
+
+ # Add command line environment variables
+ for env_var in env_vars:
+ key, value = _parse_env_var(env_var)
+ env_dict[key] = value
+
+ if claude.update_claude_config(
+ file_spec,
+ name,
+ with_editable=with_editable,
+ with_packages=with_packages,
+ env_vars=env_dict,
+ ):
+ logger.info(f"Successfully installed {name} in Claude app")
+ else:
+ logger.error(f"Failed to install {name} in Claude app")
+ sys.exit(1)
diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py
index a0dd033d6..8ffbe1ec4 100644
--- a/src/mcp/server/__init__.py
+++ b/src/mcp/server/__init__.py
@@ -1,500 +1,4 @@
-"""
-MCP Server Module
+from .fastmcp import FastMCP
+from .lowlevel import NotificationOptions, Server
-This module provides a framework for creating an MCP (Model Context Protocol) server.
-It allows you to easily define and handle various types of requests and notifications
-in an asynchronous manner.
-
-Usage:
-1. Create a Server instance:
- server = Server("your_server_name")
-
-2. Define request handlers using decorators:
- @server.list_prompts()
- async def handle_list_prompts() -> list[types.Prompt]:
- # Implementation
-
- @server.get_prompt()
- async def handle_get_prompt(
- name: str, arguments: dict[str, str] | None
- ) -> types.GetPromptResult:
- # Implementation
-
- @server.list_tools()
- async def handle_list_tools() -> list[types.Tool]:
- # Implementation
-
- @server.call_tool()
- async def handle_call_tool(
- name: str, arguments: dict | None
- ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
- # Implementation
-
- @server.list_resource_templates()
- async def handle_list_resource_templates() -> list[types.ResourceTemplate]:
- # Implementation
-
-3. Define notification handlers if needed:
- @server.progress_notification()
- async def handle_progress(
- progress_token: str | int, progress: float, total: float | None
- ) -> None:
- # Implementation
-
-4. Run the server:
- async def main():
- async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
- await server.run(
- read_stream,
- write_stream,
- InitializationOptions(
- server_name="your_server_name",
- server_version="your_version",
- capabilities=server.get_capabilities(
- notification_options=NotificationOptions(),
- experimental_capabilities={},
- ),
- ),
- )
-
- asyncio.run(main())
-
-The Server class provides methods to register handlers for various MCP requests and
-notifications. It automatically manages the request context and handles incoming
-messages from the client.
-"""
-
-import contextvars
-import logging
-import warnings
-from collections.abc import Awaitable, Callable
-from typing import Any, Sequence
-
-from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
-from pydantic import AnyUrl
-
-import mcp.types as types
-from mcp.server.models import InitializationOptions
-from mcp.server.session import ServerSession
-from mcp.server.stdio import stdio_server as stdio_server
-from mcp.shared.context import RequestContext
-from mcp.shared.exceptions import McpError
-from mcp.shared.session import RequestResponder
-
-logger = logging.getLogger(__name__)
-
-request_ctx: contextvars.ContextVar[RequestContext[ServerSession]] = (
- contextvars.ContextVar("request_ctx")
-)
-
-
-class NotificationOptions:
- def __init__(
- self,
- prompts_changed: bool = False,
- resources_changed: bool = False,
- tools_changed: bool = False,
- ):
- self.prompts_changed = prompts_changed
- self.resources_changed = resources_changed
- self.tools_changed = tools_changed
-
-
-class Server:
- def __init__(self, name: str):
- self.name = name
- self.request_handlers: dict[
- type, Callable[..., Awaitable[types.ServerResult]]
- ] = {
- types.PingRequest: _ping_handler,
- }
- self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {}
- self.notification_options = NotificationOptions()
- logger.debug(f"Initializing server '{name}'")
-
- def create_initialization_options(
- self,
- notification_options: NotificationOptions | None = None,
- experimental_capabilities: dict[str, dict[str, Any]] | None = None,
- ) -> InitializationOptions:
- """Create initialization options from this server instance."""
-
- def pkg_version(package: str) -> str:
- try:
- from importlib.metadata import version
-
- v = version(package)
- if v is not None:
- return v
- except Exception:
- pass
-
- return "unknown"
-
- return InitializationOptions(
- server_name=self.name,
- server_version=pkg_version("mcp"),
- capabilities=self.get_capabilities(
- notification_options or NotificationOptions(),
- experimental_capabilities or {},
- ),
- )
-
- def get_capabilities(
- self,
- notification_options: NotificationOptions,
- experimental_capabilities: dict[str, dict[str, Any]],
- ) -> types.ServerCapabilities:
- """Convert existing handlers to a ServerCapabilities object."""
- prompts_capability = None
- resources_capability = None
- tools_capability = None
- logging_capability = None
-
- # Set prompt capabilities if handler exists
- if types.ListPromptsRequest in self.request_handlers:
- prompts_capability = types.PromptsCapability(
- listChanged=notification_options.prompts_changed
- )
-
- # Set resource capabilities if handler exists
- if types.ListResourcesRequest in self.request_handlers:
- resources_capability = types.ResourcesCapability(
- subscribe=False, listChanged=notification_options.resources_changed
- )
-
- # Set tool capabilities if handler exists
- if types.ListToolsRequest in self.request_handlers:
- tools_capability = types.ToolsCapability(
- listChanged=notification_options.tools_changed
- )
-
- # Set logging capabilities if handler exists
- if types.SetLevelRequest in self.request_handlers:
- logging_capability = types.LoggingCapability()
-
- return types.ServerCapabilities(
- prompts=prompts_capability,
- resources=resources_capability,
- tools=tools_capability,
- logging=logging_capability,
- experimental=experimental_capabilities,
- )
-
- @property
- def request_context(self) -> RequestContext[ServerSession]:
- """If called outside of a request context, this will raise a LookupError."""
- return request_ctx.get()
-
- def list_prompts(self):
- def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]):
- logger.debug("Registering handler for PromptListRequest")
-
- async def handler(_: Any):
- prompts = await func()
- return types.ServerResult(types.ListPromptsResult(prompts=prompts))
-
- self.request_handlers[types.ListPromptsRequest] = handler
- return func
-
- return decorator
-
- def get_prompt(self):
- def decorator(
- func: Callable[
- [str, dict[str, str] | None], Awaitable[types.GetPromptResult]
- ],
- ):
- logger.debug("Registering handler for GetPromptRequest")
-
- async def handler(req: types.GetPromptRequest):
- prompt_get = await func(req.params.name, req.params.arguments)
- return types.ServerResult(prompt_get)
-
- self.request_handlers[types.GetPromptRequest] = handler
- return func
-
- return decorator
-
- def list_resources(self):
- def decorator(func: Callable[[], Awaitable[list[types.Resource]]]):
- logger.debug("Registering handler for ListResourcesRequest")
-
- async def handler(_: Any):
- resources = await func()
- return types.ServerResult(
- types.ListResourcesResult(resources=resources)
- )
-
- self.request_handlers[types.ListResourcesRequest] = handler
- return func
-
- return decorator
-
- def list_resource_templates(self):
- def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]):
- logger.debug("Registering handler for ListResourceTemplatesRequest")
-
- async def handler(_: Any):
- templates = await func()
- return types.ServerResult(
- types.ListResourceTemplatesResult(resourceTemplates=templates)
- )
-
- self.request_handlers[types.ListResourceTemplatesRequest] = handler
- return func
-
- return decorator
-
- def read_resource(self):
- def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]):
- logger.debug("Registering handler for ReadResourceRequest")
-
- async def handler(req: types.ReadResourceRequest):
- result = await func(req.params.uri)
- match result:
- case str(s):
- content = types.TextResourceContents(
- uri=req.params.uri,
- text=s,
- mimeType="text/plain",
- )
- case bytes(b):
- import base64
-
- content = types.BlobResourceContents(
- uri=req.params.uri,
- blob=base64.urlsafe_b64encode(b).decode(),
- mimeType="application/octet-stream",
- )
-
- return types.ServerResult(
- types.ReadResourceResult(
- contents=[content],
- )
- )
-
- self.request_handlers[types.ReadResourceRequest] = handler
- return func
-
- return decorator
-
- def set_logging_level(self):
- def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]):
- logger.debug("Registering handler for SetLevelRequest")
-
- async def handler(req: types.SetLevelRequest):
- await func(req.params.level)
- return types.ServerResult(types.EmptyResult())
-
- self.request_handlers[types.SetLevelRequest] = handler
- return func
-
- return decorator
-
- def subscribe_resource(self):
- def decorator(func: Callable[[AnyUrl], Awaitable[None]]):
- logger.debug("Registering handler for SubscribeRequest")
-
- async def handler(req: types.SubscribeRequest):
- await func(req.params.uri)
- return types.ServerResult(types.EmptyResult())
-
- self.request_handlers[types.SubscribeRequest] = handler
- return func
-
- return decorator
-
- def unsubscribe_resource(self):
- def decorator(func: Callable[[AnyUrl], Awaitable[None]]):
- logger.debug("Registering handler for UnsubscribeRequest")
-
- async def handler(req: types.UnsubscribeRequest):
- await func(req.params.uri)
- return types.ServerResult(types.EmptyResult())
-
- self.request_handlers[types.UnsubscribeRequest] = handler
- return func
-
- return decorator
-
- def list_tools(self):
- def decorator(func: Callable[[], Awaitable[list[types.Tool]]]):
- logger.debug("Registering handler for ListToolsRequest")
-
- async def handler(_: Any):
- tools = await func()
- return types.ServerResult(types.ListToolsResult(tools=tools))
-
- self.request_handlers[types.ListToolsRequest] = handler
- return func
-
- return decorator
-
- def call_tool(self):
- def decorator(
- func: Callable[
- ...,
- Awaitable[
- Sequence[
- types.TextContent | types.ImageContent | types.EmbeddedResource
- ]
- ],
- ],
- ):
- logger.debug("Registering handler for CallToolRequest")
-
- async def handler(req: types.CallToolRequest):
- try:
- results = await func(req.params.name, (req.params.arguments or {}))
- return types.ServerResult(
- types.CallToolResult(content=list(results), isError=False)
- )
- except Exception as e:
- return types.ServerResult(
- types.CallToolResult(
- content=[types.TextContent(type="text", text=str(e))],
- isError=True,
- )
- )
-
- self.request_handlers[types.CallToolRequest] = handler
- return func
-
- return decorator
-
- def progress_notification(self):
- def decorator(
- func: Callable[[str | int, float, float | None], Awaitable[None]],
- ):
- logger.debug("Registering handler for ProgressNotification")
-
- async def handler(req: types.ProgressNotification):
- await func(
- req.params.progressToken, req.params.progress, req.params.total
- )
-
- self.notification_handlers[types.ProgressNotification] = handler
- return func
-
- return decorator
-
- def completion(self):
- """Provides completions for prompts and resource templates"""
-
- def decorator(
- func: Callable[
- [
- types.PromptReference | types.ResourceReference,
- types.CompletionArgument,
- ],
- Awaitable[types.Completion | None],
- ],
- ):
- logger.debug("Registering handler for CompleteRequest")
-
- async def handler(req: types.CompleteRequest):
- completion = await func(req.params.ref, req.params.argument)
- return types.ServerResult(
- types.CompleteResult(
- completion=completion
- if completion is not None
- else types.Completion(values=[], total=None, hasMore=None),
- )
- )
-
- self.request_handlers[types.CompleteRequest] = handler
- return func
-
- return decorator
-
- async def run(
- self,
- read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception],
- write_stream: MemoryObjectSendStream[types.JSONRPCMessage],
- initialization_options: InitializationOptions,
- # When False, exceptions are returned as messages to the client.
- # When True, exceptions are raised, which will cause the server to shut down
- # but also make tracing exceptions much easier during testing and when using
- # in-process servers.
- raise_exceptions: bool = False,
- ):
- with warnings.catch_warnings(record=True) as w:
- async with ServerSession(
- read_stream, write_stream, initialization_options
- ) as session:
- async for message in session.incoming_messages:
- logger.debug(f"Received message: {message}")
-
- match message:
- case RequestResponder(request=types.ClientRequest(root=req)):
- logger.info(
- f"Processing request of type {type(req).__name__}"
- )
- if type(req) in self.request_handlers:
- handler = self.request_handlers[type(req)]
- logger.debug(
- f"Dispatching request of type {type(req).__name__}"
- )
-
- token = None
- try:
- # Set our global state that can be retrieved via
- # app.get_request_context()
- token = request_ctx.set(
- RequestContext(
- message.request_id,
- message.request_meta,
- session,
- )
- )
- response = await handler(req)
- except McpError as err:
- response = err.error
- except Exception as err:
- if raise_exceptions:
- raise err
- response = types.ErrorData(
- code=0, message=str(err), data=None
- )
- finally:
- # Reset the global state after we are done
- if token is not None:
- request_ctx.reset(token)
-
- await message.respond(response)
- else:
- await message.respond(
- types.ErrorData(
- code=types.METHOD_NOT_FOUND,
- message="Method not found",
- )
- )
-
- logger.debug("Response sent")
- case types.ClientNotification(root=notify):
- if type(notify) in self.notification_handlers:
- assert type(notify) in self.notification_handlers
-
- handler = self.notification_handlers[type(notify)]
- logger.debug(
- f"Dispatching notification of type "
- f"{type(notify).__name__}"
- )
-
- try:
- await handler(notify)
- except Exception as err:
- logger.error(
- f"Uncaught exception in notification handler: "
- f"{err}"
- )
-
- for warning in w:
- logger.info(
- f"Warning: {warning.category.__name__}: {warning.message}"
- )
-
-
-async def _ping_handler(request: types.PingRequest) -> types.ServerResult:
- return types.ServerResult(types.EmptyResult())
+__all__ = ["Server", "FastMCP", "NotificationOptions"]
diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py
new file mode 100644
index 000000000..84b052078
--- /dev/null
+++ b/src/mcp/server/fastmcp/__init__.py
@@ -0,0 +1,9 @@
+"""FastMCP - A more ergonomic interface for MCP servers."""
+
+from importlib.metadata import version
+
+from .server import Context, FastMCP
+from .utilities.types import Image
+
+__version__ = version("mcp")
+__all__ = ["FastMCP", "Context", "Image"]
diff --git a/src/mcp/server/fastmcp/exceptions.py b/src/mcp/server/fastmcp/exceptions.py
new file mode 100644
index 000000000..fb5bda106
--- /dev/null
+++ b/src/mcp/server/fastmcp/exceptions.py
@@ -0,0 +1,21 @@
+"""Custom exceptions for FastMCP."""
+
+
+class FastMCPError(Exception):
+ """Base error for FastMCP."""
+
+
+class ValidationError(FastMCPError):
+ """Error in validating parameters or return values."""
+
+
+class ResourceError(FastMCPError):
+ """Error in resource operations."""
+
+
+class ToolError(FastMCPError):
+ """Error in tool operations."""
+
+
+class InvalidSignature(Exception):
+ """Invalid signature for use with FastMCP."""
diff --git a/src/mcp/server/fastmcp/prompts/__init__.py b/src/mcp/server/fastmcp/prompts/__init__.py
new file mode 100644
index 000000000..763726964
--- /dev/null
+++ b/src/mcp/server/fastmcp/prompts/__init__.py
@@ -0,0 +1,4 @@
+from .base import Prompt
+from .manager import PromptManager
+
+__all__ = ["Prompt", "PromptManager"]
diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py
new file mode 100644
index 000000000..0df3d2fd3
--- /dev/null
+++ b/src/mcp/server/fastmcp/prompts/base.py
@@ -0,0 +1,167 @@
+"""Base classes for FastMCP prompts."""
+
+import inspect
+import json
+from collections.abc import Callable
+from typing import Any, Awaitable, Literal, Sequence
+
+import pydantic_core
+from pydantic import BaseModel, Field, TypeAdapter, validate_call
+
+from mcp.types import EmbeddedResource, ImageContent, TextContent
+
+CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
+
+
+class Message(BaseModel):
+ """Base class for all prompt messages."""
+
+ role: Literal["user", "assistant"]
+ content: CONTENT_TYPES
+
+ def __init__(self, content: str | CONTENT_TYPES, **kwargs):
+ if isinstance(content, str):
+ content = TextContent(type="text", text=content)
+ super().__init__(content=content, **kwargs)
+
+
+class UserMessage(Message):
+ """A message from the user."""
+
+ role: Literal["user", "assistant"] = "user"
+
+ def __init__(self, content: str | CONTENT_TYPES, **kwargs):
+ super().__init__(content=content, **kwargs)
+
+
+class AssistantMessage(Message):
+ """A message from the assistant."""
+
+ role: Literal["user", "assistant"] = "assistant"
+
+ def __init__(self, content: str | CONTENT_TYPES, **kwargs):
+ super().__init__(content=content, **kwargs)
+
+
+message_validator = TypeAdapter(UserMessage | AssistantMessage)
+
+SyncPromptResult = (
+ str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
+)
+PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
+
+
+class PromptArgument(BaseModel):
+ """An argument that can be passed to a prompt."""
+
+ name: str = Field(description="Name of the argument")
+ description: str | None = Field(
+ None, description="Description of what the argument does"
+ )
+ required: bool = Field(
+ default=False, description="Whether the argument is required"
+ )
+
+
+class Prompt(BaseModel):
+ """A prompt template that can be rendered with parameters."""
+
+ name: str = Field(description="Name of the prompt")
+ description: str | None = Field(
+ None, description="Description of what the prompt does"
+ )
+ arguments: list[PromptArgument] | None = Field(
+ None, description="Arguments that can be passed to the prompt"
+ )
+ fn: Callable = Field(exclude=True)
+
+ @classmethod
+ def from_function(
+ cls,
+ fn: Callable[..., PromptResult],
+ name: str | None = None,
+ description: str | None = None,
+ ) -> "Prompt":
+ """Create a Prompt from a function.
+
+ The function can return:
+ - A string (converted to a message)
+ - A Message object
+ - A dict (converted to a message)
+ - A sequence of any of the above
+ """
+ func_name = name or fn.__name__
+
+ if func_name == "":
+ raise ValueError("You must provide a name for lambda functions")
+
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
+ parameters = TypeAdapter(fn).json_schema()
+
+ # Convert parameters to PromptArguments
+ arguments = []
+ if "properties" in parameters:
+ for param_name, param in parameters["properties"].items():
+ required = param_name in parameters.get("required", [])
+ arguments.append(
+ PromptArgument(
+ name=param_name,
+ description=param.get("description"),
+ required=required,
+ )
+ )
+
+ # ensure the arguments are properly cast
+ fn = validate_call(fn)
+
+ return cls(
+ name=func_name,
+ description=description or fn.__doc__ or "",
+ arguments=arguments,
+ fn=fn,
+ )
+
+ async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]:
+ """Render the prompt with arguments."""
+ # Validate required arguments
+ if self.arguments:
+ required = {arg.name for arg in self.arguments if arg.required}
+ provided = set(arguments or {})
+ missing = required - provided
+ if missing:
+ raise ValueError(f"Missing required arguments: {missing}")
+
+ try:
+ # Call function and check if result is a coroutine
+ result = self.fn(**(arguments or {}))
+ if inspect.iscoroutine(result):
+ result = await result
+
+ # Validate messages
+ if not isinstance(result, (list, tuple)):
+ result = [result]
+
+ # Convert result to messages
+ messages = []
+ for msg in result:
+ try:
+ if isinstance(msg, Message):
+ messages.append(msg)
+ elif isinstance(msg, dict):
+ msg = message_validator.validate_python(msg)
+ messages.append(msg)
+ elif isinstance(msg, str):
+ messages.append(
+ UserMessage(content=TextContent(type="text", text=msg))
+ )
+ else:
+ msg = json.dumps(pydantic_core.to_jsonable_python(msg))
+ messages.append(Message(role="user", content=msg))
+ except Exception:
+ raise ValueError(
+ f"Could not convert prompt result to message: {msg}"
+ )
+
+ return messages
+ except Exception as e:
+ raise ValueError(f"Error rendering prompt {self.name}: {e}")
diff --git a/src/mcp/server/fastmcp/prompts/manager.py b/src/mcp/server/fastmcp/prompts/manager.py
new file mode 100644
index 000000000..7ccbdef36
--- /dev/null
+++ b/src/mcp/server/fastmcp/prompts/manager.py
@@ -0,0 +1,50 @@
+"""Prompt management functionality."""
+
+from typing import Any
+
+from mcp.server.fastmcp.prompts.base import Message, Prompt
+from mcp.server.fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class PromptManager:
+ """Manages FastMCP prompts."""
+
+ def __init__(self, warn_on_duplicate_prompts: bool = True):
+ self._prompts: dict[str, Prompt] = {}
+ self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
+
+ def get_prompt(self, name: str) -> Prompt | None:
+ """Get prompt by name."""
+ return self._prompts.get(name)
+
+ def list_prompts(self) -> list[Prompt]:
+ """List all registered prompts."""
+ return list(self._prompts.values())
+
+ def add_prompt(
+ self,
+ prompt: Prompt,
+ ) -> Prompt:
+ """Add a prompt to the manager."""
+
+ # Check for duplicates
+ existing = self._prompts.get(prompt.name)
+ if existing:
+ if self.warn_on_duplicate_prompts:
+ logger.warning(f"Prompt already exists: {prompt.name}")
+ return existing
+
+ self._prompts[prompt.name] = prompt
+ return prompt
+
+ async def render_prompt(
+ self, name: str, arguments: dict[str, Any] | None = None
+ ) -> list[Message]:
+ """Render a prompt by name with arguments."""
+ prompt = self.get_prompt(name)
+ if not prompt:
+ raise ValueError(f"Unknown prompt: {name}")
+
+ return await prompt.render(arguments)
diff --git a/src/mcp/server/fastmcp/prompts/prompt_manager.py b/src/mcp/server/fastmcp/prompts/prompt_manager.py
new file mode 100644
index 000000000..389e89624
--- /dev/null
+++ b/src/mcp/server/fastmcp/prompts/prompt_manager.py
@@ -0,0 +1,33 @@
+"""Prompt management functionality."""
+
+from mcp.server.fastmcp.prompts.base import Prompt
+from mcp.server.fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class PromptManager:
+ """Manages FastMCP prompts."""
+
+ def __init__(self, warn_on_duplicate_prompts: bool = True):
+ self._prompts: dict[str, Prompt] = {}
+ self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
+
+ def add_prompt(self, prompt: Prompt) -> Prompt:
+ """Add a prompt to the manager."""
+ logger.debug(f"Adding prompt: {prompt.name}")
+ existing = self._prompts.get(prompt.name)
+ if existing:
+ if self.warn_on_duplicate_prompts:
+ logger.warning(f"Prompt already exists: {prompt.name}")
+ return existing
+ self._prompts[prompt.name] = prompt
+ return prompt
+
+ def get_prompt(self, name: str) -> Prompt | None:
+ """Get prompt by name."""
+ return self._prompts.get(name)
+
+ def list_prompts(self) -> list[Prompt]:
+ """List all registered prompts."""
+ return list(self._prompts.values())
diff --git a/src/mcp/server/fastmcp/resources/__init__.py b/src/mcp/server/fastmcp/resources/__init__.py
new file mode 100644
index 000000000..b5805fb34
--- /dev/null
+++ b/src/mcp/server/fastmcp/resources/__init__.py
@@ -0,0 +1,23 @@
+from .base import Resource
+from .resource_manager import ResourceManager
+from .templates import ResourceTemplate
+from .types import (
+ BinaryResource,
+ DirectoryResource,
+ FileResource,
+ FunctionResource,
+ HttpResource,
+ TextResource,
+)
+
+__all__ = [
+ "Resource",
+ "TextResource",
+ "BinaryResource",
+ "FunctionResource",
+ "FileResource",
+ "HttpResource",
+ "DirectoryResource",
+ "ResourceTemplate",
+ "ResourceManager",
+]
diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py
new file mode 100644
index 000000000..b2050e7f8
--- /dev/null
+++ b/src/mcp/server/fastmcp/resources/base.py
@@ -0,0 +1,48 @@
+"""Base classes and interfaces for FastMCP resources."""
+
+import abc
+from typing import Annotated
+
+from pydantic import (
+ AnyUrl,
+ BaseModel,
+ ConfigDict,
+ Field,
+ UrlConstraints,
+ ValidationInfo,
+ field_validator,
+)
+
+
+class Resource(BaseModel, abc.ABC):
+ """Base class for all resources."""
+
+ model_config = ConfigDict(validate_default=True)
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
+ default=..., description="URI of the resource"
+ )
+ name: str | None = Field(description="Name of the resource", default=None)
+ description: str | None = Field(
+ description="Description of the resource", default=None
+ )
+ mime_type: str = Field(
+ default="text/plain",
+ description="MIME type of the resource content",
+ pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
+ )
+
+ @field_validator("name", mode="before")
+ @classmethod
+ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
+ """Set default name from URI if not provided."""
+ if name:
+ return name
+ if uri := info.data.get("uri"):
+ return str(uri)
+ raise ValueError("Either name or uri must be provided")
+
+ @abc.abstractmethod
+ async def read(self) -> str | bytes:
+ """Read the resource content."""
+ pass
diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py
new file mode 100644
index 000000000..ded34bf0b
--- /dev/null
+++ b/src/mcp/server/fastmcp/resources/resource_manager.py
@@ -0,0 +1,94 @@
+"""Resource manager functionality."""
+
+from typing import Callable
+
+from pydantic import AnyUrl
+
+from mcp.server.fastmcp.resources.base import Resource
+from mcp.server.fastmcp.resources.templates import ResourceTemplate
+from mcp.server.fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class ResourceManager:
+ """Manages FastMCP resources."""
+
+ def __init__(self, warn_on_duplicate_resources: bool = True):
+ self._resources: dict[str, Resource] = {}
+ self._templates: dict[str, ResourceTemplate] = {}
+ self.warn_on_duplicate_resources = warn_on_duplicate_resources
+
+ def add_resource(self, resource: Resource) -> Resource:
+ """Add a resource to the manager.
+
+ Args:
+ resource: A Resource instance to add
+
+ Returns:
+ The added resource. If a resource with the same URI already exists,
+ returns the existing resource.
+ """
+ logger.debug(
+ "Adding resource",
+ extra={
+ "uri": resource.uri,
+ "type": type(resource).__name__,
+ "name": resource.name,
+ },
+ )
+ existing = self._resources.get(str(resource.uri))
+ if existing:
+ if self.warn_on_duplicate_resources:
+ logger.warning(f"Resource already exists: {resource.uri}")
+ return existing
+ self._resources[str(resource.uri)] = resource
+ return resource
+
+ def add_template(
+ self,
+ fn: Callable,
+ uri_template: str,
+ name: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ ) -> ResourceTemplate:
+ """Add a template from a function."""
+ template = ResourceTemplate.from_function(
+ fn,
+ uri_template=uri_template,
+ name=name,
+ description=description,
+ mime_type=mime_type,
+ )
+ self._templates[template.uri_template] = template
+ return template
+
+ async def get_resource(self, uri: AnyUrl | str) -> Resource | None:
+ """Get resource by URI, checking concrete resources first, then templates."""
+ uri_str = str(uri)
+ logger.debug("Getting resource", extra={"uri": uri_str})
+
+ # First check concrete resources
+ if resource := self._resources.get(uri_str):
+ return resource
+
+ # Then check templates
+ for template in self._templates.values():
+ if params := template.matches(uri_str):
+ try:
+ return await template.create_resource(uri_str, params)
+ except Exception as e:
+ raise ValueError(f"Error creating resource from template: {e}")
+
+ raise ValueError(f"Unknown resource: {uri}")
+
+ def list_resources(self) -> list[Resource]:
+ """List all registered resources."""
+ logger.debug("Listing resources", extra={"count": len(self._resources)})
+ return list(self._resources.values())
+
+ def list_templates(self) -> list[ResourceTemplate]:
+ """List all registered templates."""
+ logger.debug("Listing templates", extra={"count": len(self._templates)})
+ return list(self._templates.values())
diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py
new file mode 100644
index 000000000..40afaf801
--- /dev/null
+++ b/src/mcp/server/fastmcp/resources/templates.py
@@ -0,0 +1,80 @@
+"""Resource template functionality."""
+
+import inspect
+import re
+from typing import Any, Callable
+
+from pydantic import BaseModel, Field, TypeAdapter, validate_call
+
+from mcp.server.fastmcp.resources.types import FunctionResource, Resource
+
+
+class ResourceTemplate(BaseModel):
+ """A template for dynamically creating resources."""
+
+ uri_template: str = Field(
+ description="URI template with parameters (e.g. weather://{city}/current)"
+ )
+ name: str = Field(description="Name of the resource")
+ description: str | None = Field(description="Description of what the resource does")
+ mime_type: str = Field(
+ default="text/plain", description="MIME type of the resource content"
+ )
+ fn: Callable = Field(exclude=True)
+ parameters: dict = Field(description="JSON schema for function parameters")
+
+ @classmethod
+ def from_function(
+ cls,
+ fn: Callable,
+ uri_template: str,
+ name: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ ) -> "ResourceTemplate":
+ """Create a template from a function."""
+ func_name = name or fn.__name__
+ if func_name == "":
+ raise ValueError("You must provide a name for lambda functions")
+
+ # Get schema from TypeAdapter - will fail if function isn't properly typed
+ parameters = TypeAdapter(fn).json_schema()
+
+ # ensure the arguments are properly cast
+ fn = validate_call(fn)
+
+ return cls(
+ uri_template=uri_template,
+ name=func_name,
+ description=description or fn.__doc__ or "",
+ mime_type=mime_type or "text/plain",
+ fn=fn,
+ parameters=parameters,
+ )
+
+ def matches(self, uri: str) -> dict[str, Any] | None:
+ """Check if URI matches template and extract parameters."""
+ # Convert template to regex pattern
+ pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
+ match = re.match(f"^{pattern}$", uri)
+ if match:
+ return match.groupdict()
+ return None
+
+ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
+ """Create a resource from the template with the given parameters."""
+ try:
+ # Call function and check if result is a coroutine
+ result = self.fn(**params)
+ if inspect.iscoroutine(result):
+ result = await result
+
+ return FunctionResource(
+ uri=uri, # type: ignore
+ name=self.name,
+ description=self.description,
+ mime_type=self.mime_type,
+ fn=lambda: result, # Capture result in closure
+ )
+ except Exception as e:
+ raise ValueError(f"Error creating resource from template: {e}")
diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py
new file mode 100644
index 000000000..79acf274f
--- /dev/null
+++ b/src/mcp/server/fastmcp/resources/types.py
@@ -0,0 +1,182 @@
+"""Concrete resource implementations."""
+
+import json
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any
+
+import anyio
+import anyio.to_thread
+import httpx
+import pydantic.json
+import pydantic_core
+from pydantic import Field, ValidationInfo
+
+from mcp.server.fastmcp.resources.base import Resource
+
+
+class TextResource(Resource):
+ """A resource that reads from a string."""
+
+ text: str = Field(description="Text content of the resource")
+
+ async def read(self) -> str:
+ """Read the text content."""
+ return self.text
+
+
+class BinaryResource(Resource):
+ """A resource that reads from bytes."""
+
+ data: bytes = Field(description="Binary content of the resource")
+
+ async def read(self) -> bytes:
+ """Read the binary content."""
+ return self.data
+
+
+class FunctionResource(Resource):
+ """A resource that defers data loading by wrapping a function.
+
+ The function is only called when the resource is read, allowing for lazy loading
+ of potentially expensive data. This is particularly useful when listing resources,
+ as the function won't be called until the resource is actually accessed.
+
+ The function can return:
+ - str for text content (default)
+ - bytes for binary content
+ - other types will be converted to JSON
+ """
+
+ fn: Callable[[], Any] = Field(exclude=True)
+
+ async def read(self) -> str | bytes:
+ """Read the resource by calling the wrapped function."""
+ try:
+ result = self.fn()
+ if isinstance(result, Resource):
+ return await result.read()
+ if isinstance(result, bytes):
+ return result
+ if isinstance(result, str):
+ return result
+ try:
+ return json.dumps(pydantic_core.to_jsonable_python(result))
+ except (TypeError, pydantic_core.PydanticSerializationError):
+ # If JSON serialization fails, try str()
+ return str(result)
+ except Exception as e:
+ raise ValueError(f"Error reading resource {self.uri}: {e}")
+
+
+class FileResource(Resource):
+ """A resource that reads from a file.
+
+ Set is_binary=True to read file as binary data instead of text.
+ """
+
+ path: Path = Field(description="Path to the file")
+ is_binary: bool = Field(
+ default=False,
+ description="Whether to read the file as binary data",
+ )
+ mime_type: str = Field(
+ default="text/plain",
+ description="MIME type of the resource content",
+ )
+
+ @pydantic.field_validator("path")
+ @classmethod
+ def validate_absolute_path(cls, path: Path) -> Path:
+ """Ensure path is absolute."""
+ if not path.is_absolute():
+ raise ValueError("Path must be absolute")
+ return path
+
+ @pydantic.field_validator("is_binary")
+ @classmethod
+ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
+ """Set is_binary based on mime_type if not explicitly set."""
+ if is_binary:
+ return True
+ mime_type = info.data.get("mime_type", "text/plain")
+ return not mime_type.startswith("text/")
+
+ async def read(self) -> str | bytes:
+ """Read the file content."""
+ try:
+ if self.is_binary:
+ return await anyio.to_thread.run_sync(self.path.read_bytes)
+ return await anyio.to_thread.run_sync(self.path.read_text)
+ except Exception as e:
+ raise ValueError(f"Error reading file {self.path}: {e}")
+
+
+class HttpResource(Resource):
+ """A resource that reads from an HTTP endpoint."""
+
+ url: str = Field(description="URL to fetch content from")
+ mime_type: str = Field(
+ default="application/json", description="MIME type of the resource content"
+ )
+
+ async def read(self) -> str | bytes:
+ """Read the HTTP content."""
+ async with httpx.AsyncClient() as client:
+ response = await client.get(self.url)
+ response.raise_for_status()
+ return response.text
+
+
+class DirectoryResource(Resource):
+ """A resource that lists files in a directory."""
+
+ path: Path = Field(description="Path to the directory")
+ recursive: bool = Field(
+ default=False, description="Whether to list files recursively"
+ )
+ pattern: str | None = Field(
+ default=None, description="Optional glob pattern to filter files"
+ )
+ mime_type: str = Field(
+ default="application/json", description="MIME type of the resource content"
+ )
+
+ @pydantic.field_validator("path")
+ @classmethod
+ def validate_absolute_path(cls, path: Path) -> Path:
+ """Ensure path is absolute."""
+ if not path.is_absolute():
+ raise ValueError("Path must be absolute")
+ return path
+
+ def list_files(self) -> list[Path]:
+ """List files in the directory."""
+ if not self.path.exists():
+ raise FileNotFoundError(f"Directory not found: {self.path}")
+ if not self.path.is_dir():
+ raise NotADirectoryError(f"Not a directory: {self.path}")
+
+ try:
+ if self.pattern:
+ return (
+ list(self.path.glob(self.pattern))
+ if not self.recursive
+ else list(self.path.rglob(self.pattern))
+ )
+ return (
+ list(self.path.glob("*"))
+ if not self.recursive
+ else list(self.path.rglob("*"))
+ )
+ except Exception as e:
+ raise ValueError(f"Error listing directory {self.path}: {e}")
+
+ async def read(self) -> str: # Always returns JSON string
+ """Read the directory listing."""
+ try:
+ files = await anyio.to_thread.run_sync(self.list_files)
+ file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
+ return json.dumps({"files": file_list}, indent=2)
+ except Exception as e:
+ raise ValueError(f"Error reading directory {self.path}: {e}")
diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py
new file mode 100644
index 000000000..f0909287d
--- /dev/null
+++ b/src/mcp/server/fastmcp/server.py
@@ -0,0 +1,669 @@
+"""FastMCP - A more ergonomic interface for MCP servers."""
+
+import functools
+import inspect
+import json
+import re
+from itertools import chain
+from typing import Any, Callable, Literal, Sequence
+
+import anyio
+import pydantic_core
+import uvicorn
+from pydantic import BaseModel, Field
+from pydantic.networks import AnyUrl
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from mcp.server.fastmcp.exceptions import ResourceError
+from mcp.server.fastmcp.prompts import Prompt, PromptManager
+from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
+from mcp.server.fastmcp.tools import ToolManager
+from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
+from mcp.server.fastmcp.utilities.types import Image
+from mcp.server.lowlevel import Server as MCPServer
+from mcp.server.sse import SseServerTransport
+from mcp.server.stdio import stdio_server
+from mcp.shared.context import RequestContext
+from mcp.types import (
+ EmbeddedResource,
+ GetPromptResult,
+ ImageContent,
+ TextContent,
+)
+from mcp.types import (
+ Prompt as MCPPrompt,
+)
+from mcp.types import (
+ PromptArgument as MCPPromptArgument,
+)
+from mcp.types import (
+ Resource as MCPResource,
+)
+from mcp.types import (
+ ResourceTemplate as MCPResourceTemplate,
+)
+from mcp.types import (
+ Tool as MCPTool,
+)
+
+logger = get_logger(__name__)
+
+
+class Settings(BaseSettings):
+ """FastMCP server settings.
+
+ All settings can be configured via environment variables with the prefix FASTMCP_.
+ For example, FASTMCP_DEBUG=true will set debug=True.
+ """
+
+ model_config = SettingsConfigDict(
+ env_prefix="FASTMCP_",
+ env_file=".env",
+ extra="ignore",
+ )
+
+ # Server settings
+ debug: bool = False
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
+
+ # HTTP settings
+ host: str = "0.0.0.0"
+ port: int = 8000
+
+ # resource settings
+ warn_on_duplicate_resources: bool = True
+
+ # tool settings
+ warn_on_duplicate_tools: bool = True
+
+ # prompt settings
+ warn_on_duplicate_prompts: bool = True
+
+ dependencies: list[str] = Field(
+ default_factory=list,
+ description="List of dependencies to install in the server environment",
+ )
+
+
+class FastMCP:
+ def __init__(self, name: str | None = None, **settings: Any):
+ self.settings = Settings(**settings)
+ self._mcp_server = MCPServer(name=name or "FastMCP")
+ self._tool_manager = ToolManager(
+ warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
+ )
+ self._resource_manager = ResourceManager(
+ warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
+ )
+ self._prompt_manager = PromptManager(
+ warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
+ )
+ self.dependencies = self.settings.dependencies
+
+ # Set up MCP protocol handlers
+ self._setup_handlers()
+
+ # Configure logging
+ configure_logging(self.settings.log_level)
+
+ @property
+ def name(self) -> str:
+ return self._mcp_server.name
+
+ def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
+ """Run the FastMCP server. Note this is a synchronous function.
+
+ Args:
+ transport: Transport protocol to use ("stdio" or "sse")
+ """
+ TRANSPORTS = Literal["stdio", "sse"]
+ if transport not in TRANSPORTS.__args__: # type: ignore
+ raise ValueError(f"Unknown transport: {transport}")
+
+ if transport == "stdio":
+ anyio.run(self.run_stdio_async)
+ else: # transport == "sse"
+ anyio.run(self.run_sse_async)
+
+ def _setup_handlers(self) -> None:
+ """Set up core MCP protocol handlers."""
+ self._mcp_server.list_tools()(self.list_tools)
+ self._mcp_server.call_tool()(self.call_tool)
+ self._mcp_server.list_resources()(self.list_resources)
+ self._mcp_server.read_resource()(self.read_resource)
+ self._mcp_server.list_prompts()(self.list_prompts)
+ self._mcp_server.get_prompt()(self.get_prompt)
+ # TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10
+ # self._mcp_server.list_resource_templates()(self.list_resource_templates)
+
+ async def list_tools(self) -> list[MCPTool]:
+ """List all available tools."""
+ tools = self._tool_manager.list_tools()
+ return [
+ MCPTool(
+ name=info.name,
+ description=info.description,
+ inputSchema=info.parameters,
+ )
+ for info in tools
+ ]
+
+ def get_context(self) -> "Context":
+ """
+ Returns a Context object. Note that the context will only be valid
+ during a request; outside a request, most methods will error.
+ """
+ try:
+ request_context = self._mcp_server.request_context
+ except LookupError:
+ request_context = None
+ return Context(request_context=request_context, fastmcp=self)
+
+ async def call_tool(
+ self, name: str, arguments: dict
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
+ """Call a tool by name with arguments."""
+ context = self.get_context()
+ result = await self._tool_manager.call_tool(name, arguments, context=context)
+ converted_result = _convert_to_content(result)
+ return converted_result
+
+ async def list_resources(self) -> list[MCPResource]:
+ """List all available resources."""
+
+ resources = self._resource_manager.list_resources()
+ return [
+ MCPResource(
+ uri=resource.uri,
+ name=resource.name or "",
+ description=resource.description,
+ mimeType=resource.mime_type,
+ )
+ for resource in resources
+ ]
+
+ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
+ templates = self._resource_manager.list_templates()
+ return [
+ MCPResourceTemplate(
+ uriTemplate=template.uri_template,
+ name=template.name,
+ description=template.description,
+ )
+ for template in templates
+ ]
+
+ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
+ """Read a resource by URI."""
+ resource = await self._resource_manager.get_resource(uri)
+ if not resource:
+ raise ResourceError(f"Unknown resource: {uri}")
+
+ try:
+ return await resource.read()
+ except Exception as e:
+ logger.error(f"Error reading resource {uri}: {e}")
+ raise ResourceError(str(e))
+
+ def add_tool(
+ self,
+ fn: Callable,
+ name: str | None = None,
+ description: str | None = None,
+ ) -> None:
+ """Add a tool to the server.
+
+ The tool function can optionally request a Context object by adding a parameter
+ with the Context type annotation. See the @tool decorator for examples.
+
+ Args:
+ fn: The function to register as a tool
+ name: Optional name for the tool (defaults to function name)
+ description: Optional description of what the tool does
+ """
+ self._tool_manager.add_tool(fn, name=name, description=description)
+
+ def tool(self, name: str | None = None, description: str | None = None) -> Callable:
+ """Decorator to register a tool.
+
+ Tools can optionally request a Context object by adding a parameter with the
+ Context type annotation. The context provides access to MCP capabilities like
+ logging, progress reporting, and resource access.
+
+ Args:
+ name: Optional name for the tool (defaults to function name)
+ description: Optional description of what the tool does
+
+ Example:
+ @server.tool()
+ def my_tool(x: int) -> str:
+ return str(x)
+
+ @server.tool()
+ def tool_with_context(x: int, ctx: Context) -> str:
+ ctx.info(f"Processing {x}")
+ return str(x)
+
+ @server.tool()
+ async def async_tool(x: int, context: Context) -> str:
+ await context.report_progress(50, 100)
+ return str(x)
+ """
+ # Check if user passed function directly instead of calling decorator
+ if callable(name):
+ raise TypeError(
+ "The @tool decorator was used incorrectly. "
+ "Did you forget to call it? Use @tool() instead of @tool"
+ )
+
+ def decorator(fn: Callable) -> Callable:
+ self.add_tool(fn, name=name, description=description)
+ return fn
+
+ return decorator
+
+ def add_resource(self, resource: Resource) -> None:
+ """Add a resource to the server.
+
+ Args:
+ resource: A Resource instance to add
+ """
+ self._resource_manager.add_resource(resource)
+
+ def resource(
+ self,
+ uri: str,
+ *,
+ name: str | None = None,
+ description: str | None = None,
+ mime_type: str | None = None,
+ ) -> Callable:
+ """Decorator to register a function as a resource.
+
+ The function will be called when the resource is read to generate its content.
+ The function can return:
+ - str for text content
+ - bytes for binary content
+ - other types will be converted to JSON
+
+ If the URI contains parameters (e.g. "resource://{param}") or the function
+ has parameters, it will be registered as a template resource.
+
+ Args:
+ uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
+ name: Optional name for the resource
+ description: Optional description of the resource
+ mime_type: Optional MIME type for the resource
+
+ Example:
+ @server.resource("resource://my-resource")
+ def get_data() -> str:
+ return "Hello, world!"
+
+ @server.resource("resource://{city}/weather")
+ def get_weather(city: str) -> str:
+ return f"Weather for {city}"
+ """
+ # Check if user passed function directly instead of calling decorator
+ if callable(uri):
+ raise TypeError(
+ "The @resource decorator was used incorrectly. "
+ "Did you forget to call it? Use @resource('uri') instead of @resource"
+ )
+
+ def decorator(fn: Callable) -> Callable:
+ @functools.wraps(fn)
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
+ return fn(*args, **kwargs)
+
+ # Check if this should be a template
+ has_uri_params = "{" in uri and "}" in uri
+ has_func_params = bool(inspect.signature(fn).parameters)
+
+ if has_uri_params or has_func_params:
+ # Validate that URI params match function params
+ uri_params = set(re.findall(r"{(\w+)}", uri))
+ func_params = set(inspect.signature(fn).parameters.keys())
+
+ if uri_params != func_params:
+ raise ValueError(
+ f"Mismatch between URI parameters {uri_params} "
+ f"and function parameters {func_params}"
+ )
+
+ # Register as template
+ self._resource_manager.add_template(
+ wrapper,
+ uri_template=uri,
+ name=name,
+ description=description,
+ mime_type=mime_type or "text/plain",
+ )
+ else:
+ # Register as regular resource
+ resource = FunctionResource(
+ uri=AnyUrl(uri),
+ name=name,
+ description=description,
+ mime_type=mime_type or "text/plain",
+ fn=wrapper,
+ )
+ self.add_resource(resource)
+ return wrapper
+
+ return decorator
+
+ def add_prompt(self, prompt: Prompt) -> None:
+ """Add a prompt to the server.
+
+ Args:
+ prompt: A Prompt instance to add
+ """
+ self._prompt_manager.add_prompt(prompt)
+
+ def prompt(
+ self, name: str | None = None, description: str | None = None
+ ) -> Callable:
+ """Decorator to register a prompt.
+
+ Args:
+ name: Optional name for the prompt (defaults to function name)
+ description: Optional description of what the prompt does
+
+ Example:
+ @server.prompt()
+ def analyze_table(table_name: str) -> list[Message]:
+ schema = read_table_schema(table_name)
+ return [
+ {
+ "role": "user",
+ "content": f"Analyze this schema:\n{schema}"
+ }
+ ]
+
+ @server.prompt()
+ async def analyze_file(path: str) -> list[Message]:
+ content = await read_file(path)
+ return [
+ {
+ "role": "user",
+ "content": {
+ "type": "resource",
+ "resource": {
+ "uri": f"file://{path}",
+ "text": content
+ }
+ }
+ }
+ ]
+ """
+ # Check if user passed function directly instead of calling decorator
+ if callable(name):
+ raise TypeError(
+ "The @prompt decorator was used incorrectly. "
+ "Did you forget to call it? Use @prompt() instead of @prompt"
+ )
+
+ def decorator(func: Callable) -> Callable:
+ prompt = Prompt.from_function(func, name=name, description=description)
+ self.add_prompt(prompt)
+ return func
+
+ return decorator
+
+ async def run_stdio_async(self) -> None:
+ """Run the server using stdio transport."""
+ async with stdio_server() as (read_stream, write_stream):
+ await self._mcp_server.run(
+ read_stream,
+ write_stream,
+ self._mcp_server.create_initialization_options(),
+ )
+
+ async def run_sse_async(self) -> None:
+ """Run the server using SSE transport."""
+ from starlette.applications import Starlette
+ from starlette.routing import Route
+
+ sse = SseServerTransport("/messages")
+
+ async def handle_sse(request):
+ async with sse.connect_sse(
+ request.scope, request.receive, request._send
+ ) as streams:
+ await self._mcp_server.run(
+ streams[0],
+ streams[1],
+ self._mcp_server.create_initialization_options(),
+ )
+
+ async def handle_messages(request):
+ await sse.handle_post_message(request.scope, request.receive, request._send)
+
+ starlette_app = Starlette(
+ debug=self.settings.debug,
+ routes=[
+ Route("/sse", endpoint=handle_sse),
+ Route("/messages", endpoint=handle_messages, methods=["POST"]),
+ ],
+ )
+
+ config = uvicorn.Config(
+ starlette_app,
+ host=self.settings.host,
+ port=self.settings.port,
+ log_level=self.settings.log_level.lower(),
+ )
+ server = uvicorn.Server(config)
+ await server.serve()
+
+ async def list_prompts(self) -> list[MCPPrompt]:
+ """List all available prompts."""
+ prompts = self._prompt_manager.list_prompts()
+ return [
+ MCPPrompt(
+ name=prompt.name,
+ description=prompt.description,
+ arguments=[
+ MCPPromptArgument(
+ name=arg.name,
+ description=arg.description,
+ required=arg.required,
+ )
+ for arg in (prompt.arguments or [])
+ ],
+ )
+ for prompt in prompts
+ ]
+
+ async def get_prompt(
+ self, name: str, arguments: dict[str, Any] | None = None
+ ) -> GetPromptResult:
+ """Get a prompt by name with arguments."""
+ try:
+ messages = await self._prompt_manager.render_prompt(name, arguments)
+
+ return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
+ except Exception as e:
+ logger.error(f"Error getting prompt {name}: {e}")
+ raise ValueError(str(e))
+
+
+def _convert_to_content(
+ result: Any,
+) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
+ """Convert a result to a sequence of content objects."""
+ if result is None:
+ return []
+
+ if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
+ return [result]
+
+ if isinstance(result, Image):
+ return [result.to_image_content()]
+
+ if isinstance(result, (list, tuple)):
+ return list(chain.from_iterable(_convert_to_content(item) for item in result))
+
+ if not isinstance(result, str):
+ try:
+ result = json.dumps(pydantic_core.to_jsonable_python(result))
+ except Exception:
+ result = str(result)
+
+ return [TextContent(type="text", text=result)]
+
+
+class Context(BaseModel):
+ """Context object providing access to MCP capabilities.
+
+ This provides a cleaner interface to MCP's RequestContext functionality.
+ It gets injected into tool and resource functions that request it via type hints.
+
+ To use context in a tool function, add a parameter with the Context type annotation:
+
+ ```python
+ @server.tool()
+ def my_tool(x: int, ctx: Context) -> str:
+ # Log messages to the client
+ ctx.info(f"Processing {x}")
+ ctx.debug("Debug info")
+ ctx.warning("Warning message")
+ ctx.error("Error message")
+
+ # Report progress
+ ctx.report_progress(50, 100)
+
+ # Access resources
+ data = ctx.read_resource("resource://data")
+
+ # Get request info
+ request_id = ctx.request_id
+ client_id = ctx.client_id
+
+ return str(x)
+ ```
+
+ The context parameter name can be anything as long as it's annotated with Context.
+ The context is optional - tools that don't need it can omit the parameter.
+ """
+
+ _request_context: RequestContext | None
+ _fastmcp: FastMCP | None
+
+ def __init__(
+ self,
+ *,
+ request_context: RequestContext | None = None,
+ fastmcp: FastMCP | None = None,
+ **kwargs: Any,
+ ):
+ super().__init__(**kwargs)
+ self._request_context = request_context
+ self._fastmcp = fastmcp
+
+ @property
+ def fastmcp(self) -> FastMCP:
+ """Access to the FastMCP server."""
+ if self._fastmcp is None:
+ raise ValueError("Context is not available outside of a request")
+ return self._fastmcp
+
+ @property
+ def request_context(self) -> RequestContext:
+ """Access to the underlying request context."""
+ if self._request_context is None:
+ raise ValueError("Context is not available outside of a request")
+ return self._request_context
+
+ async def report_progress(
+ self, progress: float, total: float | None = None
+ ) -> None:
+ """Report progress for the current operation.
+
+ Args:
+ progress: Current progress value e.g. 24
+ total: Optional total value e.g. 100
+ """
+
+ progress_token = (
+ self.request_context.meta.progressToken
+ if self.request_context.meta
+ else None
+ )
+
+ if not progress_token:
+ return
+
+ await self.request_context.session.send_progress_notification(
+ progress_token=progress_token, progress=progress, total=total
+ )
+
+ async def read_resource(self, uri: str | AnyUrl) -> str | bytes:
+ """Read a resource by URI.
+
+ Args:
+ uri: Resource URI to read
+
+ Returns:
+ The resource content as either text or bytes
+ """
+ assert (
+ self._fastmcp is not None
+ ), "Context is not available outside of a request"
+ return await self._fastmcp.read_resource(uri)
+
+ def log(
+ self,
+ level: Literal["debug", "info", "warning", "error"],
+ message: str,
+ *,
+ logger_name: str | None = None,
+ ) -> None:
+ """Send a log message to the client.
+
+ Args:
+ level: Log level (debug, info, warning, error)
+ message: Log message
+ logger_name: Optional logger name
+ **extra: Additional structured data to include
+ """
+ self.request_context.session.send_log_message(
+ level=level, data=message, logger=logger_name
+ )
+
+ @property
+ def client_id(self) -> str | None:
+ """Get the client ID if available."""
+ return (
+ getattr(self.request_context.meta, "client_id", None)
+ if self.request_context.meta
+ else None
+ )
+
+ @property
+ def request_id(self) -> str:
+ """Get the unique ID for this request."""
+ return str(self.request_context.request_id)
+
+ @property
+ def session(self):
+ """Access to the underlying session for advanced usage."""
+ return self.request_context.session
+
+ # Convenience methods for common log levels
+ def debug(self, message: str, **extra: Any) -> None:
+ """Send a debug log message."""
+ self.log("debug", message, **extra)
+
+ def info(self, message: str, **extra: Any) -> None:
+ """Send an info log message."""
+ self.log("info", message, **extra)
+
+ def warning(self, message: str, **extra: Any) -> None:
+ """Send a warning log message."""
+ self.log("warning", message, **extra)
+
+ def error(self, message: str, **extra: Any) -> None:
+ """Send an error log message."""
+ self.log("error", message, **extra)
diff --git a/src/mcp/server/fastmcp/tools/__init__.py b/src/mcp/server/fastmcp/tools/__init__.py
new file mode 100644
index 000000000..ae9c65619
--- /dev/null
+++ b/src/mcp/server/fastmcp/tools/__init__.py
@@ -0,0 +1,4 @@
+from .base import Tool
+from .tool_manager import ToolManager
+
+__all__ = ["Tool", "ToolManager"]
diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py
new file mode 100644
index 000000000..a8751a5f1
--- /dev/null
+++ b/src/mcp/server/fastmcp/tools/base.py
@@ -0,0 +1,83 @@
+import inspect
+from typing import TYPE_CHECKING, Any, Callable
+
+from pydantic import BaseModel, Field
+
+import mcp.server.fastmcp
+from mcp.server.fastmcp.exceptions import ToolError
+from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
+
+if TYPE_CHECKING:
+ from mcp.server.fastmcp.server import Context
+
+
+class Tool(BaseModel):
+ """Internal tool registration info."""
+
+ fn: Callable = Field(exclude=True)
+ name: str = Field(description="Name of the tool")
+ description: str = Field(description="Description of what the tool does")
+ parameters: dict = Field(description="JSON schema for tool parameters")
+ fn_metadata: FuncMetadata = Field(
+ description="Metadata about the function including a pydantic model for tool"
+ " arguments"
+ )
+ is_async: bool = Field(description="Whether the tool is async")
+ context_kwarg: str | None = Field(
+ None, description="Name of the kwarg that should receive context"
+ )
+
+ @classmethod
+ def from_function(
+ cls,
+ fn: Callable,
+ name: str | None = None,
+ description: str | None = None,
+ context_kwarg: str | None = None,
+ ) -> "Tool":
+ """Create a Tool from a function."""
+ func_name = name or fn.__name__
+
+ if func_name == "":
+ raise ValueError("You must provide a name for lambda functions")
+
+ func_doc = description or fn.__doc__ or ""
+ is_async = inspect.iscoroutinefunction(fn)
+
+ # Find context parameter if it exists
+ if context_kwarg is None:
+ sig = inspect.signature(fn)
+ for param_name, param in sig.parameters.items():
+ if param.annotation is mcp.server.fastmcp.Context:
+ context_kwarg = param_name
+ break
+
+ func_arg_metadata = func_metadata(
+ fn,
+ skip_names=[context_kwarg] if context_kwarg is not None else [],
+ )
+ parameters = func_arg_metadata.arg_model.model_json_schema()
+
+ return cls(
+ fn=fn,
+ name=func_name,
+ description=func_doc,
+ parameters=parameters,
+ fn_metadata=func_arg_metadata,
+ is_async=is_async,
+ context_kwarg=context_kwarg,
+ )
+
+ async def run(self, arguments: dict, context: "Context | None" = None) -> Any:
+ """Run the tool with arguments."""
+ try:
+ return await self.fn_metadata.call_fn_with_arg_validation(
+ self.fn,
+ self.is_async,
+ arguments,
+ {self.context_kwarg: context}
+ if self.context_kwarg is not None
+ else None,
+ )
+ except Exception as e:
+ raise ToolError(f"Error executing tool {self.name}: {e}") from e
diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py
new file mode 100644
index 000000000..807c26b0c
--- /dev/null
+++ b/src/mcp/server/fastmcp/tools/tool_manager.py
@@ -0,0 +1,53 @@
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any
+
+from mcp.server.fastmcp.exceptions import ToolError
+from mcp.server.fastmcp.tools.base import Tool
+from mcp.server.fastmcp.utilities.logging import get_logger
+
+if TYPE_CHECKING:
+ from mcp.server.fastmcp.server import Context
+
+logger = get_logger(__name__)
+
+
+class ToolManager:
+ """Manages FastMCP tools."""
+
+ def __init__(self, warn_on_duplicate_tools: bool = True):
+ self._tools: dict[str, Tool] = {}
+ self.warn_on_duplicate_tools = warn_on_duplicate_tools
+
+ def get_tool(self, name: str) -> Tool | None:
+ """Get tool by name."""
+ return self._tools.get(name)
+
+ def list_tools(self) -> list[Tool]:
+ """List all registered tools."""
+ return list(self._tools.values())
+
+ def add_tool(
+ self,
+ fn: Callable,
+ name: str | None = None,
+ description: str | None = None,
+ ) -> Tool:
+ """Add a tool to the server."""
+ tool = Tool.from_function(fn, name=name, description=description)
+ existing = self._tools.get(tool.name)
+ if existing:
+ if self.warn_on_duplicate_tools:
+ logger.warning(f"Tool already exists: {tool.name}")
+ return existing
+ self._tools[tool.name] = tool
+ return tool
+
+ async def call_tool(
+ self, name: str, arguments: dict, context: "Context | None" = None
+ ) -> Any:
+ """Call a tool by name with arguments."""
+ tool = self.get_tool(name)
+ if not tool:
+ raise ToolError(f"Unknown tool: {name}")
+
+ return await tool.run(arguments, context=context)
diff --git a/src/mcp/server/fastmcp/utilities/__init__.py b/src/mcp/server/fastmcp/utilities/__init__.py
new file mode 100644
index 000000000..be448f97a
--- /dev/null
+++ b/src/mcp/server/fastmcp/utilities/__init__.py
@@ -0,0 +1 @@
+"""FastMCP utility modules."""
diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py
new file mode 100644
index 000000000..cf93049e3
--- /dev/null
+++ b/src/mcp/server/fastmcp/utilities/func_metadata.py
@@ -0,0 +1,210 @@
+import inspect
+import json
+from collections.abc import Awaitable, Callable, Sequence
+from typing import (
+ Annotated,
+ Any,
+ ForwardRef,
+)
+
+from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
+from pydantic._internal._typing_extra import eval_type_backport
+from pydantic.fields import FieldInfo
+from pydantic_core import PydanticUndefined
+
+from mcp.server.fastmcp.exceptions import InvalidSignature
+from mcp.server.fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class ArgModelBase(BaseModel):
+ """A model representing the arguments to a function."""
+
+ def model_dump_one_level(self) -> dict[str, Any]:
+ """Return a dict of the model's fields, one level deep.
+
+ That is, sub-models etc are not dumped - they are kept as pydantic models.
+ """
+ kwargs: dict[str, Any] = {}
+ for field_name in self.model_fields.keys():
+ kwargs[field_name] = getattr(self, field_name)
+ return kwargs
+
+ model_config = ConfigDict(
+ arbitrary_types_allowed=True,
+ )
+
+
+class FuncMetadata(BaseModel):
+ arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
+ # We can add things in the future like
+ # - Maybe some args are excluded from attempting to parse from JSON
+ # - Maybe some args are special (like context) for dependency injection
+
+ async def call_fn_with_arg_validation(
+ self,
+ fn: Callable[..., Any] | Awaitable[Any],
+ fn_is_async: bool,
+ arguments_to_validate: dict[str, Any],
+ arguments_to_pass_directly: dict[str, Any] | None,
+ ) -> Any:
+ """Call the given function with arguments validated and injected.
+
+ Arguments are first attempted to be parsed from JSON, then validated against
+ the argument model, before being passed to the function.
+ """
+ arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
+ arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
+ arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
+
+ arguments_parsed_dict |= arguments_to_pass_directly or {}
+
+ if fn_is_async:
+ if isinstance(fn, Awaitable):
+ return await fn
+ return await fn(**arguments_parsed_dict)
+ if isinstance(fn, Callable):
+ return fn(**arguments_parsed_dict)
+ raise TypeError("fn must be either Callable or Awaitable")
+
+ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
+ """Pre-parse data from JSON.
+
+ Return a dict with same keys as input but with values parsed from JSON
+ if appropriate.
+
+ This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
+ a string rather than an actual list. Claude desktop is prone to this - in fact
+ it seems incapable of NOT doing this. For sub-models, it tends to pass
+ dicts (JSON objects) as JSON strings, which can be pre-parsed here.
+ """
+ new_data = data.copy() # Shallow copy
+ for field_name, field_info in self.arg_model.model_fields.items():
+ if field_name not in data.keys():
+ continue
+ if isinstance(data[field_name], str):
+ try:
+ pre_parsed = json.loads(data[field_name])
+ except json.JSONDecodeError:
+ continue # Not JSON - skip
+ if isinstance(pre_parsed, str):
+ # This is likely that the raw value is e.g. `"hello"` which we
+ # Should really be parsed as '"hello"' in Python - but if we parse
+ # it as JSON it'll turn into just 'hello'. So we skip it.
+ continue
+ new_data[field_name] = pre_parsed
+ assert new_data.keys() == data.keys()
+ return new_data
+
+ model_config = ConfigDict(
+ arbitrary_types_allowed=True,
+ )
+
+
+def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata:
+ """Given a function, return metadata including a pydantic model representing its
+ signature.
+
+ The use case for this is
+ ```
+ meta = func_to_pyd(func)
+ validated_args = meta.arg_model.model_validate(some_raw_data_dict)
+ return func(**validated_args.model_dump_one_level())
+ ```
+
+ **critically** it also provides pre-parse helper to attempt to parse things from
+ JSON.
+
+ Args:
+ func: The function to convert to a pydantic model
+ skip_names: A list of parameter names to skip. These will not be included in
+ the model.
+ Returns:
+ A pydantic model representing the function's signature.
+ """
+ sig = _get_typed_signature(func)
+ params = sig.parameters
+ dynamic_pydantic_model_params: dict[str, Any] = {}
+ globalns = getattr(func, "__globals__", {})
+ for param in params.values():
+ if param.name.startswith("_"):
+ raise InvalidSignature(
+ f"Parameter {param.name} of {func.__name__} cannot start with '_'"
+ )
+ if param.name in skip_names:
+ continue
+ annotation = param.annotation
+
+ # `x: None` / `x: None = None`
+ if annotation is None:
+ annotation = Annotated[
+ None,
+ Field(
+ default=param.default
+ if param.default is not inspect.Parameter.empty
+ else PydanticUndefined
+ ),
+ ]
+
+ # Untyped field
+ if annotation is inspect.Parameter.empty:
+ annotation = Annotated[
+ Any,
+ Field(),
+ # 🤷
+ WithJsonSchema({"title": param.name, "type": "string"}),
+ ]
+
+ field_info = FieldInfo.from_annotated_attribute(
+ _get_typed_annotation(annotation, globalns),
+ param.default
+ if param.default is not inspect.Parameter.empty
+ else PydanticUndefined,
+ )
+ dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
+ continue
+
+ arguments_model = create_model(
+ f"{func.__name__}Arguments",
+ **dynamic_pydantic_model_params,
+ __base__=ArgModelBase,
+ )
+ resp = FuncMetadata(arg_model=arguments_model)
+ return resp
+
+
+def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
+ def try_eval_type(value, globalns, localns):
+ try:
+ return eval_type_backport(value, globalns, localns), True
+ except NameError:
+ return value, False
+
+ if isinstance(annotation, str):
+ annotation = ForwardRef(annotation)
+ annotation, status = try_eval_type(annotation, globalns, globalns)
+
+ # This check and raise could perhaps be skipped, and we (FastMCP) just call
+ # model_rebuild right before using it 🤷
+ if status is False:
+ raise InvalidSignature(f"Unable to evaluate type annotation {annotation}")
+
+ return annotation
+
+
+def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
+ """Get function signature while evaluating forward references"""
+ signature = inspect.signature(call)
+ globalns = getattr(call, "__globals__", {})
+ typed_params = [
+ inspect.Parameter(
+ name=param.name,
+ kind=param.kind,
+ default=param.default,
+ annotation=_get_typed_annotation(param.annotation, globalns),
+ )
+ for param in signature.parameters.values()
+ ]
+ typed_signature = inspect.Signature(typed_params)
+ return typed_signature
diff --git a/src/mcp/server/fastmcp/utilities/logging.py b/src/mcp/server/fastmcp/utilities/logging.py
new file mode 100644
index 000000000..df9da433a
--- /dev/null
+++ b/src/mcp/server/fastmcp/utilities/logging.py
@@ -0,0 +1,43 @@
+"""Logging utilities for FastMCP."""
+
+import logging
+from typing import Literal
+
+
+def get_logger(name: str) -> logging.Logger:
+ """Get a logger nested under MCPnamespace.
+
+ Args:
+ name: the name of the logger, which will be prefixed with 'FastMCP.'
+
+ Returns:
+ a configured logger instance
+ """
+ return logging.getLogger(name)
+
+
+def configure_logging(
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
+) -> None:
+ """Configure logging for MCP.
+
+ Args:
+ level: the log level to use
+ """
+ handlers = []
+ try:
+ from rich.console import Console
+ from rich.logging import RichHandler
+
+ handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True))
+ except ImportError:
+ pass
+
+ if not handlers:
+ handlers.append(logging.StreamHandler())
+
+ logging.basicConfig(
+ level=level,
+ format="%(message)s",
+ handlers=handlers,
+ )
diff --git a/src/mcp/server/fastmcp/utilities/types.py b/src/mcp/server/fastmcp/utilities/types.py
new file mode 100644
index 000000000..ccaa3d69a
--- /dev/null
+++ b/src/mcp/server/fastmcp/utilities/types.py
@@ -0,0 +1,54 @@
+"""Common types used across FastMCP."""
+
+import base64
+from pathlib import Path
+
+from mcp.types import ImageContent
+
+
+class Image:
+ """Helper class for returning images from tools."""
+
+ def __init__(
+ self,
+ path: str | Path | None = None,
+ data: bytes | None = None,
+ format: str | None = None,
+ ):
+ if path is None and data is None:
+ raise ValueError("Either path or data must be provided")
+ if path is not None and data is not None:
+ raise ValueError("Only one of path or data can be provided")
+
+ self.path = Path(path) if path else None
+ self.data = data
+ self._format = format
+ self._mime_type = self._get_mime_type()
+
+ def _get_mime_type(self) -> str:
+ """Get MIME type from format or guess from file extension."""
+ if self._format:
+ return f"image/{self._format.lower()}"
+
+ if self.path:
+ suffix = self.path.suffix.lower()
+ return {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ }.get(suffix, "application/octet-stream")
+ return "image/png" # default for raw binary data
+
+ def to_image_content(self) -> ImageContent:
+ """Convert to MCP ImageContent."""
+ if self.path:
+ with open(self.path, "rb") as f:
+ data = base64.b64encode(f.read()).decode()
+ elif self.data is not None:
+ data = base64.b64encode(self.data).decode()
+ else:
+ raise ValueError("No image data available")
+
+ return ImageContent(type="image", data=data, mimeType=self._mime_type)
diff --git a/src/mcp/server/lowlevel/__init__.py b/src/mcp/server/lowlevel/__init__.py
new file mode 100644
index 000000000..66df38991
--- /dev/null
+++ b/src/mcp/server/lowlevel/__init__.py
@@ -0,0 +1,3 @@
+from .server import NotificationOptions, Server
+
+__all__ = ["Server", "NotificationOptions"]
diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py
new file mode 100644
index 000000000..a0dd033d6
--- /dev/null
+++ b/src/mcp/server/lowlevel/server.py
@@ -0,0 +1,500 @@
+"""
+MCP Server Module
+
+This module provides a framework for creating an MCP (Model Context Protocol) server.
+It allows you to easily define and handle various types of requests and notifications
+in an asynchronous manner.
+
+Usage:
+1. Create a Server instance:
+ server = Server("your_server_name")
+
+2. Define request handlers using decorators:
+ @server.list_prompts()
+ async def handle_list_prompts() -> list[types.Prompt]:
+ # Implementation
+
+ @server.get_prompt()
+ async def handle_get_prompt(
+ name: str, arguments: dict[str, str] | None
+ ) -> types.GetPromptResult:
+ # Implementation
+
+ @server.list_tools()
+ async def handle_list_tools() -> list[types.Tool]:
+ # Implementation
+
+ @server.call_tool()
+ async def handle_call_tool(
+ name: str, arguments: dict | None
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
+ # Implementation
+
+ @server.list_resource_templates()
+ async def handle_list_resource_templates() -> list[types.ResourceTemplate]:
+ # Implementation
+
+3. Define notification handlers if needed:
+ @server.progress_notification()
+ async def handle_progress(
+ progress_token: str | int, progress: float, total: float | None
+ ) -> None:
+ # Implementation
+
+4. Run the server:
+ async def main():
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
+ await server.run(
+ read_stream,
+ write_stream,
+ InitializationOptions(
+ server_name="your_server_name",
+ server_version="your_version",
+ capabilities=server.get_capabilities(
+ notification_options=NotificationOptions(),
+ experimental_capabilities={},
+ ),
+ ),
+ )
+
+ asyncio.run(main())
+
+The Server class provides methods to register handlers for various MCP requests and
+notifications. It automatically manages the request context and handles incoming
+messages from the client.
+"""
+
+import contextvars
+import logging
+import warnings
+from collections.abc import Awaitable, Callable
+from typing import Any, Sequence
+
+from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
+from pydantic import AnyUrl
+
+import mcp.types as types
+from mcp.server.models import InitializationOptions
+from mcp.server.session import ServerSession
+from mcp.server.stdio import stdio_server as stdio_server
+from mcp.shared.context import RequestContext
+from mcp.shared.exceptions import McpError
+from mcp.shared.session import RequestResponder
+
+logger = logging.getLogger(__name__)
+
+request_ctx: contextvars.ContextVar[RequestContext[ServerSession]] = (
+ contextvars.ContextVar("request_ctx")
+)
+
+
+class NotificationOptions:
+ def __init__(
+ self,
+ prompts_changed: bool = False,
+ resources_changed: bool = False,
+ tools_changed: bool = False,
+ ):
+ self.prompts_changed = prompts_changed
+ self.resources_changed = resources_changed
+ self.tools_changed = tools_changed
+
+
+class Server:
+ def __init__(self, name: str):
+ self.name = name
+ self.request_handlers: dict[
+ type, Callable[..., Awaitable[types.ServerResult]]
+ ] = {
+ types.PingRequest: _ping_handler,
+ }
+ self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {}
+ self.notification_options = NotificationOptions()
+ logger.debug(f"Initializing server '{name}'")
+
+ def create_initialization_options(
+ self,
+ notification_options: NotificationOptions | None = None,
+ experimental_capabilities: dict[str, dict[str, Any]] | None = None,
+ ) -> InitializationOptions:
+ """Create initialization options from this server instance."""
+
+ def pkg_version(package: str) -> str:
+ try:
+ from importlib.metadata import version
+
+ v = version(package)
+ if v is not None:
+ return v
+ except Exception:
+ pass
+
+ return "unknown"
+
+ return InitializationOptions(
+ server_name=self.name,
+ server_version=pkg_version("mcp"),
+ capabilities=self.get_capabilities(
+ notification_options or NotificationOptions(),
+ experimental_capabilities or {},
+ ),
+ )
+
+ def get_capabilities(
+ self,
+ notification_options: NotificationOptions,
+ experimental_capabilities: dict[str, dict[str, Any]],
+ ) -> types.ServerCapabilities:
+ """Convert existing handlers to a ServerCapabilities object."""
+ prompts_capability = None
+ resources_capability = None
+ tools_capability = None
+ logging_capability = None
+
+ # Set prompt capabilities if handler exists
+ if types.ListPromptsRequest in self.request_handlers:
+ prompts_capability = types.PromptsCapability(
+ listChanged=notification_options.prompts_changed
+ )
+
+ # Set resource capabilities if handler exists
+ if types.ListResourcesRequest in self.request_handlers:
+ resources_capability = types.ResourcesCapability(
+ subscribe=False, listChanged=notification_options.resources_changed
+ )
+
+ # Set tool capabilities if handler exists
+ if types.ListToolsRequest in self.request_handlers:
+ tools_capability = types.ToolsCapability(
+ listChanged=notification_options.tools_changed
+ )
+
+ # Set logging capabilities if handler exists
+ if types.SetLevelRequest in self.request_handlers:
+ logging_capability = types.LoggingCapability()
+
+ return types.ServerCapabilities(
+ prompts=prompts_capability,
+ resources=resources_capability,
+ tools=tools_capability,
+ logging=logging_capability,
+ experimental=experimental_capabilities,
+ )
+
+ @property
+ def request_context(self) -> RequestContext[ServerSession]:
+ """If called outside of a request context, this will raise a LookupError."""
+ return request_ctx.get()
+
+ def list_prompts(self):
+ def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]):
+ logger.debug("Registering handler for PromptListRequest")
+
+ async def handler(_: Any):
+ prompts = await func()
+ return types.ServerResult(types.ListPromptsResult(prompts=prompts))
+
+ self.request_handlers[types.ListPromptsRequest] = handler
+ return func
+
+ return decorator
+
+ def get_prompt(self):
+ def decorator(
+ func: Callable[
+ [str, dict[str, str] | None], Awaitable[types.GetPromptResult]
+ ],
+ ):
+ logger.debug("Registering handler for GetPromptRequest")
+
+ async def handler(req: types.GetPromptRequest):
+ prompt_get = await func(req.params.name, req.params.arguments)
+ return types.ServerResult(prompt_get)
+
+ self.request_handlers[types.GetPromptRequest] = handler
+ return func
+
+ return decorator
+
+ def list_resources(self):
+ def decorator(func: Callable[[], Awaitable[list[types.Resource]]]):
+ logger.debug("Registering handler for ListResourcesRequest")
+
+ async def handler(_: Any):
+ resources = await func()
+ return types.ServerResult(
+ types.ListResourcesResult(resources=resources)
+ )
+
+ self.request_handlers[types.ListResourcesRequest] = handler
+ return func
+
+ return decorator
+
+ def list_resource_templates(self):
+ def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]):
+ logger.debug("Registering handler for ListResourceTemplatesRequest")
+
+ async def handler(_: Any):
+ templates = await func()
+ return types.ServerResult(
+ types.ListResourceTemplatesResult(resourceTemplates=templates)
+ )
+
+ self.request_handlers[types.ListResourceTemplatesRequest] = handler
+ return func
+
+ return decorator
+
+ def read_resource(self):
+ def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]):
+ logger.debug("Registering handler for ReadResourceRequest")
+
+ async def handler(req: types.ReadResourceRequest):
+ result = await func(req.params.uri)
+ match result:
+ case str(s):
+ content = types.TextResourceContents(
+ uri=req.params.uri,
+ text=s,
+ mimeType="text/plain",
+ )
+ case bytes(b):
+ import base64
+
+ content = types.BlobResourceContents(
+ uri=req.params.uri,
+ blob=base64.urlsafe_b64encode(b).decode(),
+ mimeType="application/octet-stream",
+ )
+
+ return types.ServerResult(
+ types.ReadResourceResult(
+ contents=[content],
+ )
+ )
+
+ self.request_handlers[types.ReadResourceRequest] = handler
+ return func
+
+ return decorator
+
+ def set_logging_level(self):
+ def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]):
+ logger.debug("Registering handler for SetLevelRequest")
+
+ async def handler(req: types.SetLevelRequest):
+ await func(req.params.level)
+ return types.ServerResult(types.EmptyResult())
+
+ self.request_handlers[types.SetLevelRequest] = handler
+ return func
+
+ return decorator
+
+ def subscribe_resource(self):
+ def decorator(func: Callable[[AnyUrl], Awaitable[None]]):
+ logger.debug("Registering handler for SubscribeRequest")
+
+ async def handler(req: types.SubscribeRequest):
+ await func(req.params.uri)
+ return types.ServerResult(types.EmptyResult())
+
+ self.request_handlers[types.SubscribeRequest] = handler
+ return func
+
+ return decorator
+
+ def unsubscribe_resource(self):
+ def decorator(func: Callable[[AnyUrl], Awaitable[None]]):
+ logger.debug("Registering handler for UnsubscribeRequest")
+
+ async def handler(req: types.UnsubscribeRequest):
+ await func(req.params.uri)
+ return types.ServerResult(types.EmptyResult())
+
+ self.request_handlers[types.UnsubscribeRequest] = handler
+ return func
+
+ return decorator
+
+ def list_tools(self):
+ def decorator(func: Callable[[], Awaitable[list[types.Tool]]]):
+ logger.debug("Registering handler for ListToolsRequest")
+
+ async def handler(_: Any):
+ tools = await func()
+ return types.ServerResult(types.ListToolsResult(tools=tools))
+
+ self.request_handlers[types.ListToolsRequest] = handler
+ return func
+
+ return decorator
+
+ def call_tool(self):
+ def decorator(
+ func: Callable[
+ ...,
+ Awaitable[
+ Sequence[
+ types.TextContent | types.ImageContent | types.EmbeddedResource
+ ]
+ ],
+ ],
+ ):
+ logger.debug("Registering handler for CallToolRequest")
+
+ async def handler(req: types.CallToolRequest):
+ try:
+ results = await func(req.params.name, (req.params.arguments or {}))
+ return types.ServerResult(
+ types.CallToolResult(content=list(results), isError=False)
+ )
+ except Exception as e:
+ return types.ServerResult(
+ types.CallToolResult(
+ content=[types.TextContent(type="text", text=str(e))],
+ isError=True,
+ )
+ )
+
+ self.request_handlers[types.CallToolRequest] = handler
+ return func
+
+ return decorator
+
+ def progress_notification(self):
+ def decorator(
+ func: Callable[[str | int, float, float | None], Awaitable[None]],
+ ):
+ logger.debug("Registering handler for ProgressNotification")
+
+ async def handler(req: types.ProgressNotification):
+ await func(
+ req.params.progressToken, req.params.progress, req.params.total
+ )
+
+ self.notification_handlers[types.ProgressNotification] = handler
+ return func
+
+ return decorator
+
+ def completion(self):
+ """Provides completions for prompts and resource templates"""
+
+ def decorator(
+ func: Callable[
+ [
+ types.PromptReference | types.ResourceReference,
+ types.CompletionArgument,
+ ],
+ Awaitable[types.Completion | None],
+ ],
+ ):
+ logger.debug("Registering handler for CompleteRequest")
+
+ async def handler(req: types.CompleteRequest):
+ completion = await func(req.params.ref, req.params.argument)
+ return types.ServerResult(
+ types.CompleteResult(
+ completion=completion
+ if completion is not None
+ else types.Completion(values=[], total=None, hasMore=None),
+ )
+ )
+
+ self.request_handlers[types.CompleteRequest] = handler
+ return func
+
+ return decorator
+
+ async def run(
+ self,
+ read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception],
+ write_stream: MemoryObjectSendStream[types.JSONRPCMessage],
+ initialization_options: InitializationOptions,
+ # When False, exceptions are returned as messages to the client.
+ # When True, exceptions are raised, which will cause the server to shut down
+ # but also make tracing exceptions much easier during testing and when using
+ # in-process servers.
+ raise_exceptions: bool = False,
+ ):
+ with warnings.catch_warnings(record=True) as w:
+ async with ServerSession(
+ read_stream, write_stream, initialization_options
+ ) as session:
+ async for message in session.incoming_messages:
+ logger.debug(f"Received message: {message}")
+
+ match message:
+ case RequestResponder(request=types.ClientRequest(root=req)):
+ logger.info(
+ f"Processing request of type {type(req).__name__}"
+ )
+ if type(req) in self.request_handlers:
+ handler = self.request_handlers[type(req)]
+ logger.debug(
+ f"Dispatching request of type {type(req).__name__}"
+ )
+
+ token = None
+ try:
+ # Set our global state that can be retrieved via
+ # app.get_request_context()
+ token = request_ctx.set(
+ RequestContext(
+ message.request_id,
+ message.request_meta,
+ session,
+ )
+ )
+ response = await handler(req)
+ except McpError as err:
+ response = err.error
+ except Exception as err:
+ if raise_exceptions:
+ raise err
+ response = types.ErrorData(
+ code=0, message=str(err), data=None
+ )
+ finally:
+ # Reset the global state after we are done
+ if token is not None:
+ request_ctx.reset(token)
+
+ await message.respond(response)
+ else:
+ await message.respond(
+ types.ErrorData(
+ code=types.METHOD_NOT_FOUND,
+ message="Method not found",
+ )
+ )
+
+ logger.debug("Response sent")
+ case types.ClientNotification(root=notify):
+ if type(notify) in self.notification_handlers:
+ assert type(notify) in self.notification_handlers
+
+ handler = self.notification_handlers[type(notify)]
+ logger.debug(
+ f"Dispatching notification of type "
+ f"{type(notify).__name__}"
+ )
+
+ try:
+ await handler(notify)
+ except Exception as err:
+ logger.error(
+ f"Uncaught exception in notification handler: "
+ f"{err}"
+ )
+
+ for warning in w:
+ logger.info(
+ f"Warning: {warning.category.__name__}: {warning.message}"
+ )
+
+
+async def _ping_handler(request: types.PingRequest) -> types.ServerResult:
+ return types.ServerResult(types.EmptyResult())
diff --git a/tests/conftest.py b/tests/conftest.py
index 8d792aa29..9c4f8b489 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,7 @@
import pytest
from pydantic import AnyUrl
-from mcp.server import Server
+from mcp.server.lowlevel import Server
from mcp.server.models import InitializationOptions
from mcp.types import Resource, ServerCapabilities
@@ -27,3 +27,8 @@ async def handle_list_resources():
]
return server
+
+
+@pytest.fixture
+def anyio_backend():
+ return "asyncio"
diff --git a/tests/server/fastmcp/__init__.py b/tests/server/fastmcp/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/fastmcp/prompts/__init__.py b/tests/server/fastmcp/prompts/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/fastmcp/prompts/test_base.py b/tests/server/fastmcp/prompts/test_base.py
new file mode 100644
index 000000000..bb47d6d38
--- /dev/null
+++ b/tests/server/fastmcp/prompts/test_base.py
@@ -0,0 +1,206 @@
+import pytest
+from pydantic import FileUrl
+
+from mcp.server.fastmcp.prompts.base import (
+ AssistantMessage,
+ Message,
+ Prompt,
+ TextContent,
+ UserMessage,
+)
+from mcp.types import EmbeddedResource, TextResourceContents
+
+
+class TestRenderPrompt:
+ @pytest.mark.anyio
+ async def test_basic_fn(self):
+ def fn() -> str:
+ return "Hello, world!"
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ UserMessage(content=TextContent(type="text", text="Hello, world!"))
+ ]
+
+ @pytest.mark.anyio
+ async def test_async_fn(self):
+ async def fn() -> str:
+ return "Hello, world!"
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ UserMessage(content=TextContent(type="text", text="Hello, world!"))
+ ]
+
+ @pytest.mark.anyio
+ async def test_fn_with_args(self):
+ async def fn(name: str, age: int = 30) -> str:
+ return f"Hello, {name}! You're {age} years old."
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render(arguments=dict(name="World")) == [
+ UserMessage(
+ content=TextContent(
+ type="text", text="Hello, World! You're 30 years old."
+ )
+ )
+ ]
+
+ @pytest.mark.anyio
+ async def test_fn_with_invalid_kwargs(self):
+ async def fn(name: str, age: int = 30) -> str:
+ return f"Hello, {name}! You're {age} years old."
+
+ prompt = Prompt.from_function(fn)
+ with pytest.raises(ValueError):
+ await prompt.render(arguments=dict(age=40))
+
+ @pytest.mark.anyio
+ async def test_fn_returns_message(self):
+ async def fn() -> UserMessage:
+ return UserMessage(content="Hello, world!")
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ UserMessage(content=TextContent(type="text", text="Hello, world!"))
+ ]
+
+ @pytest.mark.anyio
+ async def test_fn_returns_assistant_message(self):
+ async def fn() -> AssistantMessage:
+ return AssistantMessage(
+ content=TextContent(type="text", text="Hello, world!")
+ )
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
+ ]
+
+ @pytest.mark.anyio
+ async def test_fn_returns_multiple_messages(self):
+ expected = [
+ UserMessage("Hello, world!"),
+ AssistantMessage("How can I help you today?"),
+ UserMessage("I'm looking for a restaurant in the center of town."),
+ ]
+
+ async def fn() -> list[Message]:
+ return expected
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == expected
+
+ @pytest.mark.anyio
+ async def test_fn_returns_list_of_strings(self):
+ expected = [
+ "Hello, world!",
+ "I'm looking for a restaurant in the center of town.",
+ ]
+
+ async def fn() -> list[str]:
+ return expected
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [UserMessage(t) for t in expected]
+
+ @pytest.mark.anyio
+ async def test_fn_returns_resource_content(self):
+ """Test returning a message with resource content."""
+
+ async def fn() -> UserMessage:
+ return UserMessage(
+ content=EmbeddedResource(
+ type="resource",
+ resource=TextResourceContents(
+ uri=FileUrl("file://file.txt"),
+ text="File contents",
+ mimeType="text/plain",
+ ),
+ )
+ )
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ UserMessage(
+ content=EmbeddedResource(
+ type="resource",
+ resource=TextResourceContents(
+ uri=FileUrl("file://file.txt"),
+ text="File contents",
+ mimeType="text/plain",
+ ),
+ )
+ )
+ ]
+
+ @pytest.mark.anyio
+ async def test_fn_returns_mixed_content(self):
+ """Test returning messages with mixed content types."""
+
+ async def fn() -> list[Message]:
+ return [
+ UserMessage(content="Please analyze this file:"),
+ UserMessage(
+ content=EmbeddedResource(
+ type="resource",
+ resource=TextResourceContents(
+ uri=FileUrl("file://file.txt"),
+ text="File contents",
+ mimeType="text/plain",
+ ),
+ )
+ ),
+ AssistantMessage(content="I'll help analyze that file."),
+ ]
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ UserMessage(
+ content=TextContent(type="text", text="Please analyze this file:")
+ ),
+ UserMessage(
+ content=EmbeddedResource(
+ type="resource",
+ resource=TextResourceContents(
+ uri=FileUrl("file://file.txt"),
+ text="File contents",
+ mimeType="text/plain",
+ ),
+ )
+ ),
+ AssistantMessage(
+ content=TextContent(type="text", text="I'll help analyze that file.")
+ ),
+ ]
+
+ @pytest.mark.anyio
+ async def test_fn_returns_dict_with_resource(self):
+ """Test returning a dict with resource content."""
+
+ async def fn() -> dict:
+ return {
+ "role": "user",
+ "content": {
+ "type": "resource",
+ "resource": {
+ "uri": FileUrl("file://file.txt"),
+ "text": "File contents",
+ "mimeType": "text/plain",
+ },
+ },
+ }
+
+ prompt = Prompt.from_function(fn)
+ assert await prompt.render() == [
+ UserMessage(
+ content=EmbeddedResource(
+ type="resource",
+ resource=TextResourceContents(
+ uri=FileUrl("file://file.txt"),
+ text="File contents",
+ mimeType="text/plain",
+ ),
+ )
+ )
+ ]
diff --git a/tests/server/fastmcp/prompts/test_manager.py b/tests/server/fastmcp/prompts/test_manager.py
new file mode 100644
index 000000000..c64a4a564
--- /dev/null
+++ b/tests/server/fastmcp/prompts/test_manager.py
@@ -0,0 +1,112 @@
+import pytest
+
+from mcp.server.fastmcp.prompts.base import Prompt, TextContent, UserMessage
+from mcp.server.fastmcp.prompts.manager import PromptManager
+
+
+class TestPromptManager:
+ def test_add_prompt(self):
+ """Test adding a prompt to the manager."""
+
+ def fn() -> str:
+ return "Hello, world!"
+
+ manager = PromptManager()
+ prompt = Prompt.from_function(fn)
+ added = manager.add_prompt(prompt)
+ assert added == prompt
+ assert manager.get_prompt("fn") == prompt
+
+ def test_add_duplicate_prompt(self, caplog):
+ """Test adding the same prompt twice."""
+
+ def fn() -> str:
+ return "Hello, world!"
+
+ manager = PromptManager()
+ prompt = Prompt.from_function(fn)
+ first = manager.add_prompt(prompt)
+ second = manager.add_prompt(prompt)
+ assert first == second
+ assert "Prompt already exists" in caplog.text
+
+ def test_disable_warn_on_duplicate_prompts(self, caplog):
+ """Test disabling warning on duplicate prompts."""
+
+ def fn() -> str:
+ return "Hello, world!"
+
+ manager = PromptManager(warn_on_duplicate_prompts=False)
+ prompt = Prompt.from_function(fn)
+ first = manager.add_prompt(prompt)
+ second = manager.add_prompt(prompt)
+ assert first == second
+ assert "Prompt already exists" not in caplog.text
+
+ def test_list_prompts(self):
+ """Test listing all prompts."""
+
+ def fn1() -> str:
+ return "Hello, world!"
+
+ def fn2() -> str:
+ return "Goodbye, world!"
+
+ manager = PromptManager()
+ prompt1 = Prompt.from_function(fn1)
+ prompt2 = Prompt.from_function(fn2)
+ manager.add_prompt(prompt1)
+ manager.add_prompt(prompt2)
+ prompts = manager.list_prompts()
+ assert len(prompts) == 2
+ assert prompts == [prompt1, prompt2]
+
+ @pytest.mark.anyio
+ async def test_render_prompt(self):
+ """Test rendering a prompt."""
+
+ def fn() -> str:
+ return "Hello, world!"
+
+ manager = PromptManager()
+ prompt = Prompt.from_function(fn)
+ manager.add_prompt(prompt)
+ messages = await manager.render_prompt("fn")
+ assert messages == [
+ UserMessage(content=TextContent(type="text", text="Hello, world!"))
+ ]
+
+ @pytest.mark.anyio
+ async def test_render_prompt_with_args(self):
+ """Test rendering a prompt with arguments."""
+
+ def fn(name: str) -> str:
+ return f"Hello, {name}!"
+
+ manager = PromptManager()
+ prompt = Prompt.from_function(fn)
+ manager.add_prompt(prompt)
+ messages = await manager.render_prompt("fn", arguments={"name": "World"})
+ assert messages == [
+ UserMessage(content=TextContent(type="text", text="Hello, World!"))
+ ]
+
+ @pytest.mark.anyio
+ async def test_render_unknown_prompt(self):
+ """Test rendering a non-existent prompt."""
+ manager = PromptManager()
+ with pytest.raises(ValueError, match="Unknown prompt: unknown"):
+ await manager.render_prompt("unknown")
+
+ @pytest.mark.anyio
+ async def test_render_prompt_with_missing_args(self):
+ """Test rendering a prompt with missing required arguments."""
+
+ def fn(name: str) -> str:
+ return f"Hello, {name}!"
+
+ manager = PromptManager()
+ prompt = Prompt.from_function(fn)
+ manager.add_prompt(prompt)
+ with pytest.raises(ValueError, match="Missing required arguments"):
+ await manager.render_prompt("fn")
diff --git a/tests/server/fastmcp/resources/__init__.py b/tests/server/fastmcp/resources/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/fastmcp/resources/test_file_resources.py b/tests/server/fastmcp/resources/test_file_resources.py
new file mode 100644
index 000000000..36cbca32c
--- /dev/null
+++ b/tests/server/fastmcp/resources/test_file_resources.py
@@ -0,0 +1,119 @@
+import os
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+
+import pytest
+from pydantic import FileUrl
+
+from mcp.server.fastmcp.resources import FileResource
+
+
+@pytest.fixture
+def temp_file():
+ """Create a temporary file for testing.
+
+ File is automatically cleaned up after the test if it still exists.
+ """
+ content = "test content"
+ with NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write(content)
+ path = Path(f.name).resolve()
+ yield path
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ pass # File was already deleted by the test
+
+
+class TestFileResource:
+ """Test FileResource functionality."""
+
+ def test_file_resource_creation(self, temp_file: Path):
+ """Test creating a FileResource."""
+ resource = FileResource(
+ uri=FileUrl(temp_file.as_uri()),
+ name="test",
+ description="test file",
+ path=temp_file,
+ )
+ assert str(resource.uri) == temp_file.as_uri()
+ assert resource.name == "test"
+ assert resource.description == "test file"
+ assert resource.mime_type == "text/plain" # default
+ assert resource.path == temp_file
+ assert resource.is_binary is False # default
+
+ def test_file_resource_str_path_conversion(self, temp_file: Path):
+ """Test FileResource handles string paths."""
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=Path(str(temp_file)),
+ )
+ assert isinstance(resource.path, Path)
+ assert resource.path.is_absolute()
+
+ @pytest.mark.anyio
+ async def test_read_text_file(self, temp_file: Path):
+ """Test reading a text file."""
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ )
+ content = await resource.read()
+ assert content == "test content"
+ assert resource.mime_type == "text/plain"
+
+ @pytest.mark.anyio
+ async def test_read_binary_file(self, temp_file: Path):
+ """Test reading a file as binary."""
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ is_binary=True,
+ )
+ content = await resource.read()
+ assert isinstance(content, bytes)
+ assert content == b"test content"
+
+ def test_relative_path_error(self):
+ """Test error on relative path."""
+ with pytest.raises(ValueError, match="Path must be absolute"):
+ FileResource(
+ uri=FileUrl("file:///test.txt"),
+ name="test",
+ path=Path("test.txt"),
+ )
+
+ @pytest.mark.anyio
+ async def test_missing_file_error(self, temp_file: Path):
+ """Test error when file doesn't exist."""
+ # Create path to non-existent file
+ missing = temp_file.parent / "missing.txt"
+ resource = FileResource(
+ uri=FileUrl("file:///missing.txt"),
+ name="test",
+ path=missing,
+ )
+ with pytest.raises(ValueError, match="Error reading file"):
+ await resource.read()
+
+ @pytest.mark.skipif(
+ os.name == "nt", reason="File permissions behave differently on Windows"
+ )
+ @pytest.mark.anyio
+ async def test_permission_error(self, temp_file: Path):
+ """Test reading a file without permissions."""
+ temp_file.chmod(0o000) # Remove all permissions
+ try:
+ resource = FileResource(
+ uri=FileUrl(temp_file.as_uri()),
+ name="test",
+ path=temp_file,
+ )
+ with pytest.raises(ValueError, match="Error reading file"):
+ await resource.read()
+ finally:
+ temp_file.chmod(0o644) # Restore permissions
diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py
new file mode 100644
index 000000000..b92af5c3a
--- /dev/null
+++ b/tests/server/fastmcp/resources/test_function_resources.py
@@ -0,0 +1,122 @@
+import pytest
+from pydantic import AnyUrl, BaseModel
+
+from mcp.server.fastmcp.resources import FunctionResource
+
+
+class TestFunctionResource:
+ """Test FunctionResource functionality."""
+
+ def test_function_resource_creation(self):
+ """Test creating a FunctionResource."""
+
+ def my_func() -> str:
+ return "test content"
+
+ resource = FunctionResource(
+ uri=AnyUrl("fn://test"),
+ name="test",
+ description="test function",
+ fn=my_func,
+ )
+ assert str(resource.uri) == "fn://test"
+ assert resource.name == "test"
+ assert resource.description == "test function"
+ assert resource.mime_type == "text/plain" # default
+ assert resource.fn == my_func
+
+ @pytest.mark.anyio
+ async def test_read_text(self):
+ """Test reading text from a FunctionResource."""
+
+ def get_data() -> str:
+ return "Hello, world!"
+
+ resource = FunctionResource(
+ uri=AnyUrl("function://test"),
+ name="test",
+ fn=get_data,
+ )
+ content = await resource.read()
+ assert content == "Hello, world!"
+ assert resource.mime_type == "text/plain"
+
+ @pytest.mark.anyio
+ async def test_read_binary(self):
+ """Test reading binary data from a FunctionResource."""
+
+ def get_data() -> bytes:
+ return b"Hello, world!"
+
+ resource = FunctionResource(
+ uri=AnyUrl("function://test"),
+ name="test",
+ fn=get_data,
+ )
+ content = await resource.read()
+ assert content == b"Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_json_conversion(self):
+ """Test automatic JSON conversion of non-string results."""
+
+ def get_data() -> dict:
+ return {"key": "value"}
+
+ resource = FunctionResource(
+ uri=AnyUrl("function://test"),
+ name="test",
+ fn=get_data,
+ )
+ content = await resource.read()
+ assert isinstance(content, str)
+ assert '"key": "value"' in content
+
+ @pytest.mark.anyio
+ async def test_error_handling(self):
+ """Test error handling in FunctionResource."""
+
+ def failing_func() -> str:
+ raise ValueError("Test error")
+
+ resource = FunctionResource(
+ uri=AnyUrl("function://test"),
+ name="test",
+ fn=failing_func,
+ )
+ with pytest.raises(ValueError, match="Error reading resource function://test"):
+ await resource.read()
+
+ @pytest.mark.anyio
+ async def test_basemodel_conversion(self):
+ """Test handling of BaseModel types."""
+
+ class MyModel(BaseModel):
+ name: str
+
+ resource = FunctionResource(
+ uri=AnyUrl("function://test"),
+ name="test",
+ fn=lambda: MyModel(name="test"),
+ )
+ content = await resource.read()
+ assert content == '{"name": "test"}'
+
+ @pytest.mark.anyio
+ async def test_custom_type_conversion(self):
+ """Test handling of custom types."""
+
+ class CustomData:
+ def __str__(self) -> str:
+ return "custom data"
+
+ def get_data() -> CustomData:
+ return CustomData()
+
+ resource = FunctionResource(
+ uri=AnyUrl("function://test"),
+ name="test",
+ fn=get_data,
+ )
+ content = await resource.read()
+ assert isinstance(content, str)
diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py
new file mode 100644
index 000000000..4423e5315
--- /dev/null
+++ b/tests/server/fastmcp/resources/test_resource_manager.py
@@ -0,0 +1,141 @@
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+
+import pytest
+from pydantic import AnyUrl, FileUrl
+
+from mcp.server.fastmcp.resources import (
+ FileResource,
+ FunctionResource,
+ ResourceManager,
+ ResourceTemplate,
+)
+
+
+@pytest.fixture
+def temp_file():
+ """Create a temporary file for testing.
+
+ File is automatically cleaned up after the test if it still exists.
+ """
+ content = "test content"
+ with NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write(content)
+ path = Path(f.name).resolve()
+ yield path
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ pass # File was already deleted by the test
+
+
+class TestResourceManager:
+ """Test ResourceManager functionality."""
+
+ def test_add_resource(self, temp_file: Path):
+ """Test adding a resource."""
+ manager = ResourceManager()
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ )
+ added = manager.add_resource(resource)
+ assert added == resource
+ assert manager.list_resources() == [resource]
+
+ def test_add_duplicate_resource(self, temp_file: Path):
+ """Test adding the same resource twice."""
+ manager = ResourceManager()
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ )
+ first = manager.add_resource(resource)
+ second = manager.add_resource(resource)
+ assert first == second
+ assert manager.list_resources() == [resource]
+
+ def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
+ """Test warning on duplicate resources."""
+ manager = ResourceManager()
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ )
+ manager.add_resource(resource)
+ manager.add_resource(resource)
+ assert "Resource already exists" in caplog.text
+
+ def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
+ """Test disabling warning on duplicate resources."""
+ manager = ResourceManager(warn_on_duplicate_resources=False)
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ )
+ manager.add_resource(resource)
+ manager.add_resource(resource)
+ assert "Resource already exists" not in caplog.text
+
+ @pytest.mark.anyio
+ async def test_get_resource(self, temp_file: Path):
+ """Test getting a resource by URI."""
+ manager = ResourceManager()
+ resource = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test",
+ path=temp_file,
+ )
+ manager.add_resource(resource)
+ retrieved = await manager.get_resource(resource.uri)
+ assert retrieved == resource
+
+ @pytest.mark.anyio
+ async def test_get_resource_from_template(self):
+ """Test getting a resource through a template."""
+ manager = ResourceManager()
+
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ template = ResourceTemplate.from_function(
+ fn=greet,
+ uri_template="greet://{name}",
+ name="greeter",
+ )
+ manager._templates[template.uri_template] = template
+
+ resource = await manager.get_resource(AnyUrl("greet://world"))
+ assert isinstance(resource, FunctionResource)
+ content = await resource.read()
+ assert content == "Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_get_unknown_resource(self):
+ """Test getting a non-existent resource."""
+ manager = ResourceManager()
+ with pytest.raises(ValueError, match="Unknown resource"):
+ await manager.get_resource(AnyUrl("unknown://test"))
+
+ def test_list_resources(self, temp_file: Path):
+ """Test listing all resources."""
+ manager = ResourceManager()
+ resource1 = FileResource(
+ uri=FileUrl(f"file://{temp_file}"),
+ name="test1",
+ path=temp_file,
+ )
+ resource2 = FileResource(
+ uri=FileUrl(f"file://{temp_file}2"),
+ name="test2",
+ path=temp_file,
+ )
+ manager.add_resource(resource1)
+ manager.add_resource(resource2)
+ resources = manager.list_resources()
+ assert len(resources) == 2
+ assert resources == [resource1, resource2]
diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py
new file mode 100644
index 000000000..09bc600d0
--- /dev/null
+++ b/tests/server/fastmcp/resources/test_resource_template.py
@@ -0,0 +1,188 @@
+import json
+
+import pytest
+from pydantic import BaseModel
+
+from mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate
+
+
+class TestResourceTemplate:
+ """Test ResourceTemplate functionality."""
+
+ def test_template_creation(self):
+ """Test creating a template from a function."""
+
+ def my_func(key: str, value: int) -> dict:
+ return {"key": key, "value": value}
+
+ template = ResourceTemplate.from_function(
+ fn=my_func,
+ uri_template="test://{key}/{value}",
+ name="test",
+ )
+ assert template.uri_template == "test://{key}/{value}"
+ assert template.name == "test"
+ assert template.mime_type == "text/plain" # default
+ test_input = {"key": "test", "value": 42}
+ assert template.fn(**test_input) == my_func(**test_input)
+
+ def test_template_matches(self):
+ """Test matching URIs against a template."""
+
+ def my_func(key: str, value: int) -> dict:
+ return {"key": key, "value": value}
+
+ template = ResourceTemplate.from_function(
+ fn=my_func,
+ uri_template="test://{key}/{value}",
+ name="test",
+ )
+
+ # Valid match
+ params = template.matches("test://foo/123")
+ assert params == {"key": "foo", "value": "123"}
+
+ # No match
+ assert template.matches("test://foo") is None
+ assert template.matches("other://foo/123") is None
+
+ @pytest.mark.anyio
+ async def test_create_resource(self):
+ """Test creating a resource from a template."""
+
+ def my_func(key: str, value: int) -> dict:
+ return {"key": key, "value": value}
+
+ template = ResourceTemplate.from_function(
+ fn=my_func,
+ uri_template="test://{key}/{value}",
+ name="test",
+ )
+
+ resource = await template.create_resource(
+ "test://foo/123",
+ {"key": "foo", "value": 123},
+ )
+
+ assert isinstance(resource, FunctionResource)
+ content = await resource.read()
+ assert isinstance(content, str)
+ data = json.loads(content)
+ assert data == {"key": "foo", "value": 123}
+
+ @pytest.mark.anyio
+ async def test_template_error(self):
+ """Test error handling in template resource creation."""
+
+ def failing_func(x: str) -> str:
+ raise ValueError("Test error")
+
+ template = ResourceTemplate.from_function(
+ fn=failing_func,
+ uri_template="fail://{x}",
+ name="fail",
+ )
+
+ with pytest.raises(ValueError, match="Error creating resource from template"):
+ await template.create_resource("fail://test", {"x": "test"})
+
+ @pytest.mark.anyio
+ async def test_async_text_resource(self):
+ """Test creating a text resource from async function."""
+
+ async def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ template = ResourceTemplate.from_function(
+ fn=greet,
+ uri_template="greet://{name}",
+ name="greeter",
+ )
+
+ resource = await template.create_resource(
+ "greet://world",
+ {"name": "world"},
+ )
+
+ assert isinstance(resource, FunctionResource)
+ content = await resource.read()
+ assert content == "Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_async_binary_resource(self):
+ """Test creating a binary resource from async function."""
+
+ async def get_bytes(value: str) -> bytes:
+ return value.encode()
+
+ template = ResourceTemplate.from_function(
+ fn=get_bytes,
+ uri_template="bytes://{value}",
+ name="bytes",
+ )
+
+ resource = await template.create_resource(
+ "bytes://test",
+ {"value": "test"},
+ )
+
+ assert isinstance(resource, FunctionResource)
+ content = await resource.read()
+ assert content == b"test"
+
+ @pytest.mark.anyio
+ async def test_basemodel_conversion(self):
+ """Test handling of BaseModel types."""
+
+ class MyModel(BaseModel):
+ key: str
+ value: int
+
+ def get_data(key: str, value: int) -> MyModel:
+ return MyModel(key=key, value=value)
+
+ template = ResourceTemplate.from_function(
+ fn=get_data,
+ uri_template="test://{key}/{value}",
+ name="test",
+ )
+
+ resource = await template.create_resource(
+ "test://foo/123",
+ {"key": "foo", "value": 123},
+ )
+
+ assert isinstance(resource, FunctionResource)
+ content = await resource.read()
+ assert isinstance(content, str)
+ data = json.loads(content)
+ assert data == {"key": "foo", "value": 123}
+
+ @pytest.mark.anyio
+ async def test_custom_type_conversion(self):
+ """Test handling of custom types."""
+
+ class CustomData:
+ def __init__(self, value: str):
+ self.value = value
+
+ def __str__(self) -> str:
+ return self.value
+
+ def get_data(value: str) -> CustomData:
+ return CustomData(value)
+
+ template = ResourceTemplate.from_function(
+ fn=get_data,
+ uri_template="test://{value}",
+ name="test",
+ )
+
+ resource = await template.create_resource(
+ "test://hello",
+ {"value": "hello"},
+ )
+
+ assert isinstance(resource, FunctionResource)
+ content = await resource.read()
+ assert content == "hello"
diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py
new file mode 100644
index 000000000..08b3e65e1
--- /dev/null
+++ b/tests/server/fastmcp/resources/test_resources.py
@@ -0,0 +1,101 @@
+import pytest
+from pydantic import AnyUrl
+
+from mcp.server.fastmcp.resources import FunctionResource, Resource
+
+
+class TestResourceValidation:
+ """Test base Resource validation."""
+
+ def test_resource_uri_validation(self):
+ """Test URI validation."""
+
+ def dummy_func() -> str:
+ return "data"
+
+ # Valid URI
+ resource = FunctionResource(
+ uri=AnyUrl("http://example.com/data"),
+ name="test",
+ fn=dummy_func,
+ )
+ assert str(resource.uri) == "http://example.com/data"
+
+ # Missing protocol
+ with pytest.raises(ValueError, match="Input should be a valid URL"):
+ FunctionResource(
+ uri=AnyUrl("invalid"),
+ name="test",
+ fn=dummy_func,
+ )
+
+ # Missing host
+ with pytest.raises(ValueError, match="Input should be a valid URL"):
+ FunctionResource(
+ uri=AnyUrl("http://"),
+ name="test",
+ fn=dummy_func,
+ )
+
+ def test_resource_name_from_uri(self):
+ """Test name is extracted from URI if not provided."""
+
+ def dummy_func() -> str:
+ return "data"
+
+ resource = FunctionResource(
+ uri=AnyUrl("resource://my-resource"),
+ fn=dummy_func,
+ )
+ assert resource.name == "resource://my-resource"
+
+ def test_resource_name_validation(self):
+ """Test name validation."""
+
+ def dummy_func() -> str:
+ return "data"
+
+ # Must provide either name or URI
+ with pytest.raises(ValueError, match="Either name or uri must be provided"):
+ FunctionResource(
+ fn=dummy_func,
+ )
+
+ # Explicit name takes precedence over URI
+ resource = FunctionResource(
+ uri=AnyUrl("resource://uri-name"),
+ name="explicit-name",
+ fn=dummy_func,
+ )
+ assert resource.name == "explicit-name"
+
+ def test_resource_mime_type(self):
+ """Test mime type handling."""
+
+ def dummy_func() -> str:
+ return "data"
+
+ # Default mime type
+ resource = FunctionResource(
+ uri=AnyUrl("resource://test"),
+ fn=dummy_func,
+ )
+ assert resource.mime_type == "text/plain"
+
+ # Custom mime type
+ resource = FunctionResource(
+ uri=AnyUrl("resource://test"),
+ fn=dummy_func,
+ mime_type="application/json",
+ )
+ assert resource.mime_type == "application/json"
+
+ @pytest.mark.anyio
+ async def test_resource_read_abstract(self):
+ """Test that Resource.read() is abstract."""
+
+ class ConcreteResource(Resource):
+ pass
+
+ with pytest.raises(TypeError, match="abstract method"):
+ ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore
diff --git a/tests/server/fastmcp/servers/__init__.py b/tests/server/fastmcp/servers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/fastmcp/servers/test_file_server.py
new file mode 100644
index 000000000..28773b1d5
--- /dev/null
+++ b/tests/server/fastmcp/servers/test_file_server.py
@@ -0,0 +1,121 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from mcp.server.fastmcp import FastMCP
+
+
+@pytest.fixture()
+def test_dir(tmp_path_factory) -> Path:
+ """Create a temporary directory with test files."""
+ tmp = tmp_path_factory.mktemp("test_files")
+
+ # Create test files
+ (tmp / "example.py").write_text("print('hello world')")
+ (tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
+ (tmp / "config.json").write_text('{"test": true}')
+
+ return tmp
+
+
+@pytest.fixture
+def mcp() -> FastMCP:
+ mcp = FastMCP()
+
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def resources(mcp: FastMCP, test_dir: Path) -> FastMCP:
+ @mcp.resource("dir://test_dir")
+ def list_test_dir() -> list[str]:
+ """List the files in the test directory"""
+ return [str(f) for f in test_dir.iterdir()]
+
+ @mcp.resource("file://test_dir/example.py")
+ def read_example_py() -> str:
+ """Read the example.py file"""
+ try:
+ return (test_dir / "example.py").read_text()
+ except FileNotFoundError:
+ return "File not found"
+
+ @mcp.resource("file://test_dir/readme.md")
+ def read_readme_md() -> str:
+ """Read the readme.md file"""
+ try:
+ return (test_dir / "readme.md").read_text()
+ except FileNotFoundError:
+ return "File not found"
+
+ @mcp.resource("file://test_dir/config.json")
+ def read_config_json() -> str:
+ """Read the config.json file"""
+ try:
+ return (test_dir / "config.json").read_text()
+ except FileNotFoundError:
+ return "File not found"
+
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
+ @mcp.tool()
+ def delete_file(path: str) -> bool:
+ # ensure path is in test_dir
+ if Path(path).resolve().parent != test_dir:
+ raise ValueError(f"Path must be in test_dir: {path}")
+ Path(path).unlink()
+ return True
+
+ return mcp
+
+
+@pytest.mark.anyio
+async def test_list_resources(mcp: FastMCP):
+ resources = await mcp.list_resources()
+ assert len(resources) == 4
+
+ assert [str(r.uri) for r in resources] == [
+ "dir://test_dir",
+ "file://test_dir/example.py",
+ "file://test_dir/readme.md",
+ "file://test_dir/config.json",
+ ]
+
+
+@pytest.mark.anyio
+async def test_read_resource_dir(mcp: FastMCP):
+ files = await mcp.read_resource("dir://test_dir")
+ files = json.loads(files)
+
+ assert sorted([Path(f).name for f in files]) == [
+ "config.json",
+ "example.py",
+ "readme.md",
+ ]
+
+
+@pytest.mark.anyio
+async def test_read_resource_file(mcp: FastMCP):
+ result = await mcp.read_resource("file://test_dir/example.py")
+ assert result == "print('hello world')"
+
+
+@pytest.mark.anyio
+async def test_delete_file(mcp: FastMCP, test_dir: Path):
+ await mcp.call_tool(
+ "delete_file", arguments=dict(path=str(test_dir / "example.py"))
+ )
+ assert not (test_dir / "example.py").exists()
+
+
+@pytest.mark.anyio
+async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
+ await mcp.call_tool(
+ "delete_file", arguments=dict(path=str(test_dir / "example.py"))
+ )
+ result = await mcp.read_resource("file://test_dir/example.py")
+ assert result == "File not found"
diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py
new file mode 100644
index 000000000..7173b43b2
--- /dev/null
+++ b/tests/server/fastmcp/test_func_metadata.py
@@ -0,0 +1,364 @@
+from typing import Annotated
+
+import annotated_types
+import pytest
+from pydantic import BaseModel, Field
+
+from mcp.server.fastmcp.utilities.func_metadata import func_metadata
+
+
+class SomeInputModelA(BaseModel):
+ pass
+
+
+class SomeInputModelB(BaseModel):
+ class InnerModel(BaseModel):
+ x: int
+
+ how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")]
+ ok: InnerModel
+ y: None
+
+
+def complex_arguments_fn(
+ an_int: int,
+ must_be_none: None,
+ must_be_none_dumb_annotation: Annotated[None, "blah"],
+ list_of_ints: list[int],
+ # list[str] | str is an interesting case because if it comes in as JSON like
+ # "[\"a\", \"b\"]" then it will be naively parsed as a string.
+ list_str_or_str: list[str] | str,
+ an_int_annotated_with_field: Annotated[
+ int, Field(description="An int with a field")
+ ],
+ an_int_annotated_with_field_and_others: Annotated[
+ int,
+ str, # Should be ignored, really
+ Field(description="An int with a field"),
+ annotated_types.Gt(1),
+ ],
+ an_int_annotated_with_junk: Annotated[
+ int,
+ "123",
+ 456,
+ ],
+ field_with_default_via_field_annotation_before_nondefault_arg: Annotated[
+ int, Field(1)
+ ],
+ unannotated,
+ my_model_a: SomeInputModelA,
+ my_model_a_forward_ref: "SomeInputModelA",
+ my_model_b: SomeInputModelB,
+ an_int_annotated_with_field_default: Annotated[
+ int,
+ Field(1, description="An int with a field"),
+ ],
+ unannotated_with_default=5,
+ my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
+ an_int_with_default: int = 1,
+ must_be_none_with_default: None = None,
+ an_int_with_equals_field: int = Field(1, ge=0),
+ int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
+) -> str:
+ _ = (
+ an_int,
+ must_be_none,
+ must_be_none_dumb_annotation,
+ list_of_ints,
+ list_str_or_str,
+ an_int_annotated_with_field,
+ an_int_annotated_with_field_and_others,
+ an_int_annotated_with_junk,
+ field_with_default_via_field_annotation_before_nondefault_arg,
+ unannotated,
+ an_int_annotated_with_field_default,
+ unannotated_with_default,
+ my_model_a,
+ my_model_a_forward_ref,
+ my_model_b,
+ my_model_a_with_default,
+ an_int_with_default,
+ must_be_none_with_default,
+ an_int_with_equals_field,
+ int_annotated_with_default,
+ )
+ return "ok!"
+
+
+@pytest.mark.anyio
+async def test_complex_function_runtime_arg_validation_non_json():
+ """Test that basic non-JSON arguments are validated correctly"""
+ meta = func_metadata(complex_arguments_fn)
+
+ # Test with minimum required arguments
+ result = await meta.call_fn_with_arg_validation(
+ complex_arguments_fn,
+ fn_is_async=False,
+ arguments_to_validate={
+ "an_int": 1,
+ "must_be_none": None,
+ "must_be_none_dumb_annotation": None,
+ "list_of_ints": [1, 2, 3],
+ "list_str_or_str": "hello",
+ "an_int_annotated_with_field": 42,
+ "an_int_annotated_with_field_and_others": 5,
+ "an_int_annotated_with_junk": 100,
+ "unannotated": "test",
+ "my_model_a": {},
+ "my_model_a_forward_ref": {},
+ "my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None},
+ },
+ arguments_to_pass_directly=None,
+ )
+ assert result == "ok!"
+
+ # Test with invalid types
+ with pytest.raises(ValueError):
+ await meta.call_fn_with_arg_validation(
+ complex_arguments_fn,
+ fn_is_async=False,
+ arguments_to_validate={"an_int": "not an int"},
+ arguments_to_pass_directly=None,
+ )
+
+
+@pytest.mark.anyio
+async def test_complex_function_runtime_arg_validation_with_json():
+ """Test that JSON string arguments are parsed and validated correctly"""
+ meta = func_metadata(complex_arguments_fn)
+
+ result = await meta.call_fn_with_arg_validation(
+ complex_arguments_fn,
+ fn_is_async=False,
+ arguments_to_validate={
+ "an_int": 1,
+ "must_be_none": None,
+ "must_be_none_dumb_annotation": None,
+ "list_of_ints": "[1, 2, 3]", # JSON string
+ "list_str_or_str": '["a", "b", "c"]', # JSON string
+ "an_int_annotated_with_field": 42,
+ "an_int_annotated_with_field_and_others": "5", # JSON string
+ "an_int_annotated_with_junk": 100,
+ "unannotated": "test",
+ "my_model_a": "{}", # JSON string
+ "my_model_a_forward_ref": "{}", # JSON string
+ "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}',
+ },
+ arguments_to_pass_directly=None,
+ )
+ assert result == "ok!"
+
+
+def test_str_vs_list_str():
+ """Test handling of string vs list[str] type annotations.
+
+ This is tricky as '"hello"' can be parsed as a JSON string or a Python string.
+ We want to make sure it's kept as a python string.
+ """
+
+ def func_with_str_types(str_or_list: str | list[str]):
+ return str_or_list
+
+ meta = func_metadata(func_with_str_types)
+
+ # Test string input for union type
+ result = meta.pre_parse_json({"str_or_list": "hello"})
+ assert result["str_or_list"] == "hello"
+
+ # Test string input that contains valid JSON for union type
+ # We want to see here that the JSON-vali string is NOT parsed as JSON, but rather
+ # kept as a raw string
+ result = meta.pre_parse_json({"str_or_list": '"hello"'})
+ assert result["str_or_list"] == '"hello"'
+
+ # Test list input for union type
+ result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'})
+ assert result["str_or_list"] == ["hello", "world"]
+
+
+def test_skip_names():
+ """Test that skipped parameters are not included in the model"""
+
+ def func_with_many_params(
+ keep_this: int, skip_this: str, also_keep: float, also_skip: bool
+ ):
+ return keep_this, skip_this, also_keep, also_skip
+
+ # Skip some parameters
+ meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"])
+
+ # Check model fields
+ assert "keep_this" in meta.arg_model.model_fields
+ assert "also_keep" in meta.arg_model.model_fields
+ assert "skip_this" not in meta.arg_model.model_fields
+ assert "also_skip" not in meta.arg_model.model_fields
+
+ # Validate that we can call with only non-skipped parameters
+ model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
+ assert model.keep_this == 1 # type: ignore
+ assert model.also_keep == 2.5 # type: ignore
+
+
+@pytest.mark.anyio
+async def test_lambda_function():
+ """Test lambda function schema and validation"""
+ fn = lambda x, y=5: x # noqa: E731
+ meta = func_metadata(lambda x, y=5: x)
+
+ # Test schema
+ assert meta.arg_model.model_json_schema() == {
+ "properties": {
+ "x": {"title": "x", "type": "string"},
+ "y": {"default": 5, "title": "y", "type": "string"},
+ },
+ "required": ["x"],
+ "title": "Arguments",
+ "type": "object",
+ }
+
+ async def check_call(args):
+ return await meta.call_fn_with_arg_validation(
+ fn,
+ fn_is_async=False,
+ arguments_to_validate=args,
+ arguments_to_pass_directly=None,
+ )
+
+ # Basic calls
+ assert await check_call({"x": "hello"}) == "hello"
+ assert await check_call({"x": "hello", "y": "world"}) == "hello"
+ assert await check_call({"x": '"hello"'}) == '"hello"'
+
+ # Missing required arg
+ with pytest.raises(ValueError):
+ await check_call({"y": "world"})
+
+
+def test_complex_function_json_schema():
+ meta = func_metadata(complex_arguments_fn)
+ assert meta.arg_model.model_json_schema() == {
+ "$defs": {
+ "InnerModel": {
+ "properties": {"x": {"title": "X", "type": "integer"}},
+ "required": ["x"],
+ "title": "InnerModel",
+ "type": "object",
+ },
+ "SomeInputModelA": {
+ "properties": {},
+ "title": "SomeInputModelA",
+ "type": "object",
+ },
+ "SomeInputModelB": {
+ "properties": {
+ "how_many_shrimp": {
+ "description": "How many shrimp in the tank???",
+ "title": "How Many Shrimp",
+ "type": "integer",
+ },
+ "ok": {"$ref": "#/$defs/InnerModel"},
+ "y": {"title": "Y", "type": "null"},
+ },
+ "required": ["how_many_shrimp", "ok", "y"],
+ "title": "SomeInputModelB",
+ "type": "object",
+ },
+ },
+ "properties": {
+ "an_int": {"title": "An Int", "type": "integer"},
+ "must_be_none": {"title": "Must Be None", "type": "null"},
+ "must_be_none_dumb_annotation": {
+ "title": "Must Be None Dumb Annotation",
+ "type": "null",
+ },
+ "list_of_ints": {
+ "items": {"type": "integer"},
+ "title": "List Of Ints",
+ "type": "array",
+ },
+ "list_str_or_str": {
+ "anyOf": [
+ {"items": {"type": "string"}, "type": "array"},
+ {"type": "string"},
+ ],
+ "title": "List Str Or Str",
+ },
+ "an_int_annotated_with_field": {
+ "description": "An int with a field",
+ "title": "An Int Annotated With Field",
+ "type": "integer",
+ },
+ "an_int_annotated_with_field_and_others": {
+ "description": "An int with a field",
+ "exclusiveMinimum": 1,
+ "title": "An Int Annotated With Field And Others",
+ "type": "integer",
+ },
+ "an_int_annotated_with_junk": {
+ "title": "An Int Annotated With Junk",
+ "type": "integer",
+ },
+ "field_with_default_via_field_annotation_before_nondefault_arg": {
+ "default": 1,
+ "title": "Field With Default Via Field Annotation Before Nondefault Arg",
+ "type": "integer",
+ },
+ "unannotated": {"title": "unannotated", "type": "string"},
+ "my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
+ "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
+ "my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
+ "an_int_annotated_with_field_default": {
+ "default": 1,
+ "description": "An int with a field",
+ "title": "An Int Annotated With Field Default",
+ "type": "integer",
+ },
+ "unannotated_with_default": {
+ "default": 5,
+ "title": "unannotated_with_default",
+ "type": "string",
+ },
+ "my_model_a_with_default": {
+ "$ref": "#/$defs/SomeInputModelA",
+ "default": {},
+ },
+ "an_int_with_default": {
+ "default": 1,
+ "title": "An Int With Default",
+ "type": "integer",
+ },
+ "must_be_none_with_default": {
+ "default": None,
+ "title": "Must Be None With Default",
+ "type": "null",
+ },
+ "an_int_with_equals_field": {
+ "default": 1,
+ "minimum": 0,
+ "title": "An Int With Equals Field",
+ "type": "integer",
+ },
+ "int_annotated_with_default": {
+ "default": 5,
+ "description": "hey",
+ "title": "Int Annotated With Default",
+ "type": "integer",
+ },
+ },
+ "required": [
+ "an_int",
+ "must_be_none",
+ "must_be_none_dumb_annotation",
+ "list_of_ints",
+ "list_str_or_str",
+ "an_int_annotated_with_field",
+ "an_int_annotated_with_field_and_others",
+ "an_int_annotated_with_junk",
+ "unannotated",
+ "my_model_a",
+ "my_model_a_forward_ref",
+ "my_model_b",
+ ],
+ "title": "complex_arguments_fnArguments",
+ "type": "object",
+ }
diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py
new file mode 100644
index 000000000..f5aa4cd5e
--- /dev/null
+++ b/tests/server/fastmcp/test_server.py
@@ -0,0 +1,698 @@
+import base64
+from pathlib import Path
+from typing import TYPE_CHECKING, Union
+
+import pytest
+from pydantic import AnyUrl
+
+from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
+from mcp.server.fastmcp.resources import FileResource, FunctionResource
+from mcp.server.fastmcp.utilities.types import Image
+from mcp.shared.exceptions import McpError
+from mcp.shared.memory import (
+ create_connected_server_and_client_session as client_session,
+)
+from mcp.types import (
+ BlobResourceContents,
+ ImageContent,
+ TextContent,
+ TextResourceContents,
+)
+
+if TYPE_CHECKING:
+ from mcp.server.fastmcp import Context
+
+
+class TestServer:
+ @pytest.mark.anyio
+ async def test_create_server(self):
+ mcp = FastMCP()
+ assert mcp.name == "FastMCP"
+
+ @pytest.mark.anyio
+ async def test_add_tool_decorator(self):
+ mcp = FastMCP()
+
+ @mcp.tool()
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ assert len(mcp._tool_manager.list_tools()) == 1
+
+ @pytest.mark.anyio
+ async def test_add_tool_decorator_incorrect_usage(self):
+ mcp = FastMCP()
+
+ with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
+
+ @mcp.tool # Missing parentheses #type: ignore
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ @pytest.mark.anyio
+ async def test_add_resource_decorator(self):
+ mcp = FastMCP()
+
+ @mcp.resource("r://{x}")
+ def get_data(x: str) -> str:
+ return f"Data: {x}"
+
+ assert len(mcp._resource_manager._templates) == 1
+
+ @pytest.mark.anyio
+ async def test_add_resource_decorator_incorrect_usage(self):
+ mcp = FastMCP()
+
+ with pytest.raises(
+ TypeError, match="The @resource decorator was used incorrectly"
+ ):
+
+ @mcp.resource # Missing parentheses #type: ignore
+ def get_data(x: str) -> str:
+ return f"Data: {x}"
+
+
+def tool_fn(x: int, y: int) -> int:
+ return x + y
+
+
+def error_tool_fn() -> None:
+ raise ValueError("Test error")
+
+
+def image_tool_fn(path: str) -> Image:
+ return Image(path)
+
+
+def mixed_content_tool_fn() -> list[Union[TextContent, ImageContent]]:
+ return [
+ TextContent(type="text", text="Hello"),
+ ImageContent(type="image", data="abc", mimeType="image/png"),
+ ]
+
+
+class TestServerTools:
+ @pytest.mark.anyio
+ async def test_add_tool(self):
+ mcp = FastMCP()
+ mcp.add_tool(tool_fn)
+ mcp.add_tool(tool_fn)
+ assert len(mcp._tool_manager.list_tools()) == 1
+
+ @pytest.mark.anyio
+ async def test_list_tools(self):
+ mcp = FastMCP()
+ mcp.add_tool(tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ tools = await client.list_tools()
+ assert len(tools.tools) == 1
+
+ @pytest.mark.anyio
+ async def test_call_tool(self):
+ mcp = FastMCP()
+ mcp.add_tool(tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("my_tool", {"arg1": "value"})
+ assert not hasattr(result, "error")
+ assert len(result.content) > 0
+
+ @pytest.mark.anyio
+ async def test_tool_exception_handling(self):
+ mcp = FastMCP()
+ mcp.add_tool(error_tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("error_tool_fn", {})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert "Test error" in content.text
+ assert result.isError is True
+
+ @pytest.mark.anyio
+ async def test_tool_error_handling(self):
+ mcp = FastMCP()
+ mcp.add_tool(error_tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("error_tool_fn", {})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert "Test error" in content.text
+ assert result.isError is True
+
+ @pytest.mark.anyio
+ async def test_tool_error_details(self):
+ """Test that exception details are properly formatted in the response"""
+ mcp = FastMCP()
+ mcp.add_tool(error_tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("error_tool_fn", {})
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert isinstance(content.text, str)
+ assert "Test error" in content.text
+ assert result.isError is True
+
+ @pytest.mark.anyio
+ async def test_tool_return_value_conversion(self):
+ mcp = FastMCP()
+ mcp.add_tool(tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert content.text == "3"
+
+ @pytest.mark.anyio
+ async def test_tool_image_helper(self, tmp_path: Path):
+ # Create a test image
+ image_path = tmp_path / "test.png"
+ image_path.write_bytes(b"fake png data")
+
+ mcp = FastMCP()
+ mcp.add_tool(image_tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, ImageContent)
+ assert content.type == "image"
+ assert content.mimeType == "image/png"
+ # Verify base64 encoding
+ decoded = base64.b64decode(content.data)
+ assert decoded == b"fake png data"
+
+ @pytest.mark.anyio
+ async def test_tool_mixed_content(self):
+ mcp = FastMCP()
+ mcp.add_tool(mixed_content_tool_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("mixed_content_tool_fn", {})
+ assert len(result.content) == 2
+ content1 = result.content[0]
+ content2 = result.content[1]
+ assert isinstance(content1, TextContent)
+ assert content1.text == "Hello"
+ assert isinstance(content2, ImageContent)
+ assert content2.mimeType == "image/png"
+ assert content2.data == "abc"
+
+ @pytest.mark.anyio
+ async def test_tool_mixed_list_with_image(self, tmp_path: Path):
+ """Test that lists containing Image objects and other types are handled
+ correctly"""
+ # Create a test image
+ image_path = tmp_path / "test.png"
+ image_path.write_bytes(b"test image data")
+
+ def mixed_list_fn() -> list:
+ return [
+ "text message",
+ Image(image_path),
+ {"key": "value"},
+ TextContent(type="text", text="direct content"),
+ ]
+
+ mcp = FastMCP()
+ mcp.add_tool(mixed_list_fn)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("mixed_list_fn", {})
+ assert len(result.content) == 4
+ # Check text conversion
+ content1 = result.content[0]
+ assert isinstance(content1, TextContent)
+ assert content1.text == "text message"
+ # Check image conversion
+ content2 = result.content[1]
+ assert isinstance(content2, ImageContent)
+ assert content2.mimeType == "image/png"
+ assert base64.b64decode(content2.data) == b"test image data"
+ # Check dict conversion
+ content3 = result.content[2]
+ assert isinstance(content3, TextContent)
+ assert '"key": "value"' in content3.text
+ # Check direct TextContent
+ content4 = result.content[3]
+ assert isinstance(content4, TextContent)
+ assert content4.text == "direct content"
+
+
+class TestServerResources:
+ @pytest.mark.anyio
+ async def test_text_resource(self):
+ mcp = FastMCP()
+
+ def get_text():
+ return "Hello, world!"
+
+ resource = FunctionResource(
+ uri=AnyUrl("resource://test"), name="test", fn=get_text
+ )
+ mcp.add_resource(resource)
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(AnyUrl("resource://test"))
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].text == "Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_binary_resource(self):
+ mcp = FastMCP()
+
+ def get_binary():
+ return b"Binary data"
+
+ resource = FunctionResource(
+ uri=AnyUrl("resource://binary"),
+ name="binary",
+ fn=get_binary,
+ mime_type="application/octet-stream",
+ )
+ mcp.add_resource(resource)
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(AnyUrl("resource://binary"))
+ assert isinstance(result.contents[0], BlobResourceContents)
+ assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
+
+ @pytest.mark.anyio
+ async def test_file_resource_text(self, tmp_path: Path):
+ mcp = FastMCP()
+
+ # Create a text file
+ text_file = tmp_path / "test.txt"
+ text_file.write_text("Hello from file!")
+
+ resource = FileResource(
+ uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
+ )
+ mcp.add_resource(resource)
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(AnyUrl("file://test.txt"))
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].text == "Hello from file!"
+
+ @pytest.mark.anyio
+ async def test_file_resource_binary(self, tmp_path: Path):
+ mcp = FastMCP()
+
+ # Create a binary file
+ binary_file = tmp_path / "test.bin"
+ binary_file.write_bytes(b"Binary file data")
+
+ resource = FileResource(
+ uri=AnyUrl("file://test.bin"),
+ name="test.bin",
+ path=binary_file,
+ mime_type="application/octet-stream",
+ )
+ mcp.add_resource(resource)
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(AnyUrl("file://test.bin"))
+ assert isinstance(result.contents[0], BlobResourceContents)
+ assert (
+ result.contents[0].blob
+ == base64.b64encode(b"Binary file data").decode()
+ )
+
+
+class TestServerResourceTemplates:
+ @pytest.mark.anyio
+ async def test_resource_with_params(self):
+ """Test that a resource with function parameters raises an error if the URI
+ parameters don't match"""
+ mcp = FastMCP()
+
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
+
+ @mcp.resource("resource://data")
+ def get_data_fn(param: str) -> str:
+ return f"Data: {param}"
+
+ @pytest.mark.anyio
+ async def test_resource_with_uri_params(self):
+ """Test that a resource with URI parameters is automatically a template"""
+ mcp = FastMCP()
+
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
+
+ @mcp.resource("resource://{param}")
+ def get_data() -> str:
+ return "Data"
+
+ @pytest.mark.anyio
+ async def test_resource_with_untyped_params(self):
+ """Test that a resource with untyped parameters raises an error"""
+ mcp = FastMCP()
+
+ @mcp.resource("resource://{param}")
+ def get_data(param) -> str:
+ return "Data"
+
+ @pytest.mark.anyio
+ async def test_resource_matching_params(self):
+ """Test that a resource with matching URI and function parameters works"""
+ mcp = FastMCP()
+
+ @mcp.resource("resource://{name}/data")
+ def get_data(name: str) -> str:
+ return f"Data for {name}"
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(AnyUrl("resource://test/data"))
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].text == "Data for test"
+
+ @pytest.mark.anyio
+ async def test_resource_mismatched_params(self):
+ """Test that mismatched parameters raise an error"""
+ mcp = FastMCP()
+
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
+
+ @mcp.resource("resource://{name}/data")
+ def get_data(user: str) -> str:
+ return f"Data for {user}"
+
+ @pytest.mark.anyio
+ async def test_resource_multiple_params(self):
+ """Test that multiple parameters work correctly"""
+ mcp = FastMCP()
+
+ @mcp.resource("resource://{org}/{repo}/data")
+ def get_data(org: str, repo: str) -> str:
+ return f"Data for {org}/{repo}"
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(
+ AnyUrl("resource://cursor/fastmcp/data")
+ )
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].text == "Data for cursor/fastmcp"
+
+ @pytest.mark.anyio
+ async def test_resource_multiple_mismatched_params(self):
+ """Test that mismatched parameters raise an error"""
+ mcp = FastMCP()
+
+ with pytest.raises(ValueError, match="Mismatch between URI parameters"):
+
+ @mcp.resource("resource://{org}/{repo}/data")
+ def get_data_mismatched(org: str, repo_2: str) -> str:
+ return f"Data for {org}"
+
+ """Test that a resource with no parameters works as a regular resource"""
+ mcp = FastMCP()
+
+ @mcp.resource("resource://static")
+ def get_static_data() -> str:
+ return "Static data"
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.read_resource(AnyUrl("resource://static"))
+ assert isinstance(result.contents[0], TextResourceContents)
+ assert result.contents[0].text == "Static data"
+
+ @pytest.mark.anyio
+ async def test_template_to_resource_conversion(self):
+ """Test that templates are properly converted to resources when accessed"""
+ mcp = FastMCP()
+
+ @mcp.resource("resource://{name}/data")
+ def get_data(name: str) -> str:
+ return f"Data for {name}"
+
+ # Should be registered as a template
+ assert len(mcp._resource_manager._templates) == 1
+ assert len(await mcp.list_resources()) == 0
+
+ # When accessed, should create a concrete resource
+ resource = await mcp._resource_manager.get_resource("resource://test/data")
+ assert isinstance(resource, FunctionResource)
+ result = await resource.read()
+ assert result == "Data for test"
+
+
+class TestContextInjection:
+ """Test context injection in tools."""
+
+ @pytest.mark.anyio
+ async def test_context_detection(self):
+ """Test that context parameters are properly detected."""
+ mcp = FastMCP()
+
+ def tool_with_context(x: int, ctx: Context) -> str:
+ return f"Request {ctx.request_id}: {x}"
+
+ tool = mcp._tool_manager.add_tool(tool_with_context)
+ assert tool.context_kwarg == "ctx"
+
+ @pytest.mark.anyio
+ async def test_context_injection(self):
+ """Test that context is properly injected into tool calls."""
+ mcp = FastMCP()
+
+ def tool_with_context(x: int, ctx: Context) -> str:
+ assert ctx.request_id is not None
+ return f"Request {ctx.request_id}: {x}"
+
+ mcp.add_tool(tool_with_context)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("tool_with_context", {"x": 42})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert "Request" in content.text
+ assert "42" in content.text
+
+ @pytest.mark.anyio
+ async def test_async_context(self):
+ """Test that context works in async functions."""
+ mcp = FastMCP()
+
+ async def async_tool(x: int, ctx: Context) -> str:
+ assert ctx.request_id is not None
+ return f"Async request {ctx.request_id}: {x}"
+
+ mcp.add_tool(async_tool)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("async_tool", {"x": 42})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert "Async request" in content.text
+ assert "42" in content.text
+
+ @pytest.mark.anyio
+ async def test_context_logging(self):
+ """Test that context logging methods work."""
+ mcp = FastMCP()
+
+ def logging_tool(msg: str, ctx: Context) -> str:
+ ctx.debug("Debug message")
+ ctx.info("Info message")
+ ctx.warning("Warning message")
+ ctx.error("Error message")
+ return f"Logged messages for {msg}"
+
+ mcp.add_tool(logging_tool)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("logging_tool", {"msg": "test"})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert "Logged messages for test" in content.text
+
+ @pytest.mark.anyio
+ async def test_optional_context(self):
+ """Test that context is optional."""
+ mcp = FastMCP()
+
+ def no_context(x: int) -> int:
+ return x * 2
+
+ mcp.add_tool(no_context)
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("no_context", {"x": 21})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert content.text == "42"
+
+ @pytest.mark.anyio
+ async def test_context_resource_access(self):
+ """Test that context can access resources."""
+ mcp = FastMCP()
+
+ @mcp.resource("test://data")
+ def test_resource() -> str:
+ return "resource data"
+
+ @mcp.tool()
+ async def tool_with_resource(ctx: Context) -> str:
+ data = await ctx.read_resource("test://data")
+ return f"Read resource: {data}"
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("tool_with_resource", {})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert "Read resource: resource data" in content.text
+
+
+class TestServerPrompts:
+ """Test prompt functionality in FastMCP server."""
+
+ @pytest.mark.anyio
+ async def test_prompt_decorator(self):
+ """Test that the prompt decorator registers prompts correctly."""
+ mcp = FastMCP()
+
+ @mcp.prompt()
+ def fn() -> str:
+ return "Hello, world!"
+
+ prompts = mcp._prompt_manager.list_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].name == "fn"
+ # Don't compare functions directly since validate_call wraps them
+ content = await prompts[0].render()
+ assert isinstance(content[0].content, TextContent)
+ assert content[0].content.text == "Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_prompt_decorator_with_name(self):
+ """Test prompt decorator with custom name."""
+ mcp = FastMCP()
+
+ @mcp.prompt(name="custom_name")
+ def fn() -> str:
+ return "Hello, world!"
+
+ prompts = mcp._prompt_manager.list_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].name == "custom_name"
+ content = await prompts[0].render()
+ assert isinstance(content[0].content, TextContent)
+ assert content[0].content.text == "Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_prompt_decorator_with_description(self):
+ """Test prompt decorator with custom description."""
+ mcp = FastMCP()
+
+ @mcp.prompt(description="A custom description")
+ def fn() -> str:
+ return "Hello, world!"
+
+ prompts = mcp._prompt_manager.list_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].description == "A custom description"
+ content = await prompts[0].render()
+ assert isinstance(content[0].content, TextContent)
+ assert content[0].content.text == "Hello, world!"
+
+ def test_prompt_decorator_error(self):
+ """Test error when decorator is used incorrectly."""
+ mcp = FastMCP()
+ with pytest.raises(TypeError, match="decorator was used incorrectly"):
+
+ @mcp.prompt # type: ignore
+ def fn() -> str:
+ return "Hello, world!"
+
+ @pytest.mark.anyio
+ async def test_list_prompts(self):
+ """Test listing prompts through MCP protocol."""
+ mcp = FastMCP()
+
+ @mcp.prompt()
+ def fn(name: str, optional: str = "default") -> str:
+ return f"Hello, {name}!"
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.list_prompts()
+ assert result.prompts is not None
+ assert len(result.prompts) == 1
+ prompt = result.prompts[0]
+ assert prompt.name == "fn"
+ assert prompt.arguments is not None
+ assert len(prompt.arguments) == 2
+ assert prompt.arguments[0].name == "name"
+ assert prompt.arguments[0].required is True
+ assert prompt.arguments[1].name == "optional"
+ assert prompt.arguments[1].required is False
+
+ @pytest.mark.anyio
+ async def test_get_prompt(self):
+ """Test getting a prompt through MCP protocol."""
+ mcp = FastMCP()
+
+ @mcp.prompt()
+ def fn(name: str) -> str:
+ return f"Hello, {name}!"
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.get_prompt("fn", {"name": "World"})
+ assert len(result.messages) == 1
+ message = result.messages[0]
+ assert message.role == "user"
+ content = message.content
+ assert isinstance(content, TextContent)
+ assert content.text == "Hello, World!"
+
+ @pytest.mark.anyio
+ async def test_get_prompt_with_resource(self):
+ """Test getting a prompt that returns resource content."""
+ mcp = FastMCP()
+
+ @mcp.prompt()
+ def fn() -> Message:
+ return UserMessage(
+ content=EmbeddedResource(
+ type="resource",
+ resource=TextResourceContents(
+ uri=AnyUrl("file://file.txt"),
+ text="File contents",
+ mimeType="text/plain",
+ ),
+ )
+ )
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.get_prompt("fn")
+ assert len(result.messages) == 1
+ message = result.messages[0]
+ assert message.role == "user"
+ content = message.content
+ assert isinstance(content, EmbeddedResource)
+ resource = content.resource
+ assert isinstance(resource, TextResourceContents)
+ assert resource.text == "File contents"
+ assert resource.mimeType == "text/plain"
+
+ @pytest.mark.anyio
+ async def test_get_unknown_prompt(self):
+ """Test error when getting unknown prompt."""
+ mcp = FastMCP()
+ async with client_session(mcp._mcp_server) as client:
+ with pytest.raises(McpError, match="Unknown prompt"):
+ await client.get_prompt("unknown")
+
+ @pytest.mark.anyio
+ async def test_get_prompt_missing_args(self):
+ """Test error when required arguments are missing."""
+ mcp = FastMCP()
+
+ @mcp.prompt()
+ def prompt_fn(name: str) -> str:
+ return f"Hello, {name}!"
+
+ async with client_session(mcp._mcp_server) as client:
+ with pytest.raises(McpError, match="Missing required arguments"):
+ await client.get_prompt("prompt_fn")
diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py
new file mode 100644
index 000000000..4adfc47bf
--- /dev/null
+++ b/tests/server/fastmcp/test_tool_manager.py
@@ -0,0 +1,322 @@
+import json
+import logging
+from typing import Optional
+
+import pytest
+from pydantic import BaseModel
+
+from mcp.server.fastmcp.exceptions import ToolError
+from mcp.server.fastmcp.tools import ToolManager
+
+
+class TestAddTools:
+ def test_basic_function(self):
+ """Test registering and running a basic function."""
+
+ def add(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ manager = ToolManager()
+ manager.add_tool(add)
+
+ tool = manager.get_tool("add")
+ assert tool is not None
+ assert tool.name == "add"
+ assert tool.description == "Add two numbers."
+ assert tool.is_async is False
+ assert tool.parameters["properties"]["a"]["type"] == "integer"
+ assert tool.parameters["properties"]["b"]["type"] == "integer"
+
+ @pytest.mark.anyio
+ async def test_async_function(self):
+ """Test registering and running an async function."""
+
+ async def fetch_data(url: str) -> str:
+ """Fetch data from URL."""
+ return f"Data from {url}"
+
+ manager = ToolManager()
+ manager.add_tool(fetch_data)
+
+ tool = manager.get_tool("fetch_data")
+ assert tool is not None
+ assert tool.name == "fetch_data"
+ assert tool.description == "Fetch data from URL."
+ assert tool.is_async is True
+ assert tool.parameters["properties"]["url"]["type"] == "string"
+
+ def test_pydantic_model_function(self):
+ """Test registering a function that takes a Pydantic model."""
+
+ class UserInput(BaseModel):
+ name: str
+ age: int
+
+ def create_user(user: UserInput, flag: bool) -> dict:
+ """Create a new user."""
+ return {"id": 1, **user.model_dump()}
+
+ manager = ToolManager()
+ manager.add_tool(create_user)
+
+ tool = manager.get_tool("create_user")
+ assert tool is not None
+ assert tool.name == "create_user"
+ assert tool.description == "Create a new user."
+ assert tool.is_async is False
+ assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
+ assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
+ assert "flag" in tool.parameters["properties"]
+
+ def test_add_invalid_tool(self):
+ manager = ToolManager()
+ with pytest.raises(AttributeError):
+ manager.add_tool(1) # type: ignore
+
+ def test_add_lambda(self):
+ manager = ToolManager()
+ tool = manager.add_tool(lambda x: x, name="my_tool")
+ assert tool.name == "my_tool"
+
+ def test_add_lambda_with_no_name(self):
+ manager = ToolManager()
+ with pytest.raises(
+ ValueError, match="You must provide a name for lambda functions"
+ ):
+ manager.add_tool(lambda x: x)
+
+ def test_warn_on_duplicate_tools(self, caplog):
+ """Test warning on duplicate tools."""
+
+ def f(x: int) -> int:
+ return x
+
+ manager = ToolManager()
+ manager.add_tool(f)
+ with caplog.at_level(logging.WARNING):
+ manager.add_tool(f)
+ assert "Tool already exists: f" in caplog.text
+
+ def test_disable_warn_on_duplicate_tools(self, caplog):
+ """Test disabling warning on duplicate tools."""
+
+ def f(x: int) -> int:
+ return x
+
+ manager = ToolManager()
+ manager.add_tool(f)
+ manager.warn_on_duplicate_tools = False
+ with caplog.at_level(logging.WARNING):
+ manager.add_tool(f)
+ assert "Tool already exists: f" not in caplog.text
+
+
+class TestCallTools:
+ @pytest.mark.anyio
+ async def test_call_tool(self):
+ def add(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ manager = ToolManager()
+ manager.add_tool(add)
+ result = await manager.call_tool("add", {"a": 1, "b": 2})
+ assert result == 3
+
+ @pytest.mark.anyio
+ async def test_call_async_tool(self):
+ async def double(n: int) -> int:
+ """Double a number."""
+ return n * 2
+
+ manager = ToolManager()
+ manager.add_tool(double)
+ result = await manager.call_tool("double", {"n": 5})
+ assert result == 10
+
+ @pytest.mark.anyio
+ async def test_call_tool_with_default_args(self):
+ def add(a: int, b: int = 1) -> int:
+ """Add two numbers."""
+ return a + b
+
+ manager = ToolManager()
+ manager.add_tool(add)
+ result = await manager.call_tool("add", {"a": 1})
+ assert result == 2
+
+ @pytest.mark.anyio
+ async def test_call_tool_with_missing_args(self):
+ def add(a: int, b: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ manager = ToolManager()
+ manager.add_tool(add)
+ with pytest.raises(ToolError):
+ await manager.call_tool("add", {"a": 1})
+
+ @pytest.mark.anyio
+ async def test_call_unknown_tool(self):
+ manager = ToolManager()
+ with pytest.raises(ToolError):
+ await manager.call_tool("unknown", {"a": 1})
+
+ @pytest.mark.anyio
+ async def test_call_tool_with_list_int_input(self):
+ def sum_vals(vals: list[int]) -> int:
+ return sum(vals)
+
+ manager = ToolManager()
+ manager.add_tool(sum_vals)
+ # Try both with plain list and with JSON list
+ result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
+ assert result == 6
+ result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
+ assert result == 6
+
+ @pytest.mark.anyio
+ async def test_call_tool_with_list_str_or_str_input(self):
+ def concat_strs(vals: list[str] | str) -> str:
+ return vals if isinstance(vals, str) else "".join(vals)
+
+ manager = ToolManager()
+ manager.add_tool(concat_strs)
+ # Try both with plain python object and with JSON list
+ result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
+ assert result == "abc"
+ result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
+ assert result == "abc"
+ result = await manager.call_tool("concat_strs", {"vals": "a"})
+ assert result == "a"
+ result = await manager.call_tool("concat_strs", {"vals": '"a"'})
+ assert result == '"a"'
+
+ @pytest.mark.anyio
+ async def test_call_tool_with_complex_model(self):
+ from mcp.server.fastmcp import Context
+
+ class MyShrimpTank(BaseModel):
+ class Shrimp(BaseModel):
+ name: str
+
+ shrimp: list[Shrimp]
+ x: None
+
+ def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]:
+ return [x.name for x in tank.shrimp]
+
+ manager = ToolManager()
+ manager.add_tool(name_shrimp)
+ result = await manager.call_tool(
+ "name_shrimp",
+ {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
+ )
+ assert result == ["rex", "gertrude"]
+ result = await manager.call_tool(
+ "name_shrimp",
+ {"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
+ )
+ assert result == ["rex", "gertrude"]
+
+
+class TestToolSchema:
+ @pytest.mark.anyio
+ async def test_context_arg_excluded_from_schema(self):
+ from mcp.server.fastmcp import Context
+
+ def something(a: int, ctx: Context) -> int:
+ return a
+
+ manager = ToolManager()
+ tool = manager.add_tool(something)
+ assert "ctx" not in json.dumps(tool.parameters)
+ assert "Context" not in json.dumps(tool.parameters)
+ assert "ctx" not in tool.fn_metadata.arg_model.model_fields
+
+
+class TestContextHandling:
+ """Test context handling in the tool manager."""
+
+ def test_context_parameter_detection(self):
+ """Test that context parameters are properly detected in
+ Tool.from_function()."""
+ from mcp.server.fastmcp import Context
+
+ def tool_with_context(x: int, ctx: Context) -> str:
+ return str(x)
+
+ manager = ToolManager()
+ tool = manager.add_tool(tool_with_context)
+ assert tool.context_kwarg == "ctx"
+
+ def tool_without_context(x: int) -> str:
+ return str(x)
+
+ tool = manager.add_tool(tool_without_context)
+ assert tool.context_kwarg is None
+
+ @pytest.mark.anyio
+ async def test_context_injection(self):
+ """Test that context is properly injected during tool execution."""
+ from mcp.server.fastmcp import Context, FastMCP
+
+ def tool_with_context(x: int, ctx: Context) -> str:
+ assert isinstance(ctx, Context)
+ return str(x)
+
+ manager = ToolManager()
+ manager.add_tool(tool_with_context)
+
+ mcp = FastMCP()
+ ctx = mcp.get_context()
+ result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
+ assert result == "42"
+
+ @pytest.mark.anyio
+ async def test_context_injection_async(self):
+ """Test that context is properly injected in async tools."""
+ from mcp.server.fastmcp import Context, FastMCP
+
+ async def async_tool(x: int, ctx: Context) -> str:
+ assert isinstance(ctx, Context)
+ return str(x)
+
+ manager = ToolManager()
+ manager.add_tool(async_tool)
+
+ mcp = FastMCP()
+ ctx = mcp.get_context()
+ result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
+ assert result == "42"
+
+ @pytest.mark.anyio
+ async def test_context_optional(self):
+ """Test that context is optional when calling tools."""
+ from mcp.server.fastmcp import Context
+
+ def tool_with_context(x: int, ctx: Optional[Context] = None) -> str:
+ return str(x)
+
+ manager = ToolManager()
+ manager.add_tool(tool_with_context)
+ # Should not raise an error when context is not provided
+ result = await manager.call_tool("tool_with_context", {"x": 42})
+ assert result == "42"
+
+ @pytest.mark.anyio
+ async def test_context_error_handling(self):
+ """Test error handling when context injection fails."""
+ from mcp.server.fastmcp import Context, FastMCP
+
+ def tool_with_context(x: int, ctx: Context) -> str:
+ raise ValueError("Test error")
+
+ manager = ToolManager()
+ manager.add_tool(tool_with_context)
+
+ mcp = FastMCP()
+ ctx = mcp.get_context()
+ with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
+ await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
diff --git a/tests/server/test_session.py b/tests/server/test_session.py
index a78ca90ef..ead18f7ae 100644
--- a/tests/server/test_session.py
+++ b/tests/server/test_session.py
@@ -2,7 +2,8 @@
import pytest
from mcp.client.session import ClientSession
-from mcp.server import NotificationOptions, Server
+from mcp.server import Server
+from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.types import (
diff --git a/tests/test_examples.py b/tests/test_examples.py
new file mode 100644
index 000000000..b097fafbf
--- /dev/null
+++ b/tests/test_examples.py
@@ -0,0 +1,72 @@
+"""Tests for example servers"""
+
+import pytest
+
+from mcp.shared.memory import (
+ create_connected_server_and_client_session as client_session,
+)
+from mcp.types import TextContent, TextResourceContents
+
+
+@pytest.mark.anyio
+async def test_simple_echo():
+ """Test the simple echo server"""
+ from examples.fastmcp.simple_echo import mcp
+
+ async with client_session(mcp._mcp_server) as client:
+ result = await client.call_tool("echo", {"text": "hello"})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert content.text == "hello"
+
+
+@pytest.mark.anyio
+async def test_complex_inputs():
+ """Test the complex inputs server"""
+ from examples.fastmcp.complex_inputs import mcp
+
+ async with client_session(mcp._mcp_server) as client:
+ tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]}
+ result = await client.call_tool(
+ "name_shrimp", {"tank": tank, "extra_names": ["charlie"]}
+ )
+ assert len(result.content) == 3
+ assert isinstance(result.content[0], TextContent)
+ assert isinstance(result.content[1], TextContent)
+ assert isinstance(result.content[2], TextContent)
+ assert result.content[0].text == "bob"
+ assert result.content[1].text == "alice"
+ assert result.content[2].text == "charlie"
+
+
+@pytest.mark.anyio
+async def test_desktop(monkeypatch):
+ """Test the desktop server"""
+ from pathlib import Path
+
+ from pydantic import AnyUrl
+
+ from examples.fastmcp.desktop import mcp
+
+ # Mock desktop directory listing
+ mock_files = [Path("/fake/path/file1.txt"), Path("/fake/path/file2.txt")]
+ monkeypatch.setattr(Path, "iterdir", lambda self: mock_files)
+ monkeypatch.setattr(Path, "home", lambda: Path("/fake/home"))
+
+ async with client_session(mcp._mcp_server) as client:
+ # Test the add function
+ result = await client.call_tool("add", {"a": 1, "b": 2})
+ assert len(result.content) == 1
+ content = result.content[0]
+ assert isinstance(content, TextContent)
+ assert content.text == "3"
+
+ # Test the desktop resource
+ result = await client.read_resource(AnyUrl("dir://desktop"))
+ assert len(result.contents) == 1
+ content = result.contents[0]
+ assert isinstance(content, TextResourceContents)
+ assert isinstance(content.text, str)
+ assert "/fake/path/file1.txt" in content.text
+ assert "/fake/path/file2.txt" in content.text
diff --git a/tests/test_types.py b/tests/test_types.py
index c3981ad3e..a39d33412 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -1,3 +1,5 @@
+import pytest
+
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientRequest,
@@ -6,7 +8,8 @@
)
-def test_jsonrpc_request():
+@pytest.mark.anyio
+async def test_jsonrpc_request():
json_data = {
"jsonrpc": "2.0",
"id": 1,
diff --git a/uv.lock b/uv.lock
index db4dbc7f6..e05eee46f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -38,20 +38,20 @@ wheels = [
[[package]]
name = "attrs"
-version = "24.2.0"
+version = "24.3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 }
+sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 },
+ { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 },
]
[[package]]
name = "certifi"
-version = "2024.8.30"
+version = "2024.12.14"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
+ { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 },
]
[[package]]
@@ -103,6 +103,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
]
+[[package]]
+name = "execnet"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 },
+]
+
[[package]]
name = "h11"
version = "0.14.0"
@@ -168,23 +177,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
+]
+
[[package]]
name = "mcp"
-version = "1.1.2.dev0"
+version = "1.2.0.dev0"
source = { editable = "." }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "pydantic" },
+ { name = "pydantic-settings" },
{ name = "sse-starlette" },
{ name = "starlette" },
+ { name = "uvicorn" },
+]
+
+[package.optional-dependencies]
+cli = [
+ { name = "python-dotenv" },
+ { name = "typer" },
+]
+rich = [
+ { name = "rich" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
+ { name = "pytest-flakefinder" },
+ { name = "pytest-xdist" },
{ name = "ruff" },
{ name = "trio" },
]
@@ -194,16 +228,23 @@ requires-dist = [
{ name = "anyio", specifier = ">=4.5" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" },
- { name = "pydantic", specifier = ">=2.7.2" },
+ { name = "pydantic", specifier = ">=2.10.1,<3.0.0" },
+ { name = "pydantic-settings", specifier = ">=2.6.1" },
+ { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
+ { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" },
{ name = "sse-starlette", specifier = ">=1.6.1" },
{ name = "starlette", specifier = ">=0.27" },
+ { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" },
+ { name = "uvicorn", specifier = ">=0.30" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.378" },
{ name = "pytest", specifier = ">=8.3.3" },
- { name = "ruff", specifier = ">=0.6.9" },
+ { name = "pytest-flakefinder", specifier = ">=1.1.0" },
+ { name = "pytest-xdist", specifier = ">=3.6.1" },
+ { name = "ruff", specifier = ">=0.8.1" },
{ name = "trio", specifier = ">=0.26.2" },
]
@@ -306,6 +347,15 @@ dev = [
{ name = "ruff", specifier = ">=0.6.9" },
]
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
+]
+
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -356,73 +406,113 @@ wheels = [
[[package]]
name = "pydantic"
-version = "2.7.2"
+version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/ed/b7a827705eb2490ffb6752a0302e58611ea743d7140e7dafaedee2afc953/pydantic-2.7.2.tar.gz", hash = "sha256:71b2945998f9c9b7919a45bde9a50397b289937d215ae141c1d0903ba7149fd7", size = 714293 }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/bd/7fc610993f616d2398958d0028d15eaf53bde5f80cb2edb7aa4f1feaf3a7/pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560", size = 783717 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/b9/ec44b1394957d5aa8d3a7c33f8304cd7670d10a43a286db56cec086346be/pydantic-2.7.2-py3-none-any.whl", hash = "sha256:834ab954175f94e6e68258537dc49402c4a5e9d0409b9f1b86b7e934a8372de7", size = 409545 },
+ { url = "https://files.pythonhosted.org/packages/e0/fc/fda48d347bd50a788dd2a0f318a52160f911b86fc2d8b4c86f4d7c9bceea/pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e", size = 455329 },
]
[[package]]
name = "pydantic-core"
-version = "2.18.3"
+version = "2.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/38/75/6da10bfa9a317884a7b4bf0c42297aca72391ad69eb51b974bded53fddc0/pydantic_core-2.18.3.tar.gz", hash = "sha256:432e999088d85c8f36b9a3f769a8e2b57aabd817bbb729a90d1fe7f18f6f1f39", size = 384545 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/82/ff/61c330412137b46a55b2269d0a49fd8b90e29fb57b72760b8e09b49db896/pydantic_core-2.18.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:744697428fcdec6be5670460b578161d1ffe34743a5c15656be7ea82b008197c", size = 1832602 },
- { url = "https://files.pythonhosted.org/packages/7d/3d/1640253d1da28910b02b00bf6af4a80f1de27f561879128f76bbacb8436d/pydantic_core-2.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b40c05ced1ba4218b14986fe6f283d22e1ae2ff4c8e28881a70fb81fbfcda7", size = 1752322 },
- { url = "https://files.pythonhosted.org/packages/97/1f/0d18bac0a38f8f407c219d1b558e959efc94297c1f23810dba64a64624cc/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a9a75622357076efb6b311983ff190fbfb3c12fc3a853122b34d3d358126c", size = 1776174 },
- { url = "https://files.pythonhosted.org/packages/94/ea/ce0d90ff9a623e0fe8916bfd89b5fa49b2193493965e7a7787459c1ccb7c/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2e253af04ceaebde8eb201eb3f3e3e7e390f2d275a88300d6a1959d710539e2", size = 1767064 },
- { url = "https://files.pythonhosted.org/packages/29/23/13b0fb2419b6d21e5f0b7292e6c09720e913b068a441df32cf8cbbc16133/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:855ec66589c68aa367d989da5c4755bb74ee92ccad4fdb6af942c3612c067e34", size = 1964644 },
- { url = "https://files.pythonhosted.org/packages/97/44/22afcd3b8650e157c87d20b73f8a27c25f4f0f240bdc9eb5248bbcdc6f30/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d3e42bb54e7e9d72c13ce112e02eb1b3b55681ee948d748842171201a03a98a", size = 2815869 },
- { url = "https://files.pythonhosted.org/packages/a0/27/aeade6d7b2f2bcc8fc835bdf6aa705f6f34508da380f170e13cd37477dd4/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6ac9ffccc9d2e69d9fba841441d4259cb668ac180e51b30d3632cd7abca2b9b", size = 2028872 },
- { url = "https://files.pythonhosted.org/packages/b1/0e/a8a462fade9a9a533a9379da246e3fe7d9383c5203b6f6862a54284ea744/pydantic_core-2.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c56eca1686539fa0c9bda992e7bd6a37583f20083c37590413381acfc5f192d6", size = 1891477 },
- { url = "https://files.pythonhosted.org/packages/35/1b/63c24026c6207b5aa5cd749af319891b5ac3139e2b5dd789bf4a9e95085e/pydantic_core-2.18.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:17954d784bf8abfc0ec2a633108207ebc4fa2df1a0e4c0c3ccbaa9bb01d2c426", size = 1996291 },
- { url = "https://files.pythonhosted.org/packages/79/34/05139583ecef8b5a0f5be8105b6b001016e054bcf63ac96a03790c4a790d/pydantic_core-2.18.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:98ed737567d8f2ecd54f7c8d4f8572ca7c7921ede93a2e52939416170d357812", size = 2097593 },
- { url = "https://files.pythonhosted.org/packages/a3/e3/0b53c3b8e71be2db70eb7bfb6811bd6d093aa41fdb1ccc9f7ea18b40287b/pydantic_core-2.18.3-cp310-none-win32.whl", hash = "sha256:9f9e04afebd3ed8c15d67a564ed0a34b54e52136c6d40d14c5547b238390e779", size = 1700454 },
- { url = "https://files.pythonhosted.org/packages/e2/67/85ee8a54220139159b14088dd40f4d43e60822f8d64bb2a5b9b04d673bd2/pydantic_core-2.18.3-cp310-none-win_amd64.whl", hash = "sha256:45e4ffbae34f7ae30d0047697e724e534a7ec0a82ef9994b7913a412c21462a0", size = 1889156 },
- { url = "https://files.pythonhosted.org/packages/4a/cf/2847167bab3e7676ba6f0b49963ba04112b1e4281d8c70e302c2fd29e08c/pydantic_core-2.18.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b9ebe8231726c49518b16b237b9fe0d7d361dd221302af511a83d4ada01183ab", size = 1831516 },
- { url = "https://files.pythonhosted.org/packages/0c/84/a14457b3cb1ec1f5d1567395abe11ab420dd76733bc79dd0124a874e9eac/pydantic_core-2.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8e20e15d18bf7dbb453be78a2d858f946f5cdf06c5072453dace00ab652e2b2", size = 1751781 },
- { url = "https://files.pythonhosted.org/packages/9a/a5/5c1d98cdba8e6b2fda1975dcdb59cd608257eee69637deca22389ca16a54/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0d9ff283cd3459fa0bf9b0256a2b6f01ac1ff9ffb034e24457b9035f75587cb", size = 1775332 },
- { url = "https://files.pythonhosted.org/packages/f7/27/83d6903b1eb5ac5db67acf7be1b397c962acba1bbb27bc4fa6af4b4e82bb/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f7ef5f0ebb77ba24c9970da18b771711edc5feaf00c10b18461e0f5f5949231", size = 1766532 },
- { url = "https://files.pythonhosted.org/packages/41/83/db99c69d1f3bf71b0771d7233ac65722ba24ebc39b76b4f168da735726e0/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73038d66614d2e5cde30435b5afdced2b473b4c77d4ca3a8624dd3e41a9c19be", size = 1964053 },
- { url = "https://files.pythonhosted.org/packages/49/78/daf71cbf3b3bc1605bc750b37c5e70dff985b676fd66ac7427b8fb730dc7/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6afd5c867a74c4d314c557b5ea9520183fadfbd1df4c2d6e09fd0d990ce412cd", size = 2814359 },
- { url = "https://files.pythonhosted.org/packages/08/6b/391098a7f0863b5e54c60244c069acfca969af56af4eb7cf52e08b009560/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd7df92f28d351bb9f12470f4c533cf03d1b52ec5a6e5c58c65b183055a60106", size = 2028398 },
- { url = "https://files.pythonhosted.org/packages/41/f5/cf4a616568dddd85c71bf8b4bdc492c41c1af6eb9b0fc87e8835fd63447c/pydantic_core-2.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:80aea0ffeb1049336043d07799eace1c9602519fb3192916ff525b0287b2b1e4", size = 1891053 },
- { url = "https://files.pythonhosted.org/packages/44/2e/ebdc3f4deb3e3bbf14f0da00394dd07074cfb2ea1431024ed0fc64be3e9c/pydantic_core-2.18.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaee40f25bba38132e655ffa3d1998a6d576ba7cf81deff8bfa189fb43fd2bbe", size = 1995808 },
- { url = "https://files.pythonhosted.org/packages/85/96/6f37b40651b3e43a3c9d0cf8419b333d1f0edc20f70171a9aa52a44d45c8/pydantic_core-2.18.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9128089da8f4fe73f7a91973895ebf2502539d627891a14034e45fb9e707e26d", size = 2096891 },
- { url = "https://files.pythonhosted.org/packages/9d/9f/d3d655c8e09eb87a5eecbb5d442205c56c9dd9acd49670386c29c430f5ea/pydantic_core-2.18.3-cp311-none-win32.whl", hash = "sha256:fec02527e1e03257aa25b1a4dcbe697b40a22f1229f5d026503e8b7ff6d2eda7", size = 1699771 },
- { url = "https://files.pythonhosted.org/packages/d2/c7/e01cb2017c4b7b274258694f73e8bbbb0988a28b49802e569d1d9bfd51cb/pydantic_core-2.18.3-cp311-none-win_amd64.whl", hash = "sha256:58ff8631dbab6c7c982e6425da8347108449321f61fe427c52ddfadd66642af7", size = 1888426 },
- { url = "https://files.pythonhosted.org/packages/fc/90/30f4755a09691f4efebc93e86c98e696e8a109db5a5b36f1d0d94311eac1/pydantic_core-2.18.3-cp311-none-win_arm64.whl", hash = "sha256:3fc1c7f67f34c6c2ef9c213e0f2a351797cda98249d9ca56a70ce4ebcaba45f4", size = 1762547 },
- { url = "https://files.pythonhosted.org/packages/77/72/3ce28b58f3d9c9a8bb59984d810be3eabba4455e92de806a4edacd4e5c0b/pydantic_core-2.18.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f0928cde2ae416a2d1ebe6dee324709c6f73e93494d8c7aea92df99aab1fc40f", size = 1826479 },
- { url = "https://files.pythonhosted.org/packages/94/bc/e5d1938f36cad75525e923ecfef6f544970d4f14800716728ea5555fc574/pydantic_core-2.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bee9bb305a562f8b9271855afb6ce00223f545de3d68560b3c1649c7c5295e9", size = 1750007 },
- { url = "https://files.pythonhosted.org/packages/20/a8/4c6eb74f4b421e9ea62e2bea42683b58ed2d43376895ecc5c376f3cc1630/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e862823be114387257dacbfa7d78547165a85d7add33b446ca4f4fae92c7ff5c", size = 1771054 },
- { url = "https://files.pythonhosted.org/packages/f6/0a/d5a1765b5000f56ee3a9659658aed4f978bb85b45bb01c0f921f2a70b511/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a36f78674cbddc165abab0df961b5f96b14461d05feec5e1f78da58808b97e7", size = 1752825 },
- { url = "https://files.pythonhosted.org/packages/0c/20/2e7da2f5cbc6f1849c6bad4ea04e8e763512f4af6250972c35d354b59ab1/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba905d184f62e7ddbb7a5a751d8a5c805463511c7b08d1aca4a3e8c11f2e5048", size = 1962656 },
- { url = "https://files.pythonhosted.org/packages/7e/bb/f01be2f91439f155f8b522259ef92099383d3d6e8df559caa26b8d21dd43/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fdd362f6a586e681ff86550b2379e532fee63c52def1c666887956748eaa326", size = 2738939 },
- { url = "https://files.pythonhosted.org/packages/c2/9f/e2f17d24aee5406a8e8e57784fa737abde9ac538d18028b523268796bcce/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b214b7ee3bd3b865e963dbed0f8bc5375f49449d70e8d407b567af3222aae4", size = 2066345 },
- { url = "https://files.pythonhosted.org/packages/59/c2/5597c61f62cef54cd3f183db5980bf7b3ee7aeb9bd9ab3458d275af33bd7/pydantic_core-2.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:691018785779766127f531674fa82bb368df5b36b461622b12e176c18e119022", size = 1886795 },
- { url = "https://files.pythonhosted.org/packages/da/b6/2e0a0a51b8fe047d985a7ee1b328d8d8fbef5be54c4870bbe21d2cb846de/pydantic_core-2.18.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:60e4c625e6f7155d7d0dcac151edf5858102bc61bf959d04469ca6ee4e8381bd", size = 1991654 },
- { url = "https://files.pythonhosted.org/packages/9c/ef/ade132a1d5a6f5bceee347b06a3853d63730d508c6e91dbd83ec44c4361e/pydantic_core-2.18.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4e651e47d981c1b701dcc74ab8fec5a60a5b004650416b4abbef13db23bc7be", size = 2098061 },
- { url = "https://files.pythonhosted.org/packages/1d/4b/a925d2ada3d8a554a362c29f1b0f60cb82db7e791e43e91a6f6bc093dacd/pydantic_core-2.18.3-cp312-none-win32.whl", hash = "sha256:ffecbb5edb7f5ffae13599aec33b735e9e4c7676ca1633c60f2c606beb17efc5", size = 1704763 },
- { url = "https://files.pythonhosted.org/packages/e3/5c/477dac00c0d6d34921fec2507ae6aea2cd7c84072eab1dca5bcbbf86c4a2/pydantic_core-2.18.3-cp312-none-win_amd64.whl", hash = "sha256:2c8333f6e934733483c7eddffdb094c143b9463d2af7e6bd85ebcb2d4a1b82c6", size = 1884445 },
- { url = "https://files.pythonhosted.org/packages/4d/f4/285df83eb0c4a8c710bf002b342a114fcd9e388946a0a35dc06f687f865d/pydantic_core-2.18.3-cp312-none-win_arm64.whl", hash = "sha256:7a20dded653e516a4655f4c98e97ccafb13753987434fe7cf044aa25f5b7d417", size = 1763753 },
- { url = "https://files.pythonhosted.org/packages/7f/6b/7bb6e75d4cb9aacca9683cb491b194e94146c6a304de5857a13e3dc0e094/pydantic_core-2.18.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:77319771a026f7c7d29c6ebc623de889e9563b7087911b46fd06c044a12aa5e9", size = 1830389 },
- { url = "https://files.pythonhosted.org/packages/a5/e6/a3775ca64d41a9cfd2ff57f1322e5e9cec12809f87c58f09d3c4d468d6db/pydantic_core-2.18.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:df11fa992e9f576473038510d66dd305bcd51d7dd508c163a8c8fe148454e059", size = 1711345 },
- { url = "https://files.pythonhosted.org/packages/30/64/b6a46b84f1237511aaeb8e73b3b357bdb34f63c958b92a483c7abdfe6b73/pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d531076bdfb65af593326ffd567e6ab3da145020dafb9187a1d131064a55f97c", size = 1774898 },
- { url = "https://files.pythonhosted.org/packages/ac/ca/0fd2e3849cd6b87b08fa9676dec86bf33c6c9fbc80af2247b0120dbfae80/pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33ce258e4e6e6038f2b9e8b8a631d17d017567db43483314993b3ca345dcbbb", size = 1923112 },
- { url = "https://files.pythonhosted.org/packages/32/92/eab2738a19fea14f55314eca5e31d85e0680daa1d439d9a4485ba808faf2/pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1f9cd7f5635b719939019be9bda47ecb56e165e51dd26c9a217a433e3d0d59a9", size = 1888546 },
- { url = "https://files.pythonhosted.org/packages/fe/85/32c6733055194d624b1a03c1ae6fee4121c1ecac99d87a63a9911eac7d65/pydantic_core-2.18.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cd4a032bb65cc132cae1fe3e52877daecc2097965cd3914e44fbd12b00dae7c5", size = 1994693 },
- { url = "https://files.pythonhosted.org/packages/50/67/ff5701b8f54842f9485d2b27455a4911d99b662ceb44ca81e5e26c9421a9/pydantic_core-2.18.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f2718430098bcdf60402136c845e4126a189959d103900ebabb6774a5d9fdb", size = 2094391 },
- { url = "https://files.pythonhosted.org/packages/af/d1/1c18f8e215930665e65597dd677937595355057f631bf4b9110aa6f88f79/pydantic_core-2.18.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0037a92cf0c580ed14e10953cdd26528e8796307bb8bb312dc65f71547df04d", size = 1898163 },
+sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/ce/60fd96895c09738648c83f3f00f595c807cb6735c70d3306b548cc96dd49/pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", size = 1897984 },
+ { url = "https://files.pythonhosted.org/packages/fd/b9/84623d6b6be98cc209b06687d9bca5a7b966ffed008d15225dd0d20cce2e/pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b", size = 1807491 },
+ { url = "https://files.pythonhosted.org/packages/01/72/59a70165eabbc93b1111d42df9ca016a4aa109409db04304829377947028/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", size = 1831953 },
+ { url = "https://files.pythonhosted.org/packages/7c/0c/24841136476adafd26f94b45bb718a78cb0500bd7b4f8d667b67c29d7b0d/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", size = 1856071 },
+ { url = "https://files.pythonhosted.org/packages/53/5e/c32957a09cceb2af10d7642df45d1e3dbd8596061f700eac93b801de53c0/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", size = 2038439 },
+ { url = "https://files.pythonhosted.org/packages/e4/8f/979ab3eccd118b638cd6d8f980fea8794f45018255a36044dea40fe579d4/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", size = 2787416 },
+ { url = "https://files.pythonhosted.org/packages/02/1d/00f2e4626565b3b6d3690dab4d4fe1a26edd6a20e53749eb21ca892ef2df/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", size = 2134548 },
+ { url = "https://files.pythonhosted.org/packages/9d/46/3112621204128b90898adc2e721a3cd6cf5626504178d6f32c33b5a43b79/pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", size = 1989882 },
+ { url = "https://files.pythonhosted.org/packages/49/ec/557dd4ff5287ffffdf16a31d08d723de6762bb1b691879dc4423392309bc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", size = 1995829 },
+ { url = "https://files.pythonhosted.org/packages/6e/b2/610dbeb74d8d43921a7234555e4c091cb050a2bdb8cfea86d07791ce01c5/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", size = 2091257 },
+ { url = "https://files.pythonhosted.org/packages/8c/7f/4bf8e9d26a9118521c80b229291fa9558a07cdd9a968ec2d5c1026f14fbc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", size = 2143894 },
+ { url = "https://files.pythonhosted.org/packages/1f/1c/875ac7139c958f4390f23656fe696d1acc8edf45fb81e4831960f12cd6e4/pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", size = 1816081 },
+ { url = "https://files.pythonhosted.org/packages/d7/41/55a117acaeda25ceae51030b518032934f251b1dac3704a53781383e3491/pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", size = 1981109 },
+ { url = "https://files.pythonhosted.org/packages/27/39/46fe47f2ad4746b478ba89c561cafe4428e02b3573df882334bd2964f9cb/pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", size = 1895553 },
+ { url = "https://files.pythonhosted.org/packages/1c/00/0804e84a78b7fdb394fff4c4f429815a10e5e0993e6ae0e0b27dd20379ee/pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", size = 1807220 },
+ { url = "https://files.pythonhosted.org/packages/01/de/df51b3bac9820d38371f5a261020f505025df732ce566c2a2e7970b84c8c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", size = 1829727 },
+ { url = "https://files.pythonhosted.org/packages/5f/d9/c01d19da8f9e9fbdb2bf99f8358d145a312590374d0dc9dd8dbe484a9cde/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", size = 1854282 },
+ { url = "https://files.pythonhosted.org/packages/5f/84/7db66eb12a0dc88c006abd6f3cbbf4232d26adfd827a28638c540d8f871d/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", size = 2037437 },
+ { url = "https://files.pythonhosted.org/packages/34/ac/a2537958db8299fbabed81167d58cc1506049dba4163433524e06a7d9f4c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", size = 2780899 },
+ { url = "https://files.pythonhosted.org/packages/4a/c1/3e38cd777ef832c4fdce11d204592e135ddeedb6c6f525478a53d1c7d3e5/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", size = 2135022 },
+ { url = "https://files.pythonhosted.org/packages/7a/69/b9952829f80fd555fe04340539d90e000a146f2a003d3fcd1e7077c06c71/pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", size = 1987969 },
+ { url = "https://files.pythonhosted.org/packages/05/72/257b5824d7988af43460c4e22b63932ed651fe98804cc2793068de7ec554/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", size = 1994625 },
+ { url = "https://files.pythonhosted.org/packages/73/c3/78ed6b7f3278a36589bcdd01243189ade7fc9b26852844938b4d7693895b/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", size = 2090089 },
+ { url = "https://files.pythonhosted.org/packages/8d/c8/b4139b2f78579960353c4cd987e035108c93a78371bb19ba0dc1ac3b3220/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", size = 2142496 },
+ { url = "https://files.pythonhosted.org/packages/3e/f8/171a03e97eb36c0b51981efe0f78460554a1d8311773d3d30e20c005164e/pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", size = 1811758 },
+ { url = "https://files.pythonhosted.org/packages/6a/fe/4e0e63c418c1c76e33974a05266e5633e879d4061f9533b1706a86f77d5b/pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", size = 1980864 },
+ { url = "https://files.pythonhosted.org/packages/50/fc/93f7238a514c155a8ec02fc7ac6376177d449848115e4519b853820436c5/pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", size = 1864327 },
+ { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 },
+ { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 },
+ { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 },
+ { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 },
+ { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 },
+ { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 },
+ { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 },
+ { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 },
+ { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 },
+ { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 },
+ { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 },
+ { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 },
+ { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 },
+ { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 },
+ { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 },
+ { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 },
+ { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 },
+ { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 },
+ { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 },
+ { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 },
+ { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 },
+ { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 },
+ { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 },
+ { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 },
+ { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 },
+ { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 },
+ { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 },
+ { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 },
+ { url = "https://files.pythonhosted.org/packages/7c/60/e5eb2d462595ba1f622edbe7b1d19531e510c05c405f0b87c80c1e89d5b1/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", size = 1894016 },
+ { url = "https://files.pythonhosted.org/packages/61/20/da7059855225038c1c4326a840908cc7ca72c7198cb6addb8b92ec81c1d6/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", size = 1771648 },
+ { url = "https://files.pythonhosted.org/packages/8f/fc/5485cf0b0bb38da31d1d292160a4d123b5977841ddc1122c671a30b76cfd/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", size = 1826929 },
+ { url = "https://files.pythonhosted.org/packages/a1/ff/fb1284a210e13a5f34c639efc54d51da136074ffbe25ec0c279cf9fbb1c4/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", size = 1980591 },
+ { url = "https://files.pythonhosted.org/packages/f1/14/77c1887a182d05af74f6aeac7b740da3a74155d3093ccc7ee10b900cc6b5/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", size = 1981326 },
+ { url = "https://files.pythonhosted.org/packages/06/aa/6f1b2747f811a9c66b5ef39d7f02fbb200479784c75e98290d70004b1253/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", size = 1989205 },
+ { url = "https://files.pythonhosted.org/packages/7a/d2/8ce2b074d6835f3c88d85f6d8a399790043e9fdb3d0e43455e72d19df8cc/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", size = 2079616 },
+ { url = "https://files.pythonhosted.org/packages/65/71/af01033d4e58484c3db1e5d13e751ba5e3d6b87cc3368533df4c50932c8b/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", size = 2133265 },
+ { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 },
+]
+
+[[package]]
+name = "pygments"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 },
]
[[package]]
@@ -454,29 +544,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
]
+[[package]]
+name = "pytest-flakefinder"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644 },
+]
+
+[[package]]
+name = "pytest-xdist"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "execnet" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 },
+]
+
+[[package]]
+name = "rich"
+version = "13.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
+]
+
[[package]]
name = "ruff"
-version = "0.6.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526 },
- { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612 },
- { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197 },
- { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855 },
- { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889 },
- { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678 },
- { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682 },
- { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446 },
- { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048 },
- { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855 },
- { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007 },
- { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594 },
- { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024 },
- { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085 },
- { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088 },
- { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275 },
- { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879 },
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/d0/8ff5b189d125f4260f2255d143bf2fa413b69c2610c405ace7a0a8ec81ec/ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f", size = 3313222 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/d6/1a6314e568db88acdbb5121ed53e2c52cebf3720d3437a76f82f923bf171/ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5", size = 10532605 },
+ { url = "https://files.pythonhosted.org/packages/89/a8/a957a8812e31facffb6a26a30be0b5b4af000a6e30c7d43a22a5232a3398/ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087", size = 10278243 },
+ { url = "https://files.pythonhosted.org/packages/a8/23/9db40fa19c453fabf94f7a35c61c58f20e8200b4734a20839515a19da790/ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209", size = 9917739 },
+ { url = "https://files.pythonhosted.org/packages/e2/a0/6ee2d949835d5701d832fc5acd05c0bfdad5e89cfdd074a171411f5ccad5/ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871", size = 10779153 },
+ { url = "https://files.pythonhosted.org/packages/7a/25/9c11dca9404ef1eb24833f780146236131a3c7941de394bc356912ef1041/ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1", size = 10304387 },
+ { url = "https://files.pythonhosted.org/packages/c8/b9/84c323780db1b06feae603a707d82dbbd85955c8c917738571c65d7d5aff/ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5", size = 11360351 },
+ { url = "https://files.pythonhosted.org/packages/6b/e1/9d4bbb2ace7aad14ded20e4674a48cda5b902aed7a1b14e6b028067060c4/ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d", size = 12022879 },
+ { url = "https://files.pythonhosted.org/packages/75/28/752ff6120c0e7f9981bc4bc275d540c7f36db1379ba9db9142f69c88db21/ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26", size = 11610354 },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/967b61c2cc8ebd1df877607fbe462bc1e1220b4a30ae3352648aec8c24bd/ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1", size = 12813976 },
+ { url = "https://files.pythonhosted.org/packages/7f/29/e059f945d6bd2d90213387b8c360187f2fefc989ddcee6bbf3c241329b92/ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c", size = 11154564 },
+ { url = "https://files.pythonhosted.org/packages/55/47/cbd05e5a62f3fb4c072bc65c1e8fd709924cad1c7ec60a1000d1e4ee8307/ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa", size = 10760604 },
+ { url = "https://files.pythonhosted.org/packages/bb/ee/4c3981c47147c72647a198a94202633130cfda0fc95cd863a553b6f65c6a/ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540", size = 10391071 },
+ { url = "https://files.pythonhosted.org/packages/6b/e6/083eb61300214590b188616a8ac6ae1ef5730a0974240fb4bec9c17de78b/ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9", size = 10896657 },
+ { url = "https://files.pythonhosted.org/packages/77/bd/aacdb8285d10f1b943dbeb818968efca35459afc29f66ae3bd4596fbf954/ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5", size = 11228362 },
+ { url = "https://files.pythonhosted.org/packages/39/72/fcb7ad41947f38b4eaa702aca0a361af0e9c2bf671d7fd964480670c297e/ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790", size = 8803476 },
+ { url = "https://files.pythonhosted.org/packages/e4/ea/cae9aeb0f4822c44651c8407baacdb2e5b4dcd7b31a84e1c5df33aa2cc20/ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6", size = 9614463 },
+ { url = "https://files.pythonhosted.org/packages/eb/76/fbb4bd23dfb48fa7758d35b744413b650a9fd2ddd93bca77e30376864414/ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737", size = 8959621 },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
@@ -578,6 +725,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/70/efa56ce2271c44a7f4f43533a0477e6854a0948e9f7b76491de1fd3be7c9/trio-0.26.2-py3-none-any.whl", hash = "sha256:c5237e8133eb0a1d72f09a971a55c28ebe69e351c783fc64bc37db8db8bbe1d0", size = 475996 },
]
+[[package]]
+name = "typer"
+version = "0.12.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/f7/f174a1cae84848ae8b27170a96187b91937b743f0580ff968078fe16930a/typer-0.12.4.tar.gz", hash = "sha256:c9c1613ed6a166162705b3347b8d10b661ccc5d95692654d0fb628118f2c34e6", size = 97945 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/cc/15083dcde1252a663398b1b2a173637a3ec65adadfb95137dc95df1e6adc/typer-0.12.4-py3-none-any.whl", hash = "sha256:819aa03699f438397e876aa12b0d63766864ecba1b579092cc9fe35d886e34b6", size = 47402 },
+]
+
[[package]]
name = "typing-extensions"
version = "4.12.2"
@@ -586,3 +748,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
+
+[[package]]
+name = "uvicorn"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/f7/4ad826703a49b320a4adf2470fdd2a3481ea13f4460cb615ad12c75be003/uvicorn-0.30.0.tar.gz", hash = "sha256:f678dec4fa3a39706bbf49b9ec5fc40049d42418716cea52b53f07828a60aa37", size = 42560 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/a1/d57e38417a8dabb22df02b6aebc209dc73485792e6c5620e501547133d0b/uvicorn-0.30.0-py3-none-any.whl", hash = "sha256:78fa0b5f56abb8562024a59041caeb555c86e48d0efdd23c3fe7de7a4075bdab", size = 62388 },
+]