diff --git a/dev_docs/fastmcp-documentation.txt b/dev_docs/fastmcp-documentation.txt index ce36a08..f96f971 100644 --- a/dev_docs/fastmcp-documentation.txt +++ b/dev_docs/fastmcp-documentation.txt @@ -1,108 +1,54 @@ Directory structure: -└── jlowin-fastmcp +└── jlowin-fastmcp/ + ├── docs/ + │ └── assets/ + ├── .pre-commit-config.yaml + ├── tests/ + ├── Windows_Notes.md + ├── .python-version ├── pyproject.toml - ├── docs - │ └── assets - ├── README.md - ├── examples - │ ├── simple_echo.py - │ ├── memory.py - │ ├── readme-quickstart.py + ├── examples/ │ ├── text_me.py + │ ├── readme-quickstart.py + │ ├── desktop.py │ ├── screenshot.py + │ ├── memory.py + │ ├── complex_inputs.py │ ├── echo.py - │ ├── desktop.py - │ └── complex_inputs.py - ├── Windows_Notes.md - └── src - └── fastmcp - ├── server.py - ├── tools - │ ├── tool_manager.py - │ ├── __init__.py - │ └── base.py - ├── resources - │ ├── resource_manager.py + │ └── simple_echo.py + ├── LICENSE + ├── uv.lock + ├── README.md + └── src/ + └── fastmcp/ + ├── utilities/ │ ├── __init__.py │ ├── types.py + │ ├── logging.py + │ └── func_metadata.py + ├── resources/ │ ├── templates.py - │ └── base.py - ├── __init__.py - ├── cli - │ ├── claude.py │ ├── __init__.py - │ └── cli.py - ├── utilities - │ ├── logging.py - │ ├── func_metadata.py + │ ├── base.py + │ ├── types.py + │ └── resource_manager.py + ├── exceptions.py + ├── cli/ │ ├── __init__.py - │ └── types.py - ├── prompts - │ ├── prompt_manager.py + │ ├── cli.py + │ └── claude.py + ├── __init__.py + ├── tools/ │ ├── __init__.py - │ ├── manager.py + │ ├── tool_manager.py │ └── base.py - ├── py.typed - └── exceptions.py - -================================================ -File: /pyproject.toml -================================================ -[project] -name = "fastmcp" -dynamic = ["version"] -description = "A more ergonomic interface for MCP servers" -authors = [{ name = "Jeremiah Lowin" }] -dependencies = [ - "httpx>=0.26.0", - "mcp>=1.0.0,<2.0.0", - "pydantic-settings>=2.6.1", - "pydantic>=2.5.3,<3.0.0", - "typer>=0.9.0", - "python-dotenv>=1.0.1", -] -requires-python = ">=3.10" -readme = "README.md" -license = { text = "MIT" } - -[project.scripts] -fastmcp = "fastmcp.cli:app" - -[build-system] -requires = ["hatchling>=1.21.0", "hatch-vcs>=0.4.0"] -build-backend = "hatchling.build" - -[project.optional-dependencies] -tests = [ - "pre-commit", - "pyright>=1.1.389", - "pytest>=8.3.3", - "pytest-asyncio>=0.23.5", - "pytest-flakefinder", - "pytest-xdist>=3.6.1", - "ruff", -] -dev = ["fastmcp[tests]", "copychat>=0.5.2", "ipython>=8.12.3", "pdbpp>=0.10.3"] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" - -[tool.hatch.version] -source = "vcs" - -[tool.pyright] -include = ["src", "tests"] -exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"] -pythonVersion = "3.10" -pythonPlatform = "Darwin" -typeCheckingMode = "basic" -reportMissingImports = true -reportMissingTypeStubs = false -useLibraryCodeForTypes = true -venvPath = "." -venv = ".venv" - + ├── prompts/ + │ ├── prompt_manager.py + │ ├── __init__.py + │ ├── base.py + │ └── manager.py + ├── server.py + └── py.typed ================================================ File: /README.md @@ -663,387 +609,245 @@ Feel free to reach out in a GitHub issue or discussion if you have any questions ================================================ -File: /examples/simple_echo.py +File: /.pre-commit-config.yaml ================================================ -""" -FastMCP Echo Server -""" +fail_fast: true -from fastmcp import FastMCP - - -# Create server -mcp = FastMCP("Echo Server") +repos: + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.23 + hooks: + - id: validate-pyproject + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + types_or: [yaml, json5] -@mcp.tool() -def echo(text: str) -> str: - """Echo the input text""" - return text + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff-format + - id: ruff + args: [--fix, --exit-non-zero-on-fix] ================================================ -File: /examples/memory.py +File: /Windows_Notes.md ================================================ -# /// script -# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"] -# /// - -# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp +# Getting your development environment set up properly +To get your environment up and running properly, you'll need a slightly different set of commands that are windows specific: +```bash +uv venv +.venv\Scripts\activate +uv pip install -e ".[dev]" +``` -""" -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. -""" +This will install the package in editable mode, and install the development dependencies. -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 +# Fixing `AttributeError: module 'collections' has no attribute 'Callable'` +- open `.venv\Lib\site-packages\pyreadline\py3k_compat.py` +- change `return isinstance(x, collections.Callable)` to +``` +from collections.abc import Callable +return isinstance(x, Callable) +``` -from fastmcp import FastMCP +# Helpful notes +For developing FastMCP +## Install local development version of FastMCP into a local FastMCP project server +- ensure +- change directories to your FastMCP Server location so you can install it in your .venv +- run `.venv\Scripts\activate` to activate your virtual environment +- Then run a series of commands to uninstall the old version and install the new +```bash +# First uninstall +uv pip uninstall fastmcp -MAX_DEPTH = 5 -SIMILARITY_THRESHOLD = 0.7 -DECAY_FACTOR = 0.99 -REINFORCEMENT_FACTOR = 1.1 +# Clean any build artifacts in your fastmcp directory +cd C:\path\to\fastmcp +del /s /q *.egg-info -DEFAULT_LLM_MODEL = "openai:gpt-4o" -DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" +# Then reinstall in your weather project +cd C:\path\to\new\fastmcp_server +uv pip install --no-cache-dir -e C:\Users\justj\PycharmProjects\fastmcp -mcp = FastMCP( - "memory", - dependencies=[ - "pydantic-ai-slim[openai]", - "asyncpg", - "numpy", - "pgvector", - ], -) +# Check that it installed properly and has the correct git hash +pip show fastmcp +``` -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) +## Running the FastMCP server with Inspector +MCP comes with a node.js application called Inspector that can be used to inspect the FastMCP server. To run the inspector, you'll need to install node.js and npm. Then you can run the following commands: +```bash +fastmcp dev server.py +``` +This will launch a web app on http://localhost:5173/ that you can use to inspect the FastMCP server. +## If you start development before creating a fork - your get out of jail free card +- Add your fork as a new remote to your local repository `git remote add fork git@github.com:YOUR-USERNAME/REPOSITORY-NAME.git` + - This will add your repo, short named 'fork', as a remote to your local repository +- Verify that it was added correctly by running `git remote -v` +- Commit your changes +- Push your changes to your fork `git push fork ` +- Create your pull request on GitHub -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 +================================================ +File: /.python-version +================================================ +3.12 -@dataclass -class Deps: - openai: AsyncOpenAI - pool: asyncpg.Pool +================================================ +File: /pyproject.toml +================================================ +[project] +name = "fastmcp" +dynamic = ["version"] +description = "A more ergonomic interface for MCP servers" +authors = [{ name = "Jeremiah Lowin" }] +dependencies = [ + "httpx>=0.26.0", + "mcp>=1.0.0,<2.0.0", + "pydantic-settings>=2.6.1", + "pydantic>=2.5.3,<3.0.0", + "typer>=0.9.0", + "python-dotenv>=1.0.1", +] +requires-python = ">=3.10" +readme = "README.md" +license = { text = "MIT" } -async def get_db_pool() -> asyncpg.Pool: - async def init(conn): - await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") - await register_vector(conn) +[project.scripts] +fastmcp = "fastmcp.cli:app" - pool = await asyncpg.create_pool(DB_DSN, init=init) - return pool +[build-system] +requires = ["hatchling>=1.21.0", "hatch-vcs>=0.4.0"] +build-backend = "hatchling.build" +[project.optional-dependencies] +tests = [ + "pre-commit", + "pyright>=1.1.389", + "pytest>=8.3.3", + "pytest-asyncio>=0.23.5", + "pytest-flakefinder", + "pytest-xdist>=3.6.1", + "ruff", +] +dev = ["fastmcp[tests]", "copychat>=0.5.2", "ipython>=8.12.3", "pdbpp>=0.10.3"] -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) +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" - 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, - ) +[tool.hatch.version] +source = "vcs" - 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) +[tool.pyright] +include = ["src", "tests"] +exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"] +pythonVersion = "3.10" +pythonPlatform = "Darwin" +typeCheckingMode = "basic" +reportMissingImports = true +reportMissingTypeStubs = false +useLibraryCodeForTypes = true +venvPath = "." +venv = ".venv" - def get_effective_importance(self): - return self.importance * (1 + math.log(self.access_count + 1)) +================================================ +File: /examples/text_me.py +================================================ +# /// script +# dependencies = ["fastmcp"] +# /// -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 +""" +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: -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) +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. +""" -async def add_memory(content: str, deps: Deps): - new_memory = await MemoryNode.from_content(content, deps) - await new_memory.save(deps) +from typing import Annotated +import httpx +from pydantic import BeforeValidator +from pydantic_settings import BaseSettings, SettingsConfigDict - 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) +from fastmcp import FastMCP - await update_importance(new_memory.embedding, deps) - await prune_memories(deps) +class SurgeSettings(BaseSettings): + model_config: SettingsConfigDict = SettingsConfigDict( + env_prefix="SURGE_", env_file=".env" + ) - return f"Remembered: {content}" + 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 -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 +# Create server +mcp = FastMCP("Text me") +surge_settings = SurgeSettings() # type: ignore -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" +@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, + } + }, + }, ) - 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"], - ) + response.raise_for_status() + return f"Message sent: {text_content}" -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"]) +================================================ +File: /examples/readme-quickstart.py +================================================ +from fastmcp import FastMCP -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 +# Create an MCP server +mcp = FastMCP("Demo") -@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()) - - -================================================ -File: /examples/readme-quickstart.py -================================================ -from fastmcp import FastMCP - - -# Create an MCP server -mcp = FastMCP("Demo") - - -# Add an addition tool +# Add an addition tool @mcp.tool() def add(a: int, b: int) -> int: """Add two numbers""" @@ -1058,79 +862,33 @@ def get_greeting(name: str) -> str: ================================================ -File: /examples/text_me.py +File: /examples/desktop.py ================================================ -# /// script -# dependencies = ["fastmcp"] -# /// - """ -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=... +FastMCP Desktop Example -Visit https://surgemsg.com/ and click "Get Started" to obtain these values. +A simple example that exposes the desktop directory as a resource. """ -from typing import Annotated -import httpx -from pydantic import BeforeValidator -from pydantic_settings import BaseSettings, SettingsConfigDict - -from fastmcp import FastMCP - +from pathlib import Path -class SurgeSettings(BaseSettings): - model_config: SettingsConfigDict = SettingsConfigDict( - env_prefix="SURGE_", env_file=".env" - ) +from fastmcp.server import FastMCP - 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("Demo") -# Create server -mcp = FastMCP("Text me") -surge_settings = SurgeSettings() # type: ignore +@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(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}" +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b ================================================ @@ -1167,68 +925,354 @@ def take_screenshot() -> Image: ================================================ -File: /examples/echo.py +File: /examples/memory.py ================================================ +# /// script +# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"] +# /// + +# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp + """ -FastMCP Echo Server +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. """ -from fastmcp import FastMCP +import asyncio +import math +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Self -# Create server -mcp = FastMCP("Echo Server") +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 fastmcp import FastMCP -@mcp.tool() -def echo_tool(text: str) -> str: - """Echo the input text""" - return text +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.resource("echo://static") -def echo_resource() -> str: - return "Echo!" +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) -@mcp.resource("echo://{text}") -def echo_template(text: str) -> str: - """Echo the input text""" - return f"Echo: {text}" +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) + ) -@mcp.prompt("echo") -def echo_prompt(text: str) -> str: - return text +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 -================================================ -File: /examples/desktop.py -================================================ -""" -FastMCP Desktop Example -A simple example that exposes the desktop directory as a resource. -""" +@dataclass +class Deps: + openai: AsyncOpenAI + pool: asyncpg.Pool -from pathlib import Path -from fastmcp.server import FastMCP +async def get_db_pool() -> asyncpg.Pool: + async def init(conn): + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + await register_vector(conn) -# Create server -mcp = FastMCP("Demo") + pool = await asyncpg.create_pool(DB_DSN, init=init) + return pool -@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()] +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() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b +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()) ================================================ @@ -1265,918 +1309,761 @@ def name_shrimp( ================================================ -File: /Windows_Notes.md +File: /examples/echo.py ================================================ -# Getting your development environment set up properly -To get your environment up and running properly, you'll need a slightly different set of commands that are windows specific: -```bash -uv venv -.venv\Scripts\activate -uv pip install -e ".[dev]" -``` - -This will install the package in editable mode, and install the development dependencies. +""" +FastMCP Echo Server +""" +from fastmcp import FastMCP -# Fixing `AttributeError: module 'collections' has no attribute 'Callable'` -- open `.venv\Lib\site-packages\pyreadline\py3k_compat.py` -- change `return isinstance(x, collections.Callable)` to -``` -from collections.abc import Callable -return isinstance(x, Callable) -``` +# Create server +mcp = FastMCP("Echo Server") -# Helpful notes -For developing FastMCP -## Install local development version of FastMCP into a local FastMCP project server -- ensure -- change directories to your FastMCP Server location so you can install it in your .venv -- run `.venv\Scripts\activate` to activate your virtual environment -- Then run a series of commands to uninstall the old version and install the new -```bash -# First uninstall -uv pip uninstall fastmcp -# Clean any build artifacts in your fastmcp directory -cd C:\path\to\fastmcp -del /s /q *.egg-info +@mcp.tool() +def echo_tool(text: str) -> str: + """Echo the input text""" + return text -# Then reinstall in your weather project -cd C:\path\to\new\fastmcp_server -uv pip install --no-cache-dir -e C:\Users\justj\PycharmProjects\fastmcp -# Check that it installed properly and has the correct git hash -pip show fastmcp -``` +@mcp.resource("echo://static") +def echo_resource() -> str: + return "Echo!" -## Running the FastMCP server with Inspector -MCP comes with a node.js application called Inspector that can be used to inspect the FastMCP server. To run the inspector, you'll need to install node.js and npm. Then you can run the following commands: -```bash -fastmcp dev server.py -``` -This will launch a web app on http://localhost:5173/ that you can use to inspect the FastMCP server. -## If you start development before creating a fork - your get out of jail free card -- Add your fork as a new remote to your local repository `git remote add fork git@github.com:YOUR-USERNAME/REPOSITORY-NAME.git` - - This will add your repo, short named 'fork', as a remote to your local repository -- Verify that it was added correctly by running `git remote -v` -- Commit your changes -- Push your changes to your fork `git push fork ` -- Create your pull request on GitHub +@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 ================================================ -File: /src/fastmcp/server.py +File: /examples/simple_echo.py ================================================ -"""FastMCP - A more ergonomic interface for MCP servers.""" +""" +FastMCP Echo Server +""" -import asyncio -import functools -import inspect -import json -import re -from itertools import chain -from typing import Any, Callable, Dict, Literal, Sequence, TypeVar, ParamSpec +from fastmcp import FastMCP -import pydantic_core -from pydantic import Field -import uvicorn -from mcp.server 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, - PromptArgument as MCPPromptArgument, -) -from mcp.types import ( - Resource as MCPResource, -) -from mcp.types import ( - ResourceTemplate as MCPResourceTemplate, -) -from mcp.types import ( - Tool as MCPTool, -) -from pydantic import BaseModel -from pydantic.networks import AnyUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from fastmcp.exceptions import ResourceError -from fastmcp.prompts import Prompt, PromptManager -from fastmcp.prompts.base import PromptResult -from fastmcp.resources import FunctionResource, Resource, ResourceManager -from fastmcp.tools import ToolManager -from fastmcp.utilities.logging import configure_logging, get_logger -from fastmcp.utilities.types import Image +# Create server +mcp = FastMCP("Echo Server") -logger = get_logger(__name__) -P = ParamSpec("P") -R = TypeVar("R") -R_PromptResult = TypeVar("R_PromptResult", bound=PromptResult) +@mcp.tool() +def echo(text: str) -> str: + """Echo the input text""" + return text -class Settings(BaseSettings): - """FastMCP server settings. +================================================ +File: /LICENSE +================================================ +MIT License - All settings can be configured via environment variables with the prefix FASTMCP_. - For example, FASTMCP_DEBUG=true will set debug=True. - """ +Copyright (c) 2024 Jeremiah Lowin - model_config: SettingsConfigDict = SettingsConfigDict( - env_prefix="FASTMCP_", - env_file=".env", - extra="ignore", - ) +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - # Server settings - debug: bool = False - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - # HTTP settings - host: str = "0.0.0.0" - port: int = 8000 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. - # resource settings - warn_on_duplicate_resources: bool = True - # tool settings - warn_on_duplicate_tools: bool = True +================================================ +File: /src/fastmcp/utilities/__init__.py +================================================ +"""FastMCP utility modules.""" - # 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", - ) +================================================ +File: /src/fastmcp/utilities/types.py +================================================ +"""Common types used across FastMCP.""" +import base64 +from pathlib import Path +from typing import Optional, Union -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 +from mcp.types import ImageContent - # Set up MCP protocol handlers - self._setup_handlers() - # Configure logging - configure_logging(self.settings.log_level) +class Image: + """Helper class for returning images from tools.""" - @property - def name(self) -> str: - return self._mcp_server.name + def __init__( + self, + path: Optional[Union[str, Path]] = None, + data: Optional[bytes] = None, + format: Optional[str] = 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") - def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None: - """Run the FastMCP server. Note this is a synchronous function. + self.path = Path(path) if path else None + self.data = data + self._format = format + self._mime_type = self._get_mime_type() - 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}") + 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 transport == "stdio": - asyncio.run(self.run_stdio_async()) - else: # transport == "sse" - asyncio.run(self.run_sse_async()) + 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 _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) + 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") - 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) + return ImageContent(type="image", data=data, mimeType=self._mime_type) - 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.""" +================================================ +File: /src/fastmcp/utilities/logging.py +================================================ +"""Logging utilities for FastMCP.""" - 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 - ] +import logging +from typing import Literal - 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 - ] +from rich.console import Console +from rich.logging import RichHandler - 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 get_logger(name: str) -> logging.Logger: + """Get a logger nested under FastMCP namespace. - def add_tool( - self, - fn: Callable, - name: str | None = None, - description: str | None = None, - ) -> None: - """Add a tool to the server. + Args: + name: the name of the logger, which will be prefixed with 'FastMCP.' - The tool function can optionally request a Context object by adding a parameter - with the Context type annotation. See the @tool decorator for examples. + Returns: + a configured logger instance + """ + return logging.getLogger(f"FastMCP.{name}") - 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[[Callable[P, R]], Callable[P, R]]: - """Decorator to register a tool. +def configure_logging( + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", +) -> None: + """Configure logging for FastMCP. - 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: + level: the log level to use + """ + logging.basicConfig( + level=level, + format="%(message)s", + handlers=[RichHandler(console=Console(stderr=True), rich_tracebacks=True)], + ) - 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) +================================================ +File: /src/fastmcp/utilities/func_metadata.py +================================================ +import inspect +from collections.abc import Callable, Sequence, Awaitable +from typing import ( + Annotated, + Any, + Dict, + ForwardRef, +) +from pydantic import Field +from fastmcp.exceptions import InvalidSignature +from pydantic._internal._typing_extra import eval_type_lenient +import json +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic import ConfigDict, create_model +from pydantic import WithJsonSchema +from pydantic_core import PydanticUndefined +from fastmcp.utilities.logging import get_logger - @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" - ) +logger = get_logger(__name__) - def decorator(fn: Callable[P, R]) -> Callable[P, R]: - self.add_tool(fn, name=name, description=description) - return fn - return decorator +class ArgModelBase(BaseModel): + """A model representing the arguments to a function.""" - def add_resource(self, resource: Resource) -> None: - """Add a resource to the server. + def model_dump_one_level(self) -> dict[str, Any]: + """Return a dict of the model's fields, one level deep. - Args: - resource: A Resource instance to add + That is, sub-models etc are not dumped - they are kept as pydantic models. """ - 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[[Callable[P, R]], Callable[P, R]]: - """Decorator to register a function as a resource. + kwargs: dict[str, Any] = {} + for field_name in self.model_fields.keys(): + kwargs[field_name] = getattr(self, field_name) + return kwargs - 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 + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) - 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 +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 - Example: - @server.resource("resource://my-resource") - def get_data() -> str: - return "Hello, world!" + 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. - @server.resource("resource://{city}/weather") - def get_weather(city: str) -> str: - return f"Weather for {city}" + Arguments are first attempted to be parsed from JSON, then validated against + the argument model, before being passed to the function. """ - # 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" - ) + 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() - def decorator(fn: Callable[P, R]) -> Callable[P, R]: - @functools.wraps(fn) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - return fn(*args, **kwargs) + arguments_parsed_dict |= arguments_to_pass_directly or {} - # Check if this should be a template - has_uri_params = "{" in uri and "}" in uri - has_func_params = bool(inspect.signature(fn).parameters) + 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") - 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()) + def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: + """Pre-parse data from JSON. - if uri_params != func_params: - raise ValueError( - f"Mismatch between URI parameters {uri_params} " - f"and function parameters {func_params}" - ) + Return a dict with same keys as input but with values parsed from JSON + if appropriate. - # 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 + 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, int, float)): + # 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 - def add_prompt(self, prompt: Prompt) -> None: - """Add a prompt to the server. + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) - 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[[Callable[P, R_PromptResult]], Callable[P, R_PromptResult]]: - """Decorator to register a prompt. +def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata: + """Given a function, return metadata including a pydantic model representing its signature. - Args: - name: Optional name for the prompt (defaults to function name) - description: Optional description of what the prompt does + 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()) + ``` - 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}" - } - ] + **critically** it also provides pre-parse helper to attempt to parse things from JSON. - @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" + 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__} may not start with an underscore" ) + if param.name in skip_names: + continue + annotation = param.annotation - def decorator(func: Callable[P, R_PromptResult]) -> Callable[P, R_PromptResult]: - prompt = Prompt.from_function(func, name=name, description=description) - self.add_prompt(prompt) - return func + # `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 + ), + ] - return decorator + # Untyped field + if annotation is inspect.Parameter.empty: + annotation = Annotated[ + Any, + Field(), + # 🤷 + WithJsonSchema({"title": param.name, "type": "string"}), + ] - 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(), - ) + 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 - async def run_sse_async(self) -> None: - """Run the server using SSE transport.""" - from starlette.applications import Starlette - from starlette.routing import Route + arguments_model = create_model( + f"{func.__name__}Arguments", + **dynamic_pydantic_model_params, + __base__=ArgModelBase, + ) + resp = FuncMetadata(arg_model=arguments_model) + return resp - 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(), - ) +def _get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any: + if isinstance(annotation, str): + annotation = ForwardRef(annotation) + annotation = eval_type_lenient(annotation, globalns, globalns) - async def handle_messages(request): - await sse.handle_post_message(request.scope, request.receive, request._send) + return annotation - 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(), +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), ) - 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 - ] + for param in signature.parameters.values() + ] + typed_signature = inspect.Signature(typed_params) + return typed_signature - 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)) +================================================ +File: /src/fastmcp/resources/templates.py +================================================ +"""Resource template functionality.""" +import inspect +import re +from typing import Any, Callable, Dict, Optional -def _convert_to_content( - result: Any, -) -> Sequence[TextContent | ImageContent | EmbeddedResource]: - """Convert a result to a sequence of content objects.""" - if result is None: - return [] +from pydantic import BaseModel, Field, TypeAdapter, validate_call - if isinstance(result, (TextContent, ImageContent, EmbeddedResource)): - return [result] +from fastmcp.resources.types import FunctionResource, Resource - 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)) +class ResourceTemplate(BaseModel): + """A template for dynamically creating resources.""" - if not isinstance(result, str): - try: - result = json.dumps(pydantic_core.to_jsonable_python(result)) - except Exception: - result = str(result) + 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") - return [TextContent(type="text", text=result)] + @classmethod + def from_function( + cls, + fn: Callable, + uri_template: str, + name: Optional[str] = None, + description: Optional[str] = None, + mime_type: Optional[str] = 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() -class Context(BaseModel): - """Context object providing access to MCP capabilities. + # ensure the arguments are properly cast + fn = validate_call(fn) - This provides a cleaner interface to MCP's RequestContext functionality. - It gets injected into tool and resource functions that request it via type hints. + 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, + ) - To use context in a tool function, add a parameter with the Context type annotation: + def matches(self, uri: str) -> Optional[Dict[str, Any]]: + """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 - ```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") + 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 - # Report progress - ctx.report_progress(50, 100) + 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}") - # Access resources - data = ctx.read_resource("resource://data") - # Get request info - request_id = ctx.request_id - client_id = ctx.client_id +================================================ +File: /src/fastmcp/resources/__init__.py +================================================ +from .base import Resource +from .types import ( + TextResource, + BinaryResource, + FunctionResource, + FileResource, + HttpResource, + DirectoryResource, +) +from .templates import ResourceTemplate +from .resource_manager import ResourceManager - return str(x) - ``` +__all__ = [ + "Resource", + "TextResource", + "BinaryResource", + "FunctionResource", + "FileResource", + "HttpResource", + "DirectoryResource", + "ResourceTemplate", + "ResourceManager", +] - 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 +================================================ +File: /src/fastmcp/resources/base.py +================================================ +"""Base classes and interfaces for FastMCP resources.""" - 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 +import abc +from typing import Union, Annotated - @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 +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + Field, + UrlConstraints, + ValidationInfo, + field_validator, +) - @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. +class Resource(BaseModel, abc.ABC): + """Base class for all resources.""" - Args: - progress: Current progress value e.g. 24 - total: Optional total value e.g. 100 - """ + model_config = ConfigDict(validate_default=True) - progress_token = ( - self.request_context.meta.progressToken - if self.request_context.meta - else None - ) + 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\-+.]+$", + ) - if not progress_token: - return + @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") - await self.request_context.session.send_progress_notification( - progress_token=progress_token, progress=progress, total=total - ) + @abc.abstractmethod + async def read(self) -> Union[str, bytes]: + """Read the resource content.""" + pass - async def read_resource(self, uri: str | AnyUrl) -> str | bytes: - """Read a resource by URI. - Args: - uri: Resource URI to read +================================================ +File: /src/fastmcp/resources/types.py +================================================ +"""Concrete resource implementations.""" - 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) +import asyncio +import json +from pathlib import Path +from typing import Any, Callable, Union - def log( - self, - level: Literal["debug", "info", "warning", "error"], - message: str, - *, - logger_name: str | None = None, - ) -> None: - """Send a log message to the client. +import httpx +import pydantic.json +import pydantic_core +from pydantic import Field, ValidationInfo - 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 - ) +from fastmcp.resources.base import Resource - @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) +class TextResource(Resource): + """A resource that reads from a string.""" - @property - def session(self): - """Access to the underlying session for advanced usage.""" - return self.request_context.session + text: str = Field(description="Text content of the resource") - # Convenience methods for common log levels - def debug(self, message: str, **extra: Any) -> None: - """Send a debug log message.""" - self.log("debug", message, **extra) + async def read(self) -> str: + """Read the text content.""" + return self.text - 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) +class BinaryResource(Resource): + """A resource that reads from bytes.""" - def error(self, message: str, **extra: Any) -> None: - """Send an error log message.""" - self.log("error", message, **extra) + data: bytes = Field(description="Binary content of the resource") + async def read(self) -> bytes: + """Read the binary content.""" + return self.data -================================================ -File: /src/fastmcp/tools/tool_manager.py -================================================ -from fastmcp.exceptions import ToolError -from fastmcp.tools.base import Tool +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. -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING + The function can return: + - str for text content (default) + - bytes for binary content + - other types will be converted to JSON + """ -from fastmcp.utilities.logging import get_logger + fn: Callable[[], Any] = Field(exclude=True) -if TYPE_CHECKING: - from fastmcp.server import Context + async def read(self) -> Union[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}") -logger = get_logger(__name__) +class FileResource(Resource): + """A resource that reads from a file. -class ToolManager: - """Manages FastMCP tools.""" + Set is_binary=True to read file as binary data instead of text. + """ - def __init__(self, warn_on_duplicate_tools: bool = True): - self._tools: Dict[str, Tool] = {} - self.warn_on_duplicate_tools = warn_on_duplicate_tools + 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", + ) - def get_tool(self, name: str) -> Optional[Tool]: - """Get tool by name.""" - return self._tools.get(name) + @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_tools(self) -> list[Tool]: - """List all registered tools.""" - return list(self._tools.values()) + @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/") - def add_tool( - self, - fn: Callable, - name: Optional[str] = None, - description: Optional[str] = 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 read(self) -> Union[str, bytes]: + """Read the file content.""" + try: + if self.is_binary: + return await asyncio.to_thread(self.path.read_bytes) + return await asyncio.to_thread(self.path.read_text) + except Exception as e: + raise ValueError(f"Error reading file {self.path}: {e}") - async def call_tool( - self, name: str, arguments: dict, context: Optional["Context"] = 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) +class HttpResource(Resource): + """A resource that reads from an HTTP endpoint.""" + url: str = Field(description="URL to fetch content from") + mime_type: str | None = Field( + default="application/json", description="MIME type of the resource content" + ) -================================================ -File: /src/fastmcp/tools/__init__.py -================================================ -from .base import Tool -from .tool_manager import ToolManager + async def read(self) -> Union[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 -__all__ = ["Tool", "ToolManager"] +class DirectoryResource(Resource): + """A resource that lists files in a directory.""" -================================================ -File: /src/fastmcp/tools/base.py -================================================ -import fastmcp -from fastmcp.exceptions import ToolError + 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 | None = Field( + default="application/json", description="MIME type of the resource content" + ) -from fastmcp.utilities.func_metadata import func_metadata, FuncMetadata -from pydantic import BaseModel, Field + @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}") -import inspect -from typing import TYPE_CHECKING, Any, Callable, Optional + 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}") -if TYPE_CHECKING: - from fastmcp.server import Context + async def read(self) -> str: # Always returns JSON string + """Read the directory listing.""" + try: + files = await asyncio.to_thread(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}") -class Tool(BaseModel): - """Internal tool registration info.""" +================================================ +File: /src/fastmcp/resources/resource_manager.py +================================================ +"""Resource manager functionality.""" - 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: Optional[str] = Field( - None, description="Name of the kwarg that should receive context" - ) +from typing import Callable, Dict, Optional, Union - @classmethod - def from_function( - cls, - fn: Callable, - name: Optional[str] = None, - description: Optional[str] = None, - context_kwarg: Optional[str] = None, - ) -> "Tool": - """Create a Tool from a function.""" - func_name = name or fn.__name__ +from pydantic import AnyUrl - if func_name == "": - raise ValueError("You must provide a name for lambda functions") +from fastmcp.resources.base import Resource +from fastmcp.resources.templates import ResourceTemplate +from fastmcp.utilities.logging import get_logger - 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 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: Optional["Context"] = 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 - - -================================================ -File: /src/fastmcp/resources/resource_manager.py -================================================ -"""Resource manager functionality.""" - -from typing import Callable, Dict, Optional, Union - -from pydantic import AnyUrl - -from fastmcp.resources.base import Resource -from fastmcp.resources.templates import ResourceTemplate -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) +logger = get_logger(__name__) class ResourceManager: @@ -2263,367 +2150,498 @@ class ResourceManager: ================================================ -File: /src/fastmcp/resources/__init__.py +File: /src/fastmcp/exceptions.py ================================================ -from .base import Resource -from .types import ( - TextResource, - BinaryResource, - FunctionResource, - FileResource, - HttpResource, - DirectoryResource, -) -from .templates import ResourceTemplate -from .resource_manager import ResourceManager +"""Custom exceptions for FastMCP.""" -__all__ = [ - "Resource", - "TextResource", - "BinaryResource", - "FunctionResource", - "FileResource", - "HttpResource", - "DirectoryResource", - "ResourceTemplate", - "ResourceManager", -] +class FastMCPError(Exception): + """Base error for FastMCP.""" -================================================ -File: /src/fastmcp/resources/types.py -================================================ -"""Concrete resource implementations.""" -import asyncio -import json -from pathlib import Path -from typing import Any, Callable, Union +class ValidationError(FastMCPError): + """Error in validating parameters or return values.""" -import httpx -import pydantic.json -import pydantic_core -from pydantic import Field, ValidationInfo -from fastmcp.resources.base import Resource +class ResourceError(FastMCPError): + """Error in resource operations.""" -class TextResource(Resource): - """A resource that reads from a string.""" +class ToolError(FastMCPError): + """Error in tool operations.""" - text: str = Field(description="Text content of the resource") - async def read(self) -> str: - """Read the text content.""" - return self.text +class InvalidSignature(Exception): + """Invalid signature for use with FastMCP.""" -class BinaryResource(Resource): - """A resource that reads from bytes.""" +================================================ +File: /src/fastmcp/cli/__init__.py +================================================ +"""FastMCP CLI package.""" - data: bytes = Field(description="Binary content of the resource") +from .cli import app - async def read(self) -> bytes: - """Read the binary content.""" - return self.data +if __name__ == "__main__": + app() -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. +================================================ +File: /src/fastmcp/cli/cli.py +================================================ +"""FastMCP CLI tools.""" - The function can return: - - str for text content (default) - - bytes for binary content - - other types will be converted to JSON - """ +import importlib.metadata +import importlib.util +import os +import subprocess +import sys +from pathlib import Path +from typing import Dict, Optional, Tuple - fn: Callable[[], Any] = Field(exclude=True) +import dotenv +import typer +from typing_extensions import Annotated - async def read(self) -> Union[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}") +from fastmcp.cli import claude +from fastmcp.utilities.logging import get_logger +logger = get_logger("cli") -class FileResource(Resource): - """A resource that reads from a file. +app = typer.Typer( + name="fastmcp", + help="FastMCP development tools", + add_completion=False, + no_args_is_help=True, # Show help if no args provided +) - 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", - ) +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 - @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/") +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() - async def read(self) -> Union[str, bytes]: - """Read the file content.""" - try: - if self.is_binary: - return await asyncio.to_thread(self.path.read_bytes) - return await asyncio.to_thread(self.path.read_text) - except Exception as e: - raise ValueError(f"Error reading file {self.path}: {e}") +def _build_uv_command( + file_spec: str, + with_editable: Optional[Path] = None, + with_packages: Optional[list[str]] = None, +) -> list[str]: + """Build the uv run command that runs a FastMCP server through fastmcp run.""" + cmd = ["uv"] -class HttpResource(Resource): - """A resource that reads from an HTTP endpoint.""" + cmd.extend(["run", "--with", "fastmcp"]) - url: str = Field(description="URL to fetch content from") - mime_type: str | None = Field( - default="application/json", description="MIME type of the resource content" - ) + if with_editable: + cmd.extend(["--with-editable", str(with_editable)]) - async def read(self) -> Union[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 + if with_packages: + for pkg in with_packages: + if pkg: + cmd.extend(["--with", pkg]) + # Add fastmcp run command + cmd.extend(["fastmcp", "run", file_spec]) + return cmd -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 | None = 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}") +def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]: + """Parse a file path that may include a server object specification. - async def read(self) -> str: # Always returns JSON string - """Read the directory listing.""" - try: - files = await asyncio.to_thread(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}") + 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] == ":" -================================================ -File: /src/fastmcp/resources/templates.py -================================================ -"""Resource template functionality.""" + # 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 -import inspect -import re -from typing import Any, Callable, Dict, Optional + # 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) -from pydantic import BaseModel, Field, TypeAdapter, validate_call + return file_path, server_object -from fastmcp.resources.types import FunctionResource, Resource +def _import_server(file: Path, server_object: Optional[str] = None): + """Import a FastMCP server from a file. -class ResourceTemplate(BaseModel): - """A template for dynamically creating resources.""" + Args: + file: Path to the file + server_object: Optional object name in format "module:object" or just "object" - 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") + 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) - @classmethod - def from_function( - cls, - fn: Callable, - uri_template: str, - name: Optional[str] = None, - description: Optional[str] = None, - mime_type: Optional[str] = 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") + # 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) - # Get schema from TypeAdapter - will fail if function isn't properly typed - parameters = TypeAdapter(fn).json_schema() + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) - # ensure the arguments are properly cast - fn = validate_call(fn) + # 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) - 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, + 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) - def matches(self, uri: str) -> Optional[Dict[str, Any]]: - """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.""" + # Handle module:object syntax + if ":" in server_object: + module_name, object_name = server_object.split(":", 1) 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 + 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)}, ) - except Exception as e: - raise ValueError(f"Error creating resource from template: {e}") + 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) -================================================ -File: /src/fastmcp/resources/base.py -================================================ -"""Base classes and interfaces for FastMCP resources.""" + return server -import abc -from typing import Union, Annotated -from pydantic import ( - AnyUrl, - BaseModel, - ConfigDict, - Field, - UrlConstraints, - ValidationInfo, - field_validator, -) +@app.command() +def version() -> None: + """Show the FastMCP version.""" + try: + version = importlib.metadata.version("fastmcp") + print(f"FastMCP version {version}") + except importlib.metadata.PackageNotFoundError: + print("FastMCP version unknown (package not installed)") + sys.exit(1) -class Resource(BaseModel, abc.ABC): - """Base class for all resources.""" +@app.command() +def dev( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + with_editable: Annotated[ + Optional[Path], + 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 FastMCP server with the MCP Inspector.""" + file, server_object = _parse_file_path(file_spec) - model_config = ConfigDict(validate_default=True) + 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, + }, + ) - 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 + 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[ + Optional[str], + typer.Option( + "--transport", + "-t", + help="Transport protocol to use (stdio or sse)", + ), + ] = None, +) -> None: + """Run a FastMCP 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 fastmcp install + or fastmcp dev instead. + """ + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Running server", + extra={ + "file": str(file), + "server_object": server_object, + "transport": transport, + }, ) - mime_type: str = Field( - default="text/plain", - description="MIME type of the resource content", - pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", + + 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[ + Optional[str], + typer.Option( + "--name", + "-n", + help="Custom name for the server (defaults to server's name attribute or file name)", + ), + ] = None, + with_editable: Annotated[ + Optional[Path], + 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[ + Optional[Path], + 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 FastMCP 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, + }, ) - @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") + if not claude.get_claude_config_path(): + logger.error("Claude app not found") + sys.exit(1) - @abc.abstractmethod - async def read(self) -> Union[str, bytes]: - """Read the resource content.""" - pass + # 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)) -================================================ -File: /src/fastmcp/__init__.py -================================================ -"""FastMCP - A more ergonomic interface for MCP servers.""" + # Process environment variables if provided + env_dict: Optional[Dict[str, str]] = None + if env_file or env_vars: + env_dict = {} + # Load from .env file if specified + if env_file: + 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) -from importlib.metadata import version -from .server import FastMCP, Context -from .utilities.types import Image + # Add command line environment variables + for env_var in env_vars: + key, value = _parse_env_var(env_var) + env_dict[key] = value -__version__ = version("fastmcp") -__all__ = ["FastMCP", "Context", "Image"] + 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) ================================================ @@ -2770,1087 +2788,1126 @@ def update_claude_config( ================================================ -File: /src/fastmcp/cli/__init__.py +File: /src/fastmcp/__init__.py ================================================ -"""FastMCP CLI package.""" - -from .cli import app +"""FastMCP - A more ergonomic interface for MCP servers.""" +from importlib.metadata import version +from .server import FastMCP, Context +from .utilities.types import Image -if __name__ == "__main__": - app() +__version__ = version("fastmcp") +__all__ = ["FastMCP", "Context", "Image"] ================================================ -File: /src/fastmcp/cli/cli.py +File: /src/fastmcp/tools/__init__.py ================================================ -"""FastMCP CLI tools.""" - -import importlib.metadata -import importlib.util -import os -import subprocess -import sys -from pathlib import Path -from typing import Dict, Optional, Tuple +from .base import Tool +from .tool_manager import ToolManager -import dotenv -import typer -from typing_extensions import Annotated +__all__ = ["Tool", "ToolManager"] -from fastmcp.cli import claude -from fastmcp.utilities.logging import get_logger -logger = get_logger("cli") +================================================ +File: /src/fastmcp/tools/tool_manager.py +================================================ +from fastmcp.exceptions import ToolError -app = typer.Typer( - name="fastmcp", - help="FastMCP development tools", - add_completion=False, - no_args_is_help=True, # Show help if no args provided -) +from fastmcp.tools.base import Tool -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 +from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +from fastmcp.utilities.logging import get_logger -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() +if TYPE_CHECKING: + from fastmcp.server import Context +logger = get_logger(__name__) -def _build_uv_command( - file_spec: str, - with_editable: Optional[Path] = None, - with_packages: Optional[list[str]] = None, -) -> list[str]: - """Build the uv run command that runs a FastMCP server through fastmcp run.""" - cmd = ["uv"] - cmd.extend(["run", "--with", "fastmcp"]) +class ToolManager: + """Manages FastMCP tools.""" - if with_editable: - cmd.extend(["--with-editable", str(with_editable)]) + def __init__(self, warn_on_duplicate_tools: bool = True): + self._tools: Dict[str, Tool] = {} + self.warn_on_duplicate_tools = warn_on_duplicate_tools - if with_packages: - for pkg in with_packages: - if pkg: - cmd.extend(["--with", pkg]) + def get_tool(self, name: str) -> Optional[Tool]: + """Get tool by name.""" + return self._tools.get(name) - # Add fastmcp run command - cmd.extend(["fastmcp", "run", file_spec]) - return cmd + def list_tools(self) -> list[Tool]: + """List all registered tools.""" + return list(self._tools.values()) + def add_tool( + self, + fn: Callable, + name: Optional[str] = None, + description: Optional[str] = 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 -def _parse_file_path(file_spec: str) -> Tuple[Path, Optional[str]]: - """Parse a file path that may include a server object specification. + async def call_tool( + self, name: str, arguments: dict, context: Optional["Context"] = None + ) -> Any: + """Call a tool by name with arguments.""" + tool = self.get_tool(name) + if not tool: + raise ToolError(f"Unknown tool: {name}") - Args: - file_spec: Path to file, optionally with :object suffix + return await tool.run(arguments, context=context) - 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 +================================================ +File: /src/fastmcp/tools/base.py +================================================ +import fastmcp +from fastmcp.exceptions import ToolError - # 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) +from fastmcp.utilities.func_metadata import func_metadata, FuncMetadata +from pydantic import BaseModel, Field - return file_path, server_object +import inspect +from typing import TYPE_CHECKING, Any, Callable, Optional -def _import_server(file: Path, server_object: Optional[str] = None): - """Import a FastMCP server from a file. +if TYPE_CHECKING: + from fastmcp.server import Context - 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) +class Tool(BaseModel): + """Internal tool registration info.""" - # 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) + 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: Optional[str] = Field( + None, description="Name of the kwarg that should receive context" + ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + @classmethod + def from_function( + cls, + fn: Callable, + name: Optional[str] = None, + description: Optional[str] = None, + context_kwarg: Optional[str] = None, + ) -> "Tool": + """Create a Tool from a function.""" + func_name = name or fn.__name__ - # 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) + if func_name == "": + raise ValueError("You must provide a name for lambda functions") - 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) + func_doc = description or fn.__doc__ or "" + is_async = inspect.iscoroutinefunction(fn) - # 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) + # 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 fastmcp.Context: + context_kwarg = param_name + break - if server is None: - logger.error( - f"Server object '{server_object}' not found", - extra={"file": str(file)}, + func_arg_metadata = func_metadata( + fn, + skip_names=[context_kwarg] if context_kwarg is not None else [], ) - sys.exit(1) - - return server + 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, + ) -@app.command() -def version() -> None: - """Show the FastMCP version.""" - try: - version = importlib.metadata.version("fastmcp") - print(f"FastMCP version {version}") - except importlib.metadata.PackageNotFoundError: - print("FastMCP version unknown (package not installed)") - sys.exit(1) + async def run(self, arguments: dict, context: Optional["Context"] = 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 -@app.command() -def dev( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), - with_editable: Annotated[ - Optional[Path], - 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 FastMCP server with the MCP Inspector.""" - file, server_object = _parse_file_path(file_spec) +================================================ +File: /src/fastmcp/prompts/prompt_manager.py +================================================ +"""Prompt management functionality.""" - 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, - }, - ) +from typing import Dict, Optional - 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) +from fastmcp.prompts.base import Prompt +from fastmcp.utilities.logging import get_logger - # 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) +logger = get_logger(__name__) - # 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) +class PromptManager: + """Manages FastMCP prompts.""" -@app.command() -def run( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), - transport: Annotated[ - Optional[str], - typer.Option( - "--transport", - "-t", - help="Transport protocol to use (stdio or sse)", - ), - ] = None, -) -> None: - """Run a FastMCP server. + def __init__(self, warn_on_duplicate_prompts: bool = True): + self._prompts: Dict[str, Prompt] = {} + self.warn_on_duplicate_prompts = warn_on_duplicate_prompts - 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 + 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 - Note: This command runs the server directly. You are responsible for ensuring - all dependencies are available. For dependency management, use fastmcp install - or fastmcp dev instead. - """ - file, server_object = _parse_file_path(file_spec) + def get_prompt(self, name: str) -> Optional[Prompt]: + """Get prompt by name.""" + return self._prompts.get(name) - logger.debug( - "Running server", - extra={ - "file": str(file), - "server_object": server_object, - "transport": transport, - }, - ) + def list_prompts(self) -> list[Prompt]: + """List all registered prompts.""" + return list(self._prompts.values()) - try: - # Import and get server object - server = _import_server(file, server_object) - # Run the server - kwargs = {} - if transport: - kwargs["transport"] = transport +================================================ +File: /src/fastmcp/prompts/__init__.py +================================================ +from .base import Prompt +from .manager import PromptManager - server.run(**kwargs) +__all__ = ["Prompt", "PromptManager"] - except Exception as e: - logger.error( - f"Failed to run server: {e}", - extra={ - "file": str(file), - "error": str(e), - }, - ) - sys.exit(1) +================================================ +File: /src/fastmcp/prompts/base.py +================================================ +"""Base classes for FastMCP prompts.""" -@app.command() -def install( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), - server_name: Annotated[ - Optional[str], - typer.Option( - "--name", - "-n", - help="Custom name for the server (defaults to server's name attribute or file name)", - ), - ] = None, - with_editable: Annotated[ - Optional[Path], - 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[ - Optional[Path], - 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 FastMCP server in the Claude desktop app. +import json +from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable +import inspect - Environment variables are preserved once added and only updated if new values - are explicitly provided. - """ - file, server_object = _parse_file_path(file_spec) +from pydantic import BaseModel, Field, TypeAdapter, validate_call +from mcp.types import TextContent, ImageContent, EmbeddedResource +import pydantic_core - 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, - }, - ) +CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource - 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 +class Message(BaseModel): + """Base class for all prompt messages.""" - # Get server dependencies if available - server_dependencies = getattr(server, "dependencies", []) if server else [] - if server_dependencies: - with_packages = list(set(with_packages + server_dependencies)) + role: Literal["user", "assistant"] + content: CONTENT_TYPES - # Process environment variables if provided - env_dict: Optional[Dict[str, str]] = None - if env_file or env_vars: - env_dict = {} - # Load from .env file if specified - if env_file: - 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) + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + if isinstance(content, str): + content = TextContent(type="text", text=content) + super().__init__(content=content, **kwargs) - # 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) +class UserMessage(Message): + """A message from the user.""" + role: Literal["user"] = "user" -================================================ -File: /src/fastmcp/utilities/logging.py -================================================ -"""Logging utilities for FastMCP.""" + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + super().__init__(content=content, **kwargs) -import logging -from typing import Literal -from rich.console import Console -from rich.logging import RichHandler +class AssistantMessage(Message): + """A message from the assistant.""" + role: Literal["assistant"] = "assistant" -def get_logger(name: str) -> logging.Logger: - """Get a logger nested under FastMCP namespace. + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + super().__init__(content=content, **kwargs) - Args: - name: the name of the logger, which will be prefixed with 'FastMCP.' - Returns: - a configured logger instance - """ - return logging.getLogger(f"FastMCP.{name}") +message_validator = TypeAdapter(UserMessage | AssistantMessage) +SyncPromptResult = ( + str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] +) +PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] -def configure_logging( - level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", -) -> None: - """Configure logging for FastMCP. - Args: - level: the log level to use - """ - logging.basicConfig( - level=level, - format="%(message)s", - handlers=[RichHandler(console=Console(stderr=True), rich_tracebacks=True)], +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: Optional[str] = None, + description: Optional[str] = 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: Optional[Dict[str, Any]] = 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}") + + ================================================ -File: /src/fastmcp/utilities/func_metadata.py +File: /src/fastmcp/prompts/manager.py ================================================ -import inspect -from collections.abc import Callable, Sequence, Awaitable -from typing import ( - Annotated, - Any, - Dict, - ForwardRef, -) -from pydantic import Field -from fastmcp.exceptions import InvalidSignature -from pydantic._internal._typing_extra import eval_type_lenient -import json -from pydantic import BaseModel -from pydantic.fields import FieldInfo -from pydantic import ConfigDict, create_model -from pydantic import WithJsonSchema -from pydantic_core import PydanticUndefined +"""Prompt management functionality.""" + +from typing import Any, Dict, Optional + +from fastmcp.prompts.base import Message, Prompt from 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 +class PromptManager: + """Manages FastMCP prompts.""" - model_config = ConfigDict( - arbitrary_types_allowed=True, - ) + 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) -> Optional[Prompt]: + """Get prompt by name.""" + return self._prompts.get(name) -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 + def list_prompts(self) -> list[Prompt]: + """List all registered prompts.""" + return list(self._prompts.values()) - async def call_fn_with_arg_validation( + def add_prompt( 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() + prompt: Prompt, + ) -> Prompt: + """Add a prompt to the manager.""" - arguments_parsed_dict |= arguments_to_pass_directly or {} + # 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 - 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") + self._prompts[prompt.name] = prompt + return prompt - def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: - """Pre-parse data from JSON. + async def render_prompt( + self, name: str, arguments: Optional[Dict[str, Any]] = 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 a dict with same keys as input but with values parsed from JSON - if appropriate. + return await prompt.render(arguments) - 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, int, float)): - # 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, - ) +================================================ +File: /src/fastmcp/server.py +================================================ +"""FastMCP - A more ergonomic interface for MCP servers.""" +import asyncio +import functools +import inspect +import json +import re +from itertools import chain +from typing import Any, Callable, Dict, Literal, Sequence, TypeVar, ParamSpec -def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata: - """Given a function, return metadata including a pydantic model representing its signature. +import pydantic_core +from pydantic import Field +import uvicorn +from mcp.server 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, + PromptArgument as MCPPromptArgument, +) +from mcp.types import ( + Resource as MCPResource, +) +from mcp.types import ( + ResourceTemplate as MCPResourceTemplate, +) +from mcp.types import ( + Tool as MCPTool, +) +from pydantic import BaseModel +from pydantic.networks import AnyUrl +from pydantic_settings import BaseSettings, SettingsConfigDict - 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()) - ``` +from fastmcp.exceptions import ResourceError +from fastmcp.prompts import Prompt, PromptManager +from fastmcp.prompts.base import PromptResult +from fastmcp.resources import FunctionResource, Resource, ResourceManager +from fastmcp.tools import ToolManager +from fastmcp.utilities.logging import configure_logging, get_logger +from fastmcp.utilities.types import Image - **critically** it also provides pre-parse helper to attempt to parse things from JSON. +logger = get_logger(__name__) - 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__} may not start with an underscore" - ) - if param.name in skip_names: - continue - annotation = param.annotation +P = ParamSpec("P") +R = TypeVar("R") +R_PromptResult = TypeVar("R_PromptResult", bound=PromptResult) - # `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"}), - ] +class Settings(BaseSettings): + """FastMCP server settings. - 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 + All settings can be configured via environment variables with the prefix FASTMCP_. + For example, FASTMCP_DEBUG=true will set debug=True. + """ - arguments_model = create_model( - f"{func.__name__}Arguments", - **dynamic_pydantic_model_params, - __base__=ArgModelBase, + model_config: SettingsConfigDict = SettingsConfigDict( + env_prefix="FASTMCP_", + env_file=".env", + extra="ignore", ) - resp = FuncMetadata(arg_model=arguments_model) - return resp + # 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 -def _get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any: - if isinstance(annotation, str): - annotation = ForwardRef(annotation) - annotation = eval_type_lenient(annotation, globalns, globalns) + # resource settings + warn_on_duplicate_resources: bool = True - return annotation + # tool settings + warn_on_duplicate_tools: bool = True + # prompt settings + warn_on_duplicate_prompts: bool = True -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 + dependencies: list[str] = Field( + default_factory=list, + description="List of dependencies to install in the server environment", + ) -================================================ -File: /src/fastmcp/utilities/__init__.py -================================================ -"""FastMCP utility modules.""" +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() -================================================ -File: /src/fastmcp/utilities/types.py -================================================ -"""Common types used across FastMCP.""" + # Configure logging + configure_logging(self.settings.log_level) -import base64 -from pathlib import Path -from typing import Optional, Union + @property + def name(self) -> str: + return self._mcp_server.name -from mcp.types import ImageContent + 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}") -class Image: - """Helper class for returning images from tools.""" + if transport == "stdio": + asyncio.run(self.run_stdio_async()) + else: # transport == "sse" + asyncio.run(self.run_sse_async()) - def __init__( - self, - path: Optional[Union[str, Path]] = None, - data: Optional[bytes] = None, - format: Optional[str] = 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") + 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) - self.path = Path(path) if path else None - self.data = data - self._format = format - self._mime_type = self._get_mime_type() + 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_mime_type(self) -> str: - """Get MIME type from format or guess from file extension.""" - if self._format: - return f"image/{self._format.lower()}" + 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) - 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 + 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 - 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") + async def list_resources(self) -> list[MCPResource]: + """List all available resources.""" - return ImageContent(type="image", data=data, mimeType=self._mime_type) + 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 + ] -================================================ -File: /src/fastmcp/prompts/prompt_manager.py -================================================ -"""Prompt management functionality.""" + 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}") -from typing import Dict, Optional + 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. -from fastmcp.prompts.base import Prompt -from fastmcp.utilities.logging import get_logger + The tool function can optionally request a Context object by adding a parameter + with the Context type annotation. See the @tool decorator for examples. -logger = get_logger(__name__) + 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[[Callable[P, R]], Callable[P, R]]: + """Decorator to register a tool. -class PromptManager: - """Manages FastMCP prompts.""" + 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. - def __init__(self, warn_on_duplicate_prompts: bool = True): - self._prompts: Dict[str, Prompt] = {} - self.warn_on_duplicate_prompts = warn_on_duplicate_prompts + Args: + name: Optional name for the tool (defaults to function name) + description: Optional description of what the tool does - 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 + Example: + @server.tool() + def my_tool(x: int) -> str: + return str(x) - def get_prompt(self, name: str) -> Optional[Prompt]: - """Get prompt by name.""" - return self._prompts.get(name) + @server.tool() + def tool_with_context(x: int, ctx: Context) -> str: + ctx.info(f"Processing {x}") + return str(x) - def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" - return list(self._prompts.values()) + @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[P, R]) -> Callable[P, R]: + self.add_tool(fn, name=name, description=description) + return fn -================================================ -File: /src/fastmcp/prompts/__init__.py -================================================ -from .base import Prompt -from .manager import PromptManager + return decorator -__all__ = ["Prompt", "PromptManager"] + 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[[Callable[P, R]], Callable[P, R]]: + """Decorator to register a function as a resource. -================================================ -File: /src/fastmcp/prompts/manager.py -================================================ -"""Prompt management functionality.""" + 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 -from typing import Any, Dict, Optional + If the URI contains parameters (e.g. "resource://{param}") or the function + has parameters, it will be registered as a template resource. -from fastmcp.prompts.base import Message, Prompt -from fastmcp.utilities.logging import get_logger + 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 -logger = get_logger(__name__) + 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" + ) -class PromptManager: - """Manages FastMCP prompts.""" + def decorator(fn: Callable[P, R]) -> Callable[P, R]: + @functools.wraps(fn) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + return fn(*args, **kwargs) - def __init__(self, warn_on_duplicate_prompts: bool = True): - self._prompts: Dict[str, Prompt] = {} - self.warn_on_duplicate_prompts = warn_on_duplicate_prompts + # Check if this should be a template + has_uri_params = "{" in uri and "}" in uri + has_func_params = bool(inspect.signature(fn).parameters) - def get_prompt(self, name: str) -> Optional[Prompt]: - """Get prompt by name.""" - return self._prompts.get(name) + 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()) - def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" - return list(self._prompts.values()) + if uri_params != func_params: + raise ValueError( + f"Mismatch between URI parameters {uri_params} " + f"and function parameters {func_params}" + ) - def add_prompt( - self, - prompt: Prompt, - ) -> Prompt: - """Add a prompt to the manager.""" + # 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 - # 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 + return decorator - self._prompts[prompt.name] = prompt - return prompt + def add_prompt(self, prompt: Prompt) -> None: + """Add a prompt to the server. - async def render_prompt( - self, name: str, arguments: Optional[Dict[str, Any]] = None - ) -> list[Message]: - """Render a prompt by name with arguments.""" - prompt = self.get_prompt(name) - if not prompt: - raise ValueError(f"Unknown prompt: {name}") + Args: + prompt: A Prompt instance to add + """ + self._prompt_manager.add_prompt(prompt) - return await prompt.render(arguments) + def prompt( + self, name: str | None = None, description: str | None = None + ) -> Callable[[Callable[P, R_PromptResult]], Callable[P, R_PromptResult]]: + """Decorator to register a prompt. + Args: + name: Optional name for the prompt (defaults to function name) + description: Optional description of what the prompt does -================================================ -File: /src/fastmcp/prompts/base.py -================================================ -"""Base classes for FastMCP prompts.""" + 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}" + } + ] -import json -from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable -import inspect + @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" + ) -from pydantic import BaseModel, Field, TypeAdapter, validate_call -from mcp.types import TextContent, ImageContent, EmbeddedResource -import pydantic_core + def decorator(func: Callable[P, R_PromptResult]) -> Callable[P, R_PromptResult]: + prompt = Prompt.from_function(func, name=name, description=description) + self.add_prompt(prompt) + return func -CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource + 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(), + ) -class Message(BaseModel): - """Base class for all prompt messages.""" + async def run_sse_async(self) -> None: + """Run the server using SSE transport.""" + from starlette.applications import Starlette + from starlette.routing import Route - role: Literal["user", "assistant"] - content: CONTENT_TYPES + sse = SseServerTransport("/messages") - def __init__(self, content: str | CONTENT_TYPES, **kwargs): - if isinstance(content, str): - content = TextContent(type="text", text=content) - super().__init__(content=content, **kwargs) + 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) -class UserMessage(Message): - """A message from the user.""" + starlette_app = Starlette( + debug=self.settings.debug, + routes=[ + Route("/sse", endpoint=handle_sse), + Route("/messages", endpoint=handle_messages, methods=["POST"]), + ], + ) - role: Literal["user"] = "user" + 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() - def __init__(self, content: str | CONTENT_TYPES, **kwargs): - super().__init__(content=content, **kwargs) + 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) -class AssistantMessage(Message): - """A message from the assistant.""" + 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)) - role: Literal["assistant"] = "assistant" - def __init__(self, content: str | CONTENT_TYPES, **kwargs): - super().__init__(content=content, **kwargs) +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] -message_validator = TypeAdapter(UserMessage | AssistantMessage) + if isinstance(result, Image): + return [result.to_image_content()] -SyncPromptResult = ( - str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] -) -PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] + 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) -class PromptArgument(BaseModel): - """An argument that can be passed to a prompt.""" + return [TextContent(type="text", text=result)] - 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 Context(BaseModel): + """Context object providing access to MCP capabilities. -class Prompt(BaseModel): - """A prompt template that can be rendered with parameters.""" + This provides a cleaner interface to MCP's RequestContext functionality. + It gets injected into tool and resource functions that request it via type hints. - 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) + To use context in a tool function, add a parameter with the Context type annotation: - @classmethod - def from_function( - cls, - fn: Callable[..., PromptResult], - name: Optional[str] = None, - description: Optional[str] = None, - ) -> "Prompt": - """Create a Prompt from a function. + ```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") - 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__ + # Report progress + ctx.report_progress(50, 100) - if func_name == "": - raise ValueError("You must provide a name for lambda functions") + # Access resources + data = ctx.read_resource("resource://data") - # Get schema from TypeAdapter - will fail if function isn't properly typed - parameters = TypeAdapter(fn).json_schema() + # Get request info + request_id = ctx.request_id + client_id = ctx.client_id - # 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, - ) - ) + return str(x) + ``` - # ensure the arguments are properly cast - fn = validate_call(fn) + 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. + """ - return cls( - name=func_name, - description=description or fn.__doc__ or "", - arguments=arguments, - fn=fn, - ) + _request_context: RequestContext | None + _fastmcp: FastMCP | None - async def render(self, arguments: Optional[Dict[str, Any]] = 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}") + 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 - try: - # Call function and check if result is a coroutine - result = self.fn(**(arguments or {})) - if inspect.iscoroutine(result): - result = await result + @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 - # Validate messages - if not isinstance(result, (list, tuple)): - result = [result] + @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 - # 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}" - ) + async def report_progress( + self, progress: float, total: float | None = None + ) -> None: + """Report progress for the current operation. - return messages - except Exception as e: - raise ValueError(f"Error rendering prompt {self.name}: {e}") + 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 -================================================ -File: /src/fastmcp/py.typed -================================================ + 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. -================================================ -File: /src/fastmcp/exceptions.py -================================================ -"""Custom exceptions for FastMCP.""" + 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) -class FastMCPError(Exception): - """Base error for FastMCP.""" + 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 + ) -class ValidationError(FastMCPError): - """Error in validating parameters or return values.""" + @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) -class ResourceError(FastMCPError): - """Error in resource operations.""" + @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) -class ToolError(FastMCPError): - """Error in tool operations.""" + 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) -class InvalidSignature(Exception): - """Invalid signature for use with FastMCP.""" + def error(self, message: str, **extra: Any) -> None: + """Send an error log message.""" + self.log("error", message, **extra) diff --git a/imessage-query-server.py b/imessage-query-server.py index 14b74d6..352d500 100644 --- a/imessage-query-server.py +++ b/imessage-query-server.py @@ -1,24 +1,142 @@ from pathlib import Path import os -from typing import Dict, Any +import shutil +import subprocess +from typing import Dict, Any, Optional from fastmcp import FastMCP -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import imessagedb import phonenumbers import contextlib import io +import plistlib # Initialize FastMCP server -mcp = FastMCP("iMessage Query", dependencies=["imessagedb", "phonenumbers"]) +mcp = FastMCP("iMessage Query", dependencies=["imessagedb", "phonenumbers"], + log_level="CRITICAL", + host="0.0.0.0", port=8123) # Default to Messages database in user's Library DEFAULT_DB_PATH = Path.home() / "Library" / "Messages" / "chat.db" DB_PATH = Path(os.environ.get('SQLITE_DB_PATH', DEFAULT_DB_PATH)) +def convert_heic_to_jpeg(input_heic: Path, output_jpeg: Path) -> bool: + """Convert HEIC file to JPEG using ImageMagick.""" + try: + if not input_heic.exists(): + raise FileNotFoundError(f"Input file {input_heic} not found.") + + # Run ImageMagick's convert command + subprocess.run( + ["magick", "convert", str(input_heic), str(output_jpeg)], + check=True, + capture_output=True, + text=True + ) + return True + except subprocess.CalledProcessError as e: + print(f"Error converting HEIC to JPEG: {e.stderr}") + return False + except Exception as e: + print(f"Error converting HEIC to JPEG: {str(e)}") + return False + +def normalize_phone_number(phone: str) -> str: + """Remove all non-digit characters from a phone number.""" + return ''.join(c for c in phone if c.isdigit()) + +def extract_message_text(text: str, attributed_body: bytes, message_summary_info: bytes) -> str: + """Extract message text from various possible storage locations.""" + if text: + return text + + if attributed_body: + try: + # Find the NSString marker + ns_string_idx = attributed_body.find(b'NSString') + if ns_string_idx == -1: + return None + + # Look for the actual message content after NSString + content_marker = b'\x01+' + text_start = attributed_body.find(content_marker, ns_string_idx) + if text_start == -1: + return None + + # Skip the content marker and any control bytes + text_start += len(content_marker) + while text_start < len(attributed_body) and attributed_body[text_start] < 0x20: + text_start += 1 + + # Find the end of the text (before the next control sequence) + text_end = attributed_body.find(b'\x86', text_start) + if text_end == -1: + text_end = len(attributed_body) + + # Extract and decode the text, removing any remaining control characters + text_data = attributed_body[text_start:text_end] + decoded = text_data.decode('utf-8', errors='replace') + # Clean up any remaining control characters except newlines + cleaned = ''.join(char for char in decoded if char == '\n' or char >= ' ') + # Remove replacement characters and image placeholders + cleaned = cleaned.replace('\ufffd', '').replace('\ufffc', '') + # Remove any leading/trailing whitespace + cleaned = cleaned.strip() + return cleaned if cleaned else None + except Exception as e: + print(f"Error extracting text from attributed_body: {e}") + + if message_summary_info: + try: + # Try to extract text from message_summary_info (edited messages) + plist = plistlib.loads(message_summary_info) + if 'ec' in plist and '0' in plist['ec']: + # Get the most recent edit + latest_edit = plist['ec']['0'][-1] + if 't' in latest_edit: + edit_text = latest_edit['t'] + # Extract text from edit data using same method as attributed_body + return extract_message_text(None, edit_text, None) + except Exception as e: + print(f"Error extracting text from message_summary_info: {e}") + + return None + +def copy_and_convert_attachment(attachment_path: Path, destination_dir: Path) -> Optional[Path]: + """ + Copy attachment to destination directory, converting HEIC to jpg if needed. + + Args: + attachment_path: Source path of the attachment + destination_dir: Destination directory (Downloads folder) + + Returns: + Path to the copied/converted file or None if conversion failed + """ + if not attachment_path.exists(): + raise FileNotFoundError(f"Attachment not found: {attachment_path}") + + # Create destination directory if it doesn't exist + destination_dir.mkdir(parents=True, exist_ok=True) + + # Handle HEIC files + if attachment_path.suffix.lower() == '.heic': + dest_name = attachment_path.stem + '.jpeg' + dest_path = destination_dir / dest_name + + # Try to convert HEIC to JPEG + if convert_heic_to_jpeg(attachment_path, dest_path): + return dest_path + return None + else: + # Regular file copy for non-HEIC files + dest_path = destination_dir / attachment_path.name + shutil.copy2(attachment_path, dest_path) + return dest_path + class DatabaseContext: """Singleton context for managing database connections across tools.""" _instance = None - def __new__(cls): if cls._instance is None: cls._instance = super(DatabaseContext, cls).__new__(cls) @@ -48,14 +166,14 @@ def __exit__(self, exc_type, exc_val, exc_tb): @mcp.tool() def get_chat_transcript( - phone_number: str, + identifiers: str, start_date: str = None, end_date: str = None ) -> Dict[str, Any]: - """Get chat transcript for a specific phone number within a date range. + """Get chat transcript for one or more identifiers (phone numbers/emails) within a date range. Args: - phone_number: Phone number to get transcript for (E.164 format preferred) + identifiers: Comma-separated list of identifiers (phone numbers in E.164 format preferred, or email addresses) start_date: Optional start date in ISO format (YYYY-MM-DD) end_date: Optional end date in ISO format (YYYY-MM-DD) @@ -63,18 +181,47 @@ def get_chat_transcript( Dictionary containing the chat transcript data Raises: - ValueError: If the phone number is invalid + ValueError: If any identifier is invalid """ - # Validate and format the phone number - try: - # Parse assuming US number if no region provided - parsed_number = phonenumbers.parse(phone_number, "US") - if not phonenumbers.is_valid_number(parsed_number): - raise ValueError(f"Invalid phone number: {phone_number}") - # Format to E.164 format - phone_number = phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.E164) - except phonenumbers.NumberParseException as e: - raise ValueError(f"Invalid phone number format: {e}") + # Split and clean identifiers + id_list = [id.strip() for id in identifiers.split(',')] + normalized_ids = [] + + # Process each identifier + for identifier in id_list: + if '@' in identifier: # Email address + normalized_ids.append(identifier) + else: # Phone number + try: + # Parse assuming US number if no region provided + parsed_number = phonenumbers.parse(identifier, "US") + if not phonenumbers.is_valid_number(parsed_number): + raise ValueError(f"Invalid phone number: {identifier}") + # Format to E.164 format + normalized_ids.append(phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.E164)) + except phonenumbers.NumberParseException as e: + raise ValueError(f"Invalid phone number format for {identifier}: {e}") + + if not normalized_ids: + raise ValueError("No valid identifiers provided") + + # Check which identifiers exist in database + invalid_ids = [] + valid_ids = [] + with MessageDBConnection() as db: + for identifier in normalized_ids: + db.connection.execute("SELECT COUNT(*) FROM handle WHERE id = ?", (identifier,)) + if db.connection.fetchone()[0] == 0: + invalid_ids.append(identifier) + else: + valid_ids.append(identifier) + + if not valid_ids: + return { + "messages": [], + "total_count": 0, + "warnings": [f"None of the provided identifiers were found in the database: {', '.join(invalid_ids)}"] + } if not DB_PATH.exists(): raise FileNotFoundError(f"Messages database not found at: {DB_PATH}") @@ -82,47 +229,127 @@ def get_chat_transcript( # Suppress stdout to hide progress bars with contextlib.redirect_stdout(io.StringIO()): with MessageDBConnection() as db: - # Create Messages object for the phone number - messages = db.Messages("person", phone_number, numbers=[phone_number]) - - # Set default date range to last 7 days if not specified - if not start_date and not end_date: - end_dt = datetime.now() - start_dt = end_dt - timedelta(days=7) - start_date = start_dt.strftime("%Y-%m-%d") - end_date = end_dt.strftime("%Y-%m-%d") + try: + # Build query for multiple identifiers using UNION approach + placeholders = ','.join(['?' for _ in valid_ids]) + query = f""" + -- Get messages from direct handle associations + SELECT DISTINCT m1.ROWID, m1.guid, m1.text, m1.is_from_me, m1.date, + m1.attributedBody, m1.message_summary_info, m1.handle_id + FROM message m1 + WHERE m1.handle_id IN ( + SELECT rowid FROM handle + WHERE id IN ({placeholders}) + ) + UNION + -- Get messages from chat associations + SELECT DISTINCT m2.ROWID, m2.guid, m2.text, m2.is_from_me, m2.date, + m2.attributedBody, m2.message_summary_info, m2.handle_id + FROM message m2 + JOIN chat_message_join cmj ON m2.ROWID = cmj.message_id + JOIN chat c ON cmj.chat_id = c.ROWID + JOIN chat_handle_join chj ON c.ROWID = chj.chat_id + WHERE chj.handle_id IN ( + SELECT rowid FROM handle + WHERE id IN ({placeholders}) + ) + ORDER BY date ASC + """ + # Execute with valid_ids twice since we have two placeholders sets + db.connection.execute(query, valid_ids + valid_ids) + rows = db.connection.fetchall() + + # Process messages + filtered_messages = [] + for row in rows: + rowid, guid, text, is_from_me, date, attributed_body, message_summary_info, handle_id = row + + # Convert database timestamp to datetime + msg_dt = datetime.fromtimestamp(date/1000000000 + 978307200).astimezone(timezone.utc) + msg_date = msg_dt.date() + + if start_date: + start_dt = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc) + if msg_date < start_dt.date(): + continue + + if end_date: + end_dt = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc) + if msg_date > end_dt.date(): + continue + + # Set up downloads directory for attachments + downloads_dir = Path.home() / "Downloads" + + # Extract text from various possible locations + message_text = extract_message_text(text, attributed_body, message_summary_info) + + # Get attachments if any + attachments = [] + if rowid in db.attachment_list.message_join: + att_list = db.attachment_list.message_join[rowid] + for att in att_list: + if att in db.attachment_list.attachment_list: + attachment = db.attachment_list.attachment_list[att] + if not attachment.missing: + try: + # Copy and potentially convert the attachment + src_path = Path(attachment.original_path) + new_path = copy_and_convert_attachment(src_path, downloads_dir) + if new_path: # Only add if copy/conversion succeeded + attachments.append({ + 'path': str(new_path), + 'mime_type': 'image/jpeg' if src_path.suffix.lower() == '.heic' else attachment.mime_type + }) + except Exception as e: + print(f"Failed to copy attachment: {e}") + + filtered_messages.append({ + "text": message_text, + "date": msg_dt.strftime("%Y-%m-%d %H:%M:%SZ"), + "is_from_me": bool(is_from_me), + "has_attachments": bool(attachments), + "attachments": attachments + }) - # Filter messages by date if specified - filtered_messages = [] - for msg in messages.message_list: - msg_date = datetime.strptime(msg.date[:10], "%Y-%m-%d") + response = { + "messages": filtered_messages, + "total_count": len(filtered_messages) + } - if start_date: - start_dt = datetime.fromisoformat(start_date) - if msg_date < start_dt: - continue - - if end_date: - end_dt = datetime.fromisoformat(end_date) - if msg_date > end_dt: - continue - - filtered_messages.append({ - "text": msg.text, - "date": msg.date, - "is_from_me": msg.is_from_me, - "has_attachments": bool(msg.attachments), - "attachments": [ - { - "mime_type": att.mime_type if hasattr(att, 'mime_type') else None, - "filename": att.filename if hasattr(att, 'filename') else None, - "file_path": att.original_path if hasattr(att, 'original_path') else None, - "is_missing": att.missing if hasattr(att, 'missing') else False - } for att in msg.attachments if isinstance(att, object) - ] if msg.attachments else [] - }) + if invalid_ids: + response["warnings"] = [f"The following identifiers were not found in the database: {', '.join(invalid_ids)}"] + return response + except Exception as e: + # Handle any errors during message retrieval + print(f"Error retrieving messages: {str(e)}") + return { + "messages": [], + "total_count": 0, + "error": str(e) + } + +@mcp.tool() +def list_conversations() -> Dict[str, Any]: + """List all conversations in the Messages database. + + Returns: + Dictionary containing the list of conversations with participant info and last message dates + """ + if not DB_PATH.exists(): + raise FileNotFoundError(f"Messages database not found at: {DB_PATH}") + + # Suppress stdout to hide progress bars + with contextlib.redirect_stdout(io.StringIO()): + with MessageDBConnection() as db: + # Get formatted list of all chats + conversations = db.chats.get_chats() + + # Split into list and count total + conversation_list = conversations.split('\n') + return { - "messages": filtered_messages, - "total_count": len(filtered_messages) - } \ No newline at end of file + "conversations": conversation_list, + "total_count": len(conversation_list) + }