Compare commits

...

14 commits

Author SHA1 Message Date
Jeremiah Lowin
5dc7baa5a8
Add plugin auth hook and install-time contributions (#4022) 2026-05-04 12:09:33 -04:00
Jeremiah Lowin
18ddf28b79
Convert skills providers to the Skills plugin (#4017) 2026-04-22 15:43:53 -04:00
Jeremiah Lowin
a82979e433
Convert OpenAPI provider to the OpenAPI plugin (#4015) 2026-04-22 13:56:56 -04:00
Jeremiah Lowin
03f4a90e60
Convert prompts-as-tools and resources-as-tools to plugins (#4012) 2026-04-22 10:29:16 -04:00
Jeremiah Lowin
19fa2fc33e
Convert code-mode to the CodeMode plugin (#4002) 2026-04-22 09:46:29 -04:00
Jeremiah Lowin
3c0d248526
Convert search transforms to the Search plugin (#3989) 2026-04-20 19:44:41 -04:00
Jeremiah Lowin
34cb2218dc
Make PluginMeta.version optional; bundled plugins default to None (#3991) 2026-04-20 14:40:25 -04:00
Jeremiah Lowin
67f226d453
Enforce JSON-serializable contract on Plugin Config (#3986) 2026-04-20 13:00:00 -04:00
Jeremiah Lowin
cc290b3a2e
Make Plugin generic over its Config model (#3983) 2026-04-20 10:39:58 -04:00
Jeremiah Lowin
ff0ae10d88
Add Plugin.capabilities() hook and auto-derive Plugin.meta (#3982) 2026-04-19 11:41:47 -04:00
Jeremiah Lowin
e2e49f77d2
Add PluginMeta.from_package() helper (#3974) 2026-04-19 08:56:42 -04:00
Jeremiah Lowin
769e998017
Reject plugin registration after the setup pass completes (#3973) 2026-04-18 20:22:27 -04:00
Jeremiah Lowin
54e83367a3
Replace plugin setup/teardown with run(server) async context manager (#3972) 2026-04-18 19:23:14 -04:00
Jeremiah Lowin
823ea4c5fc
Add new FastMCP Plugin support (#3970) 2026-04-18 19:10:03 -04:00
102 changed files with 8841 additions and 3378 deletions

View file

@ -13,8 +13,11 @@ repos:
types_or: [yaml, json5]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.10
# Ruff version. Keep in sync with the `ruff` pin in uv.lock so
# `uv run ruff format` locally and `prek run` / CI use the same
# ruleset — otherwise minor-version drift produces line-join and
# trailing-comma diffs that only show up in CI.
rev: v0.15.8
hooks:
# Run the linter.
- id: ruff-check

View file

@ -67,6 +67,7 @@ When modifying MCP functionality, changes typically need to be applied across al
- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
- **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session.
- **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view <num> --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted.
- **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship.
### Releases

View file

@ -10,9 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.1.0" />
<Warning>
CodeMode is experimental. The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice.
</Warning>
<Note>
The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice.
</Note>
Standard MCP tool usage has two scaling problems. First, every tool in the catalog is loaded into the LLM's context upfront — with hundreds of tools, that's tens of thousands of tokens spent before the LLM even reads the user's request. Second, every tool call is a round-trip: the LLM calls a tool, the result passes back through the context window, the LLM reasons about it, calls another tool, and so on. Intermediate results that only exist to feed the next step still burn tokens flowing through the model.
@ -26,13 +26,13 @@ The approach was introduced by Cloudflare in [Code Mode](https://blog.cloudflare
CodeMode requires the `code-mode` extra for sandbox support. Install it with `pip install "fastmcp[code-mode]"`.
</Tip>
You take a normal server with normally registered tools and add a `CodeMode` transform. The transform wraps your existing tools in the code mode machinery — your tool functions don't change at all:
You take a normal server with normally registered tools and attach the `CodeMode` plugin. The plugin wraps your existing tools in the code mode machinery — your tool functions don't change at all:
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
mcp = FastMCP("Server", plugins=[CodeMode()])
@mcp.tool
def add(x: int, y: int) -> int:
@ -165,7 +165,7 @@ If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` para
`ListTools` isn't included in the defaults — for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips:
```python
from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode, ListTools, GetSchemas
code_mode = CodeMode(
discovery_tools=[ListTools(), GetSchemas()],
@ -182,23 +182,23 @@ The default. The LLM searches for candidates, inspects schemas for the ones it w
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
mcp = FastMCP("Server", plugins=[CodeMode()])
```
If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure:
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import GetTags, Search, GetSchemas
code_mode = CodeMode(
discovery_tools=[GetTags(), Search(), GetSchemas()],
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
### Two-Stage
@ -207,14 +207,14 @@ Search returns parameter schemas inline, so the LLM can go straight from search
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import Search, GetSchemas
code_mode = CodeMode(
discovery_tools=[Search(default_detail="detailed"), GetSchemas()],
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
`GetSchemas` is still available as a fallback — the LLM can call it with `detail="full"` if it encounters a tool with complex nested parameters where the compact markdown isn't enough.
@ -225,7 +225,7 @@ Skip discovery entirely and bake tool instructions into the execute tool's descr
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
code_mode = CodeMode(
discovery_tools=[],
@ -237,7 +237,7 @@ code_mode = CodeMode(
),
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
## Custom Discovery Tools
@ -247,8 +247,8 @@ Discovery tools are composable — you can mix the built-ins with your own. Each
Here's a minimal example:
```python
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import GetToolCatalog, GetSchemas
from fastmcp.server.context import Context
from fastmcp.tools.tool import Tool
@ -268,7 +268,7 @@ The LLM sees the docstring of each discovery tool's inner function as its descri
Discovery tools and the execute tool can also have custom names:
```python
from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
from fastmcp.server.plugins.code_mode import Search, GetSchemas
code_mode = CodeMode(
discovery_tools=[
@ -278,7 +278,7 @@ code_mode = CodeMode(
execute_tool_name="run_workflow",
)
mcp = FastMCP("Server", transforms=[code_mode])
mcp = FastMCP("Server", plugins=[code_mode])
```
## Sandbox Configuration
@ -288,14 +288,14 @@ mcp = FastMCP("Server", transforms=[code_mode])
The default `MontySandboxProvider` can enforce execution limits — timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely.
```python
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import MontySandboxProvider
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import MontySandboxProvider
sandbox = MontySandboxProvider(
limits={"max_duration_secs": 10, "max_memory": 50_000_000},
)
mcp = FastMCP("Server", transforms=[CodeMode(sandbox_provider=sandbox)])
mcp = FastMCP("Server", plugins=[CodeMode(sandbox_provider=sandbox)])
```
All keys are optional — omit any to leave that dimension uncapped:
@ -316,8 +316,8 @@ You can replace the default sandbox with any object implementing the `SandboxPro
from collections.abc import Callable
from typing import Any
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import SandboxProvider
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import SandboxProvider
class RemoteSandboxProvider:
async def run(
@ -332,7 +332,7 @@ class RemoteSandboxProvider:
mcp = FastMCP(
"Server",
transforms=[CodeMode(sandbox_provider=RemoteSandboxProvider())],
plugins=[CodeMode(sandbox_provider=RemoteSandboxProvider())],
)
```

View file

@ -24,14 +24,14 @@ This means any client that can call tools can now access prompts, even if the cl
Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls.
<Note>
`PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there.
`PromptsAsTools` is a plugin — register it on a FastMCP server (not a raw Provider). The generated tools call back into the server's middleware chain at runtime. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and register the plugin there.
</Note>
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import PromptsAsTools
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("My Server")
mcp = FastMCP("My Server", plugins=[PromptsAsTools()])
@mcp.prompt
def analyze_code(code: str, language: str = "python") -> str:
@ -42,9 +42,6 @@ def analyze_code(code: str, language: str = "python") -> str:
def explain_concept(concept: str) -> str:
"""Explain a programming concept."""
return f"Explain: {concept}"
# Add the transform - creates list_prompts and get_prompt tools
mcp.add_transform(PromptsAsTools(mcp))
```
Clients now see three items: whatever tools you defined directly, plus `list_prompts` and `get_prompt`.

View file

@ -24,14 +24,14 @@ This means any client that can call tools can now access resources, even if the
Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls.
<Note>
`ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there.
`ResourcesAsTools` is a plugin — register it on a FastMCP server (not a raw Provider). The generated tools call back into the server's middleware chain at runtime. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and register the plugin there.
</Note>
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import ResourcesAsTools
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("My Server")
mcp = FastMCP("My Server", plugins=[ResourcesAsTools()])
@mcp.resource("config://app")
def app_config() -> str:
@ -42,9 +42,6 @@ def app_config() -> str:
def user_profile(user_id: str) -> str:
"""Get a user's profile by ID."""
return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}'
# Add the transform - creates list_resources and read_resource tools
mcp.add_transform(ResourcesAsTools(mcp))
```
Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`.

View file

@ -1,4 +1,4 @@
"""Example: CodeMode transform — search and execute tools via code.
"""Example: CodeMode plugin — search and execute tools via code.
CodeMode replaces the entire tool catalog with two meta-tools: `search`
(keyword-based tool discovery) and `execute` (run Python code that chains
@ -13,9 +13,9 @@ Run with:
"""
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("CodeMode Demo")
mcp = FastMCP("CodeMode Demo", plugins=[CodeMode()])
@mcp.tool
@ -74,10 +74,10 @@ def read_file(path: str) -> str:
return f.read()
# CodeMode collapses all 8 tools into just `search` + `execute`.
# The LLM discovers tools via keyword search, then writes Python
# scripts that chain multiple tool calls in a single round-trip.
mcp.add_transform(CodeMode())
# CodeMode (registered at construction above) collapses all 8 tools
# into just `search` + `execute`. The LLM discovers tools via keyword
# search, then writes Python scripts that chain multiple tool calls in
# a single round-trip.
if __name__ == "__main__":

View file

@ -1,4 +1,4 @@
"""Example: Expose prompts as tools using PromptsAsTools transform.
"""Example: Expose prompts as tools using the PromptsAsTools plugin.
This example shows how to use PromptsAsTools to make prompts accessible
to clients that only support tools (not the prompts protocol).
@ -8,9 +8,9 @@ Run with:
"""
from fastmcp import FastMCP
from fastmcp.server.transforms import PromptsAsTools
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("Prompt Tools Demo")
mcp = FastMCP("Prompt Tools Demo", plugins=[PromptsAsTools()])
# Simple prompt without arguments
@ -78,8 +78,8 @@ Please provide:
"""
# Add the transform - this creates list_prompts and get_prompt tools
mcp.add_transform(PromptsAsTools(mcp))
# PromptsAsTools (registered at construction above) adds list_prompts
# and get_prompt synthetic tools so tools-only clients can drive prompts.
if __name__ == "__main__":

View file

@ -1,4 +1,4 @@
"""Example: Expose resources as tools using ResourcesAsTools transform.
"""Example: Expose resources as tools using the ResourcesAsTools plugin.
This example shows how to use ResourcesAsTools to make resources accessible
to clients that only support tools (not the resources protocol).
@ -8,9 +8,9 @@ Run with:
"""
from fastmcp import FastMCP
from fastmcp.server.transforms import ResourcesAsTools
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("Resource Tools Demo")
mcp = FastMCP("Resource Tools Demo", plugins=[ResourcesAsTools()])
# Static resource - has a fixed URI
@ -57,8 +57,8 @@ def read_file(directory: str, filename: str) -> str:
return f"Contents of {directory}/{filename}"
# Add the transform - this creates list_resources and read_resource tools
mcp.add_transform(ResourcesAsTools(mcp))
# ResourcesAsTools (registered at construction above) adds list_resources
# and read_resource synthetic tools so tools-only clients can drive resources.
if __name__ == "__main__":

View file

@ -1,21 +0,0 @@
# Search Transforms
When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. Search transforms collapse the full tool catalog behind a search interface — clients see only `search_tools` and `call_tool`, and discover the real tools on demand.
## Two search strategies
**Regex** (`RegexSearchTransform`) — clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for.
**BM25** (`BM25SearchTransform`) — clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change.
Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results.
## Run
```bash
# Regex
uv run python examples/search/client_regex.py
# BM25
uv run python examples/search/client_bm25.py
```

View file

@ -0,0 +1,28 @@
# ToolSearch plugin
When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. The `ToolSearch` plugin collapses the full tool catalog behind a search interface — clients see only `search_tools` and `call_tool`, and discover the real tools on demand.
## Two search strategies
**Regex** (`strategy="regex"`) — clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for.
**BM25** (`strategy="bm25"`) — clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change.
Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results.
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
mcp = FastMCP("Server", plugins=[ToolSearch(ToolSearchConfig(strategy="regex"))])
```
## Run
```bash
# Regex
uv run python examples/tool_search/client_regex.py
# BM25
uv run python examples/tool_search/client_bm25.py
```

View file

@ -4,7 +4,7 @@ BM25 search accepts natural language queries instead of regex patterns.
This client shows how relevance ranking surfaces the best matches.
Run with:
uv run python examples/search/client_bm25.py
uv run python examples/tool_search/client_bm25.py
"""
import asyncio
@ -65,7 +65,7 @@ def _tool_table(
async def main():
async with Client("examples/search/server_bm25.py") as client:
async with Client("examples/tool_search/server_bm25.py") as client:
console.print()
console.rule("[bold]BM25 Search Transform[/bold]")
console.print()

View file

@ -4,7 +4,7 @@ Regex search lets clients find tools by matching patterns against tool names
and descriptions. Precise when you know what you're looking for.
Run with:
uv run python examples/search/client_regex.py
uv run python examples/tool_search/client_regex.py
"""
import asyncio
@ -65,7 +65,7 @@ def _tool_table(
async def main():
async with Client("examples/search/server_regex.py") as client:
async with Client("examples/tool_search/server_regex.py") as client:
console.print()
console.rule("[bold]Regex Search Transform[/bold]")
console.print()

View file

@ -9,15 +9,20 @@ The index is built lazily and rebuilt automatically when the tool catalog
changes (e.g. tools added or removed between requests).
Run with:
uv run python examples/search/server_bm25.py
uv run python examples/tool_search/server_bm25.py
"""
import os
from fastmcp import FastMCP
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
mcp = FastMCP("BM25 Search Demo")
mcp = FastMCP(
"BM25 Search Demo",
plugins=[
ToolSearch(ToolSearchConfig(max_results=5, always_visible=["list_files"]))
],
)
@mcp.tool
@ -75,10 +80,9 @@ def read_file(path: str) -> str:
# BM25 search with a higher result limit for this larger catalog.
# The `always_visible` option keeps specific tools in list_tools output
# alongside the search/call tools — useful for tools the LLM should
# always know about.
mcp.add_transform(BM25SearchTransform(max_results=5, always_visible=["list_files"]))
# The ToolSearch plugin is configured at server construction above —
# `always_visible` keeps specific tools in list_tools alongside the
# synthetic search/call tools.
if __name__ == "__main__":

View file

@ -10,13 +10,16 @@ Clients use `search_tools` with a regex pattern to find relevant tools, then
`call_tool` to execute them by name.
Run with:
uv run python examples/search/server_regex.py
uv run python examples/tool_search/server_regex.py
"""
from fastmcp import FastMCP
from fastmcp.server.transforms.search import RegexSearchTransform
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
mcp = FastMCP("Regex Search Demo")
mcp = FastMCP(
"Regex Search Demo",
plugins=[ToolSearch(ToolSearchConfig(strategy="regex", max_results=3))],
)
# Register a variety of tools across different domains.
@ -65,9 +68,8 @@ def to_uppercase(text: str) -> str:
return text.upper()
# Apply the regex search transform.
# max_results limits how many tools a single search returns.
mcp.add_transform(RegexSearchTransform(max_results=3))
# The ToolSearch plugin is configured at server construction above —
# nothing else to wire here.
if __name__ == "__main__":

View file

@ -23,6 +23,7 @@ from fastmcp.cli.auth import auth_app
from fastmcp.cli.client import call_command, discover_command, list_command
from fastmcp.cli.generate import generate_cli_command
from fastmcp.cli.install import install_app
from fastmcp.cli.plugin import plugin_app
from fastmcp.cli.tasks import tasks_app
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
from fastmcp.utilities.inspect import (
@ -1102,6 +1103,9 @@ app.command(install_app)
# Add tasks subcommand group
app.command(tasks_app)
# Add plugin subcommand group
app.command(plugin_app)
# Add client query commands
app.command(list_command, name="list")
app.command(call_command, name="call")

102
src/fastmcp/cli/plugin.py Normal file
View file

@ -0,0 +1,102 @@
"""CLI commands for working with FastMCP plugins.
Currently exposes a single verb, `fastmcp plugin manifest`, which imports
a plugin class and emits its manifest (metadata + config schema + entry
point) as JSON. The manifest is the artifact downstream consumers
(Horizon, registries, CI tooling) ingest to discover and configure the
plugin without importing its module themselves.
"""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
from typing import Annotated
import cyclopts
from cyclopts import Parameter
from fastmcp.server.plugins import Plugin
from fastmcp.server.plugins.base import PluginError
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli.plugin")
plugin_app = cyclopts.App(
name="plugin",
help="Work with FastMCP plugins.",
default_parameter=Parameter(negative=()),
)
def _resolve_plugin_class(entry_point: str) -> type[Plugin]:
"""Import a plugin class from a `module.path:ClassName` spec.
The class portion may be dotted (e.g. `module:Outer.MyPlugin`) to
resolve a nested class, matching the `entry_point` format
`Plugin.manifest()` emits via `__qualname__`.
"""
if ":" not in entry_point:
raise ValueError(
f"Invalid plugin reference {entry_point!r}: "
f"expected 'module.path:ClassName'"
)
module_path, class_name = entry_point.split(":", 1)
try:
module = importlib.import_module(module_path)
except ImportError as exc:
raise ImportError(f"Could not import module {module_path!r}: {exc}") from exc
cls: object = module
for part in class_name.split("."):
try:
cls = getattr(cls, part)
except AttributeError as exc:
raise AttributeError(
f"Module {module_path!r} has no attribute {class_name!r}"
) from exc
if not isinstance(cls, type) or not issubclass(cls, Plugin):
raise TypeError(f"{entry_point!r} does not refer to a fastmcp.Plugin subclass")
return cls
@plugin_app.command(name="manifest")
def manifest_command(
entry_point: Annotated[
str,
Parameter(help="Plugin reference in 'module.path:ClassName' form."),
],
output: Annotated[
Path | None,
Parameter(
name=["--output", "-o"],
help="Write manifest JSON to this path instead of stdout.",
),
] = None,
) -> None:
"""Emit a plugin's manifest as JSON.
Imports the referenced plugin class and prints its manifest to stdout,
or writes it to the path given by `-o/--output`.
"""
try:
cls = _resolve_plugin_class(entry_point)
except (ImportError, AttributeError, TypeError, ValueError) as exc:
logger.error(str(exc))
sys.exit(1)
try:
manifest = cls.manifest()
except (PluginError, TypeError) as exc:
logger.error(str(exc))
sys.exit(1)
if output is None:
print(json.dumps(manifest, indent=2, sort_keys=False))
return
output.write_text(json.dumps(manifest, indent=2, sort_keys=False))
print(f"Wrote manifest for {cls.meta.name} to {output}")

View file

@ -1,4 +1,4 @@
"""Deprecated: Import from fastmcp.server.providers.openapi instead."""
"""Deprecated: Import from fastmcp.server.plugins.openapi instead."""
import warnings
@ -7,23 +7,27 @@ from fastmcp.exceptions import FastMCPDeprecationWarning
# Deprecated in 2.14 when OpenAPI support was promoted out of experimental
warnings.warn(
"Importing from fastmcp.experimental.server.openapi is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# Import from canonical location
from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402
from fastmcp.server.providers.openapi import ( # noqa: E402
ComponentFn as ComponentFn,
from fastmcp.server.plugins.openapi import ( # noqa: E402
MCPType as MCPType,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.components import ( # noqa: E402
OpenAPIResource as OpenAPIResource,
OpenAPIResourceTemplate as OpenAPIResourceTemplate,
OpenAPITool as OpenAPITool,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.routing import ( # noqa: E402
ComponentFn as ComponentFn,
RouteMapFn as RouteMapFn,
)
from fastmcp.server.providers.openapi.routing import ( # noqa: E402
from fastmcp.server.plugins.openapi.routing import ( # noqa: E402
DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS,
_determine_route_type as _determine_route_type,
)

View file

@ -1,568 +1,62 @@
import importlib
import json
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol
"""Deprecation shim — code mode moved to `fastmcp.server.plugins.code_mode`.
if TYPE_CHECKING:
from pydantic_monty import ResourceLimits
The preferred API is now the `CodeMode` plugin:
from mcp.types import TextContent
from pydantic import Field
from fastmcp import FastMCP
from fastmcp.server.plugins.code_mode import CodeMode
from fastmcp.exceptions import NotFoundError
from fastmcp.server.context import Context
from fastmcp.server.transforms import GetToolNext
from fastmcp.server.transforms.catalog import CatalogTransform
from fastmcp.server.transforms.search.base import (
serialize_tools_for_output_json,
serialize_tools_for_output_markdown,
)
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.async_utils import is_coroutine_function
from fastmcp.utilities.versions import VersionSpec
mcp = FastMCP("Server", plugins=[CodeMode()])
# ---------------------------------------------------------------------------
# Type aliases
# ---------------------------------------------------------------------------
For backcompat, this module keeps `CodeMode` bound to the **transform**
class (so existing `mcp.add_transform(CodeMode())` code keeps working).
The transform is also exported under its new canonical name,
`CodeModeTransform`. Sandbox providers, discovery-tool factories, and
related helpers re-export from the new location unchanged.
GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]]
"""Async callable that returns the auth-filtered tool catalog."""
SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]]
"""Async callable that searches a tool sequence by query string."""
DiscoveryToolFactory = Callable[[GetToolCatalog], Tool]
"""Factory that receives catalog access and returns a synthetic Tool."""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
if is_coroutine_function(fn):
return fn
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs)
return wrapper
def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str:
"""Convert a ToolResult for use in the sandbox.
- Output schema present structured_content dict (matches the schema)
- Otherwise concatenated text content as a string
"""
if result.structured_content is not None:
return result.structured_content
parts: list[str] = []
for content in result.content:
if isinstance(content, TextContent):
parts.append(content.text)
else:
parts.append(str(content))
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Sandbox providers
# ---------------------------------------------------------------------------
class SandboxProvider(Protocol):
"""Interface for executing LLM-generated Python code in a sandbox.
WARNING: The ``code`` parameter passed to ``run`` contains untrusted,
LLM-generated Python. Implementations MUST execute it in an isolated
sandbox never with plain ``exec()``. Use ``MontySandboxProvider``
(backed by ``pydantic-monty``) for production workloads.
"""
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any: ...
class MontySandboxProvider:
"""Sandbox provider backed by `pydantic-monty`.
Args:
limits: Resource limits for sandbox execution. Supported keys:
``max_duration_secs`` (float), ``max_allocations`` (int),
``max_memory`` (int), ``max_recursion_depth`` (int),
``gc_interval`` (int). All are optional; omit a key to
leave that limit uncapped.
"""
def __init__(
self,
*,
limits: "ResourceLimits | None" = None,
) -> None:
self.limits = limits
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any:
try:
pydantic_monty = importlib.import_module("pydantic_monty")
except ModuleNotFoundError as exc:
raise ImportError(
"CodeMode requires pydantic-monty for the Monty sandbox provider. "
"Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider."
) from exc
inputs = inputs or {}
async_functions = {
key: _ensure_async(value)
for key, value in (external_functions or {}).items()
}
monty = pydantic_monty.Monty(code, inputs=list(inputs))
return await monty.run_async(
inputs=inputs or None,
external_functions=async_functions or None,
limits=self.limits,
)
# ---------------------------------------------------------------------------
# Built-in discovery tools
# ---------------------------------------------------------------------------
ToolDetailLevel = Literal["brief", "detailed", "full"]
"""Detail level for discovery tool output.
- ``"brief"``: tool names and one-line descriptions
- ``"detailed"``: compact markdown with parameter names, types, and required markers
- ``"full"``: complete JSON schema
This path issues a `FastMCPDeprecationWarning` on import a
`DeprecationWarning` subclass that fastmcp enables by default (plain
`DeprecationWarning` is suppressed by CPython's default filter, so
users wouldn't see the notice).
"""
def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str:
"""Render tools at the requested detail level.
The same detail value produces the same output format regardless of
which discovery tool calls this, so ``detail="detailed"`` on Search
gives identical formatting to ``detail="detailed"`` on GetSchemas.
"""
if not tools:
if detail == "full":
return json.dumps([], indent=2)
return "No tools matched the query."
if detail == "full":
return json.dumps(serialize_tools_for_output_json(tools), indent=2)
if detail == "detailed":
return serialize_tools_for_output_markdown(tools)
# brief
lines: list[str] = []
for tool in tools:
desc = f": {tool.description}" if tool.description else ""
lines.append(f"- {tool.name}{desc}")
return "\n".join(lines)
class Search:
"""Discovery tool factory that searches the catalog by query.
Args:
search_fn: Async callable ``(tools, query) -> matching_tools``.
Defaults to BM25 ranking.
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level for search results.
``"brief"`` returns tool names and descriptions only.
``"detailed"`` returns compact markdown with parameter schemas.
``"full"`` returns complete JSON tool definitions.
default_limit: Maximum number of results to return.
The LLM can override this per call. ``None`` means no limit.
"""
def __init__(
self,
*,
search_fn: SearchFn | None = None,
name: str = "search",
default_detail: ToolDetailLevel | None = None,
default_limit: int | None = None,
) -> None:
if search_fn is None:
from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
_bm25 = BM25SearchTransform(max_results=default_limit or 50)
search_fn = _bm25._search
self._search_fn = search_fn
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "brief"
self._default_limit = default_limit
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
search_fn = self._search_fn
default_detail = self._default_detail
default_limit = self._default_limit
async def search(
query: Annotated[str, "Search query to find available tools"],
tags: Annotated[
list[str] | None,
"Filter to tools with any of these tags before searching",
] = None,
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
limit: Annotated[
int | None,
"Maximum number of results to return",
] = default_limit,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""Search for available tools by query.
Returns matching tools ranked by relevance.
"""
catalog = await get_catalog(ctx)
catalog_size = len(catalog)
tools: Sequence[Tool] = catalog
if tags:
tag_set = set(tags)
has_untagged = "untagged" in tag_set
real_tags = tag_set - {"untagged"}
tools = [
t
for t in tools
if (t.tags & real_tags) or (has_untagged and not t.tags)
]
results = await search_fn(tools, query)
if limit is not None:
results = results[:limit]
rendered = _render_tools(results, detail)
if len(results) < catalog_size and detail != "full":
n = len(results)
rendered = f"{n} of {catalog_size} tools:\n\n{rendered}"
return rendered
return Tool.from_function(fn=search, name=self._name)
class GetSchemas:
"""Discovery tool factory that returns schemas for tools by name.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level for schema results.
``"brief"`` returns tool names and descriptions only.
``"detailed"`` renders compact markdown with parameter names,
types, and required markers.
``"full"`` returns the complete JSON schema.
"""
def __init__(
self,
*,
name: str = "get_schema",
default_detail: ToolDetailLevel | None = None,
) -> None:
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "detailed"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def get_schema(
tools: Annotated[
list[str],
"List of tool names to get schemas for",
],
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""Get parameter schemas for specific tools.
Use after searching to get the detail needed to call a tool.
"""
catalog = await get_catalog(ctx)
catalog_by_name = {t.name: t for t in catalog}
matched = [catalog_by_name[n] for n in tools if n in catalog_by_name]
not_found = [n for n in tools if n not in catalog_by_name]
if not matched and not_found:
return f"Tools not found: {', '.join(not_found)}"
if detail == "full":
data = serialize_tools_for_output_json(matched)
if not_found:
data.append({"not_found": not_found})
return json.dumps(data, indent=2)
result = _render_tools(matched, detail)
if not_found:
result += f"\n\nTools not found: {', '.join(not_found)}"
return result
return Tool.from_function(fn=get_schema, name=self._name)
class GetTags:
"""Discovery tool factory that lists tool tags from the catalog.
Reads ``tool.tags`` from the catalog and groups tools by tag. Tools
without tags appear under ``"untagged"``.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level.
``"brief"`` returns tag names with tool counts.
``"full"`` lists all tools under each tag.
"""
def __init__(
self,
*,
name: str = "tags",
default_detail: Literal["brief", "full"] | None = None,
) -> None:
self._name = name
self._default_detail: Literal["brief", "full"] = default_detail or "brief"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def tags(
detail: Annotated[
Literal["brief", "full"],
"Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""List available tool tags.
Use to browse available tools by tag before searching.
"""
catalog = await get_catalog(ctx)
by_tag: dict[str, list[Tool]] = {}
for tool in catalog:
if tool.tags:
for tag in tool.tags:
by_tag.setdefault(tag, []).append(tool)
else:
by_tag.setdefault("untagged", []).append(tool)
if not by_tag:
return "No tools available."
if detail == "brief":
lines = [
f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})"
for tag, tools in sorted(by_tag.items())
]
return "\n".join(lines)
blocks: list[str] = []
for tag, tools in sorted(by_tag.items()):
lines = [f"### {tag}"]
for tool in tools:
desc = f": {tool.description}" if tool.description else ""
lines.append(f"- {tool.name}{desc}")
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
return Tool.from_function(fn=tags, name=self._name)
class ListTools:
"""Discovery tool factory that lists all tools in the catalog.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level.
``"brief"`` returns tool names and one-line descriptions.
``"detailed"`` returns compact markdown with parameter schemas.
``"full"`` returns the complete JSON schema.
"""
def __init__(
self,
*,
name: str = "list_tools",
default_detail: ToolDetailLevel | None = None,
) -> None:
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "brief"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def list_tools(
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""List all available tools.
Use to see the full catalog before searching or calling tools.
"""
catalog = await get_catalog(ctx)
return _render_tools(catalog, detail)
return Tool.from_function(fn=list_tools, name=self._name)
# ---------------------------------------------------------------------------
# CodeMode
# ---------------------------------------------------------------------------
def _default_discovery_tools() -> list[DiscoveryToolFactory]:
return [Search(), GetSchemas()]
class CodeMode(CatalogTransform):
"""Transform that collapses all tools into discovery + execute meta-tools.
Discovery tools are composable via the ``discovery_tools`` parameter.
Each is a callable that receives catalog access and returns a ``Tool``.
By default, ``Search`` and ``GetSchemas`` are included for
progressive disclosure: search finds candidates, get_schema retrieves
parameter details, and execute runs code.
The ``execute`` tool is always present and provides a sandboxed Python
environment with ``call_tool(name, params)`` in scope.
"""
def __init__(
self,
*,
sandbox_provider: SandboxProvider | None = None,
discovery_tools: list[DiscoveryToolFactory] | None = None,
execute_tool_name: str = "execute",
execute_description: str | None = None,
) -> None:
super().__init__()
self.execute_tool_name = execute_tool_name
self.execute_description = execute_description
self.sandbox_provider = sandbox_provider or MontySandboxProvider()
self._discovery_factories = (
discovery_tools
if discovery_tools is not None
else _default_discovery_tools()
)
self._built_discovery_tools: list[Tool] | None = None
self._cached_execute_tool: Tool | None = None
def _build_discovery_tools(self) -> list[Tool]:
if self._built_discovery_tools is None:
tools = [
factory(self.get_tool_catalog) for factory in self._discovery_factories
]
names = {t.name for t in tools}
if self.execute_tool_name in names:
raise ValueError(
f"Discovery tool name '{self.execute_tool_name}' "
f"collides with execute_tool_name."
)
if len(names) != len(tools):
raise ValueError("Discovery tools must have unique names.")
self._built_discovery_tools = tools
return self._built_discovery_tools
async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [*self._build_discovery_tools(), self._get_execute_tool()]
async def get_tool(
self,
name: str,
call_next: GetToolNext,
*,
version: VersionSpec | None = None,
) -> Tool | None:
for tool in self._build_discovery_tools():
if tool.name == name:
return tool
if name == self.execute_tool_name:
return self._get_execute_tool()
return await call_next(name, version=version)
def _build_execute_description(self) -> str:
if self.execute_description is not None:
return self.execute_description
return (
"Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n"
"Use `return` to produce output.\n"
"Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
)
@staticmethod
def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None:
"""Find a tool by name from a pre-fetched list."""
for tool in tools:
if tool.name == name:
return tool
return None
def _get_execute_tool(self) -> Tool:
if self._cached_execute_tool is None:
self._cached_execute_tool = self._make_execute_tool()
return self._cached_execute_tool
def _make_execute_tool(self) -> Tool:
transform = self
async def execute(
code: Annotated[
str,
Field(
description=(
"Python async code to execute tool calls via call_tool(name, arguments)"
)
),
],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> Any:
"""Execute tool calls using Python code."""
async def call_tool(tool_name: str, params: dict[str, Any]) -> Any:
backend_tools = await transform.get_tool_catalog(ctx)
tool = transform._find_tool(tool_name, backend_tools)
if tool is None:
raise NotFoundError(f"Unknown tool: {tool_name}")
result = await ctx.fastmcp.call_tool(tool.name, params)
return _unwrap_tool_result(result)
return await transform.sandbox_provider.run(
code,
external_functions={"call_tool": call_tool},
)
return Tool.from_function(
fn=execute,
name=self.execute_tool_name,
description=self._build_execute_description(),
)
import warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.code_mode.discovery import (
DiscoveryToolFactory,
GetSchemas,
GetTags,
GetToolCatalog,
ListTools,
Search,
)
from fastmcp.server.plugins.code_mode.sandbox import (
MontySandboxProvider,
SandboxProvider,
)
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
# `CodeMode` at this old path stays bound to the transform class, so
# `mcp.add_transform(CodeMode(...))` keeps working. The new plugin class
# is at `fastmcp.server.plugins.code_mode.CodeMode`.
CodeMode = CodeModeTransform
warnings.warn(
"fastmcp.experimental.transforms.code_mode has moved to "
"fastmcp.server.plugins.code_mode. Prefer the CodeMode plugin: "
"`from fastmcp.server.plugins.code_mode import CodeMode` and pass "
"it via `plugins=[CodeMode(...)]`. At this old path, `CodeMode` "
"remains the transform class (also exported as `CodeModeTransform`) "
"for backcompat. The old import path will be removed in a future "
"release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
__all__ = [
"CodeMode",
"CodeModeTransform",
"DiscoveryToolFactory",
"GetSchemas",
"GetTags",
"GetToolCatalog",

View file

@ -220,6 +220,10 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]):
)
capabilities.extensions = {**existing_extensions, UI_EXTENSION_ID: {}}
# Plugin contributions apply last so plugins can override built-in
# defaults. See FastMCP._apply_plugin_capabilities for merge rules.
capabilities = self.fastmcp._apply_plugin_capabilities(capabilities)
return capabilities
async def run(

View file

@ -171,6 +171,16 @@ class LifespanMixin:
self._lifespan_result = user_lifespan_result
self._lifespan_result_set = True
# Plugin runtime pass: each registered plugin's `run()` async
# context manager wraps the server's lifespan. Contributions
# were already installed at add_plugin() time, so this only
# enters async runtime work before provider lifespans and
# `_started`. Partial-failure safety is automatic —
# AsyncExitStack only unwinds plugin contexts that were
# successfully entered, so a raising plugin doesn't tear down
# plugins that never entered.
await self._enter_plugin_contexts(stack)
# Start lifespans for all providers
for provider in self.providers:
await stack.enter_async_context(provider.lifespan())

View file

@ -333,7 +333,6 @@ class TransportMixin:
Returns:
A Starlette application configured with the specified transport
"""
if transport in ("streamable-http", "http"):
return create_streamable_http_app(
server=self,

View file

@ -1,12 +1,12 @@
"""OpenAPI server implementation for FastMCP.
.. deprecated::
This module is deprecated. Import from fastmcp.server.providers.openapi instead.
This module is deprecated. Import from fastmcp.server.plugins.openapi instead.
The recommended approach is to use OpenAPIProvider with FastMCP:
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")
@ -24,20 +24,26 @@ from fastmcp.exceptions import FastMCPDeprecationWarning
warnings.warn(
"fastmcp.server.openapi is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# Re-export from new canonical location
from fastmcp.server.providers.openapi import ( # noqa: E402
ComponentFn as ComponentFn,
from fastmcp.server.plugins.openapi import ( # noqa: E402
MCPType as MCPType,
OpenAPIProvider as OpenAPIProvider,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.components import ( # noqa: E402
OpenAPIResource as OpenAPIResource,
OpenAPIResourceTemplate as OpenAPIResourceTemplate,
OpenAPITool as OpenAPITool,
RouteMap as RouteMap,
)
from fastmcp.server.plugins.openapi.provider import ( # noqa: E402
OpenAPIProvider as OpenAPIProvider,
)
from fastmcp.server.plugins.openapi.routing import ( # noqa: E402
ComponentFn as ComponentFn,
RouteMapFn as RouteMapFn,
)

View file

@ -1,6 +1,6 @@
"""OpenAPI component implementations - backwards compatibility stub.
This module is deprecated. Import from fastmcp.server.providers.openapi instead.
This module is deprecated. Import from fastmcp.server.plugins.openapi instead.
"""
from __future__ import annotations
@ -11,12 +11,12 @@ from fastmcp.exceptions import FastMCPDeprecationWarning
warnings.warn(
"fastmcp.server.openapi.components is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.server.providers.openapi import ( # noqa: E402
from fastmcp.server.plugins.openapi.components import ( # noqa: E402
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,

View file

@ -22,27 +22,27 @@ __all__ = [
warnings.warn(
"fastmcp.server.openapi.routing is deprecated. "
"Import from fastmcp.server.providers.openapi instead.",
"Import from fastmcp.server.plugins.openapi instead.",
FastMCPDeprecationWarning,
stacklevel=2,
)
# Re-export from new canonical location
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
ComponentFn as ComponentFn,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
MCPType as MCPType,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
RouteMap as RouteMap,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
RouteMapFn as RouteMapFn,
)
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.routing import (
_determine_route_type as _determine_route_type,
)

View file

@ -3,7 +3,7 @@
This class is deprecated. Use FastMCP with OpenAPIProvider instead:
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")
@ -19,12 +19,9 @@ from typing import Any
import httpx
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.providers.openapi import (
ComponentFn,
OpenAPIProvider,
RouteMap,
RouteMapFn,
)
from fastmcp.server.plugins.openapi import RouteMap
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
from fastmcp.server.plugins.openapi.routing import ComponentFn, RouteMapFn
from fastmcp.server.server import FastMCP
@ -49,7 +46,7 @@ class FastMCPOpenAPI(FastMCP):
New approach:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")

View file

@ -0,0 +1,15 @@
"""FastMCP plugin primitive.
Plugins are reusable, configurable units that contribute middleware,
transforms, providers, and custom HTTP routes to a FastMCP server. See
the design document for the full specification.
Only the two user-facing primitives are re-exported here: `Plugin`
(subclass to define a plugin) and `PluginMeta` (the metadata model
plugins instantiate). Error classes live in `fastmcp.server.plugins.base`
and can be imported from there if needed.
"""
from fastmcp.server.plugins.base import Plugin, PluginMeta
__all__ = ["Plugin", "PluginMeta"]

View file

@ -0,0 +1,859 @@
"""Plugin primitive for FastMCP.
Plugins package server-side behavior middleware, component transforms,
providers, and custom HTTP routes into reusable, configurable,
distributable units. A plugin is a subclass of `Plugin` (optionally
parameterized with a pydantic config model `Plugin[MyConfig]` for
typed configuration).
See the design document for the full specification.
"""
from __future__ import annotations
import json
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from email.message import Message as EmailMessage
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Generic,
TypeVar,
cast,
get_args,
get_origin,
)
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version
from pydantic import BaseModel, ConfigDict, ValidationError
from typing_extensions import Self
import fastmcp
from fastmcp.exceptions import FastMCPError
from fastmcp.server.auth.auth import AuthProvider
from fastmcp.server.middleware import Middleware
from fastmcp.server.providers import Provider
from fastmcp.server.transforms import Transform
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
if TYPE_CHECKING:
from starlette.routing import BaseRoute
from fastmcp.server.server import FastMCP
class PluginError(FastMCPError):
"""Base class for plugin-related errors."""
class PluginConfigError(PluginError):
"""Raised when a plugin's configuration fails validation."""
class PluginCompatibilityError(PluginError):
"""Raised when a plugin declares a FastMCP version it is not compatible with."""
class PluginMeta(BaseModel):
"""Descriptive metadata for a plugin.
Users who want typed custom fields subclass this model. Users who want
to attach ad-hoc fields without defining a model put them in the
`meta` dict. Unknown top-level fields are rejected to prevent future
collisions with standard fields.
"""
name: str
"""Plugin name. Required. Must be unique within a server."""
version: str | None = None
"""Plugin's independent semver, if it has one. `None` means the
plugin is bundled with its containing package (typically fastmcp
itself) and doesn't track a separate release cadence — which is the
correct answer for first-party plugins that ship in-tree. Published
plugins derive this from their PyPI distribution via
`PluginMeta.from_package(...)`.
"""
description: str | None = None
"""Short human-readable description."""
tags: list[str] = []
"""Free-form tags for discovery and filtering."""
author: str | None = None
"""Author identifier (person, team, or org)."""
homepage: str | None = None
"""Homepage URL."""
dependencies: list[str] = []
"""PEP 508 requirement specifiers for packages required to import and
run the plugin. Includes the plugin's own containing package plus any
runtime extras. FastMCP itself is implicit and must not be listed.
"""
fastmcp_version: str | None = None
"""Optional PEP 440 specifier expressing compatibility with FastMCP
core (e.g. `">=3.0"`). Verified at registration time.
"""
meta: dict[str, Any] = {}
"""Free-form bag for custom fields that have not been standardized.
Namespaced to prevent collisions with future standard fields.
"""
model_config = ConfigDict(extra="forbid")
@classmethod
def from_package(cls, distribution: str, /, **overrides: Any) -> Self:
"""Derive plugin metadata from an installed Python distribution.
Reads `version`, `description`, `author`, and `homepage` from the
distribution's metadata (as recorded in its `pyproject.toml` and
exposed via `importlib.metadata`), and pins the distribution
itself as the sole entry in `dependencies` so the manifest
automatically reflects the containing package and stays in sync
with every new release. Runtime dependencies declared in the
distribution's `Requires-Dist` are NOT harvested; plugin authors
pass additional runtime deps via the `dependencies` override.
Any keyword argument overrides the derived value.
Example:
```python
class MyPiiRedactor(Plugin):
meta = PluginMeta.from_package(
"fastmcp-plugin-my-pii", # distribution name on PyPI
name="my-pii", # plugin identifier
tags=["security"],
)
```
Args:
distribution: The installed distribution name to read from
(e.g. `"fastmcp-plugin-my-pii"`). Must be importable via
`importlib.metadata`. Cannot be `fastmcp` itself use
`fastmcp_version` for core compatibility.
**overrides: Any `PluginMeta` field. Overrides take precedence
over the derived value. `name` is required unless a
`name` override is supplied; the distribution name is not
used as the plugin name by default since the two serve
different purposes (distribution = wheel identity, plugin
name = runtime identifier shown to Horizon / CLI users).
Raises:
PluginError: If the distribution is not installed in the
current environment, if `distribution` is `fastmcp`
(which would produce an invalid manifest), or if the
distribution's version cannot be parsed.
"""
# FastMCP itself is implicit; pinning it would produce a manifest
# that Plugin._validate_meta rejects. Plugin authors expressing
# core compatibility should use the `fastmcp_version` field.
if canonicalize_name(distribution) == "fastmcp":
raise PluginError(
f"PluginMeta.from_package({distribution!r}): "
f"`fastmcp` is implicit and must not be used as the "
f"containing distribution. Use the `fastmcp_version` "
f"field on PluginMeta to express core compatibility."
)
try:
dist = importlib_metadata.distribution(distribution)
except importlib_metadata.PackageNotFoundError as exc:
raise PluginError(
f"PluginMeta.from_package({distribution!r}): distribution "
f"is not installed in the current environment. Install it "
f"(e.g. via `uv pip install {distribution}`) before "
f"calling from_package."
) from exc
# `dist.metadata` is an email.message.Message at runtime, but
# `importlib.metadata.PackageMetadata`'s stubs don't expose that
# interface. Cast to email.message.Message to flatten header
# access (item lookup returns None on miss; `items()` yields one
# entry per header, including repeated keys like Project-URL).
raw = cast(EmailMessage, dist.metadata)
headers: dict[str, str] = {}
all_project_urls: list[str] = []
for key, value in raw.items():
if key == "Project-URL":
all_project_urls.append(value)
else:
# For repeated headers we only need one; first-wins.
headers.setdefault(key, value)
def _first_non_blank(*values: str | None) -> str | None:
"""Return the first value whose `.strip()` is truthy, or None.
Guards against whitespace-only headers silently blocking the
fallback chain (e.g. a METADATA file with `Author: ` would
otherwise make the `Author-email` fallback unreachable).
"""
for v in values:
if v is not None and v.strip():
return v.strip()
return None
derived: dict[str, Any] = {"version": dist.version}
# description ← Summary header
summary = _first_non_blank(headers.get("Summary"))
if summary:
derived["description"] = summary
# author ← Author, falling back to Author-email
author = _first_non_blank(headers.get("Author"), headers.get("Author-email"))
if author:
derived["author"] = author
# homepage ← Home-page, falling back to the first Project-URL
# whose label looks like a canonical homepage reference
homepage = _first_non_blank(headers.get("Home-page"))
if not homepage:
for entry in all_project_urls:
# Project-URL values are `"Label, URL"` pairs.
label, _, url = entry.partition(",")
if label.strip().lower() in {
"homepage",
"home",
"repository",
"source",
}:
homepage = _first_non_blank(url)
if homepage:
break
if homepage:
derived["homepage"] = homepage
# dependencies — pin the containing distribution at its current
# version, minus the local segment. PEP 440 only restricts local
# versions (`+abc.def`) from use with `>=` / `<=`; prereleases
# (`rc1`), dev (`.dev0`), and post segments are all valid there,
# so we preserve them to keep the pin meaningful for actively
# developed distributions. `Version.public` strips exactly the
# local segment.
try:
public = Version(dist.version).public
except InvalidVersion as exc:
raise PluginError(
f"PluginMeta.from_package({distribution!r}): could not "
f"parse distribution version {dist.version!r}: {exc}"
) from exc
derived["dependencies"] = [f"{distribution}>={public}"]
derived.update(overrides)
return cls(**derived)
class _EmptyConfig(BaseModel):
"""Default config for plugins that don't declare their own via the
`Plugin[ConfigType]` generic parameter."""
model_config = ConfigDict(extra="forbid")
C = TypeVar("C", bound=BaseModel)
"""Type variable for a plugin's config model. Bound to `BaseModel` so
any pydantic model is valid. Plugins without a config omit the generic
parameter; the runtime falls back to `_EmptyConfig` in that case.
"""
def _derive_plugin_name(cls_name: str) -> str:
"""Kebab-case a class name, stripping a trailing ``Plugin`` suffix.
`ChannelPlugin` `"channel"`, `CodeMode` `"code-mode"`,
`PIIRedactor` `"pii-redactor"`.
"""
# Split acronym from following capitalized word: `PIIRedactor` → `PII-Redactor`
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", cls_name)
# Split lowercase/digit from following uppercase: `CodeMode` → `Code-Mode`
name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
name = name.lower()
if name.endswith("-plugin") and name != "-plugin":
name = name[: -len("-plugin")]
return name
def _resolve_plugin_config_cls(cls: type) -> type[BaseModel] | None:
"""Resolve the config class bound to `Plugin[C]` for a subclass.
Walks `cls.__orig_bases__`, recursing through intermediate `Plugin`
subclasses and propagating TypeVar substitutions. Returns the bound
`BaseModel` subclass, or `None` if the binding is still a TypeVar
(unresolved typically an intermediate abstract base).
Raises `TypeError` if a resolved argument is concrete but not a
`BaseModel` subclass (a misuse of `Plugin[NonPydanticType]`).
"""
def _resolve(base: Any, substitutions: dict[Any, Any]) -> Any:
origin = get_origin(base)
if origin is None or not (
isinstance(origin, type) and issubclass(origin, Plugin)
):
return None
args = get_args(base)
# Apply outer-scope substitutions so a parent's TypeVar bound to
# a concrete type at this level becomes that concrete type here.
resolved_args = tuple(substitutions.get(a, a) for a in args)
if origin is Plugin:
# We're at the root parameterization.
if not resolved_args:
return None
cfg = resolved_args[0]
# Still a TypeVar: unresolved at this level of the chain.
if isinstance(cfg, TypeVar):
return None
return cfg
# Intermediate Plugin subclass. Push down its own TypeVar
# substitutions (from its `__parameters__`) and recurse into its
# bases to find the Plugin parameterization.
origin_params = getattr(origin, "__parameters__", ())
new_subs = {
**substitutions,
**dict(zip(origin_params, resolved_args, strict=False)),
}
for inner in getattr(origin, "__orig_bases__", ()):
found = _resolve(inner, new_subs)
if found is not None:
return found
return None
for base in getattr(cls, "__orig_bases__", ()):
resolved = _resolve(base, substitutions={})
if resolved is None:
continue
if not (isinstance(resolved, type) and issubclass(resolved, BaseModel)):
raise TypeError(
f"{cls.__name__}: Plugin[...] generic parameter must be a "
f"pydantic BaseModel subclass, got {resolved!r}"
)
return resolved
return None
class Plugin(Generic[C]):
"""Base class for FastMCP plugins.
Subclass to define a plugin. A subclass may optionally declare a
class-level `meta` attribute (a `PluginMeta` instance); if omitted,
a default is derived from the class name (kebab-cased, trailing
`Plugin` stripped) and no independent version. Declare `meta` explicitly
when publishing or when Horizon/registry-facing metadata matters.
**Config typing.** Parameterize `Plugin` with a pydantic model to
give your plugin typed configuration `self.config.<field>` is then
correctly typed in editors and type checkers, and passing a dict or
model instance to the constructor validates against the model.
Plugins without a config omit the parameter.
Example:
```python
from pydantic import BaseModel
from fastmcp.server.plugins import Plugin, PluginMeta
class PIIRedactorConfig(BaseModel):
patterns: list[str] = ["ssn", "email"]
class PIIRedactor(Plugin[PIIRedactorConfig]):
meta = PluginMeta(name="pii-redactor", version="0.3.0")
def middleware(self):
# self.config is typed as PIIRedactorConfig
return [PIIMiddleware(self.config.patterns)]
```
"""
meta: ClassVar[PluginMeta]
"""Class-level metadata. Auto-derived from the class name with no
independent version if the subclass doesn't declare one — the
honest default for bundled first-party plugins. Declare `meta`
explicitly (or use `PluginMeta.from_package(...)`) when publishing
as a separate package or when Horizon/registry-facing metadata
matters.
"""
_config_cls: ClassVar[type[BaseModel]] = _EmptyConfig
"""Config model class resolved from the `Plugin[C]` generic parameter.
Auto-populated by `__init_subclass__`; falls back to `_EmptyConfig`
for plugins that don't parameterize `Plugin`.
"""
config: C
"""The validated config instance. Typed as `C`, the generic
parameter, so `self.config.<field>` type-checks correctly."""
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# Auto-derive meta if the subclass didn't declare its own. We
# check `cls.__dict__` rather than attribute lookup so inherited
# meta from an intermediate subclass isn't treated as a local
# declaration — each concrete Plugin class gets its own name.
if "meta" not in cls.__dict__:
cls.meta = PluginMeta(name=_derive_plugin_name(cls.__name__))
# Resolve the Config model from the generic parameter. We walk the
# `__orig_bases__` chain and propagate TypeVar substitutions, so
# both direct parameterization (`class P(Plugin[Cfg])`) and
# deferred binding (`class Abstract(Plugin[_T])` →
# `class P(Abstract[Cfg])`) resolve correctly. Intermediate
# generic bases with their own unrelated TypeVars are unaffected
# because we substitute through each step rather than treating
# `args[0]` as the config unconditionally.
config_cls = _resolve_plugin_config_cls(cls)
if config_cls is not None:
cls._config_cls = config_cls
# Enforce the JSON-serializable contract on the resolved config.
# Every plugin config must round-trip through JSON so plugins
# can be loaded from config files, rendered by registry/Horizon
# forms, and published to manifest artifacts. Runs on every
# Plugin subclass — including `_EmptyConfig`, which passes
# trivially.
cls._validate_config_cls(cls._config_cls)
# Framework-internal: the server this plugin is attached to, or None
# if not yet installed. Set by `install()` and checked to enforce the
# "one plugin instance per server" contract — plugin instances are
# single-server by design so plugin authors don't have to reason about
# per-server state isolation for contributions and lifecycle hooks.
_installed_on: FastMCP | None = None
def __init__(self, config: C | dict[str, Any] | None = None) -> None:
meta = getattr(type(self), "meta", None)
if not isinstance(meta, PluginMeta):
raise TypeError(
f"{type(self).__name__} must declare a class-level "
f"'meta' attribute of type PluginMeta"
)
self._validate_meta(meta)
config_cls = type(self)._config_cls
def _wrap(exc: ValidationError) -> PluginConfigError:
# For unparameterized plugins, pydantic's error string
# includes "1 validation error for _EmptyConfig" — an
# internal class name users shouldn't see. Emit a scoped
# message instead; for parameterized plugins, forward
# pydantic's full diagnostic.
if config_cls is _EmptyConfig:
keys = list(config.keys()) if isinstance(config, dict) else []
return PluginConfigError(
f"Invalid configuration for {type(self).__name__}: this "
f"plugin declares no config fields but received "
f"{keys}."
)
return PluginConfigError(
f"Invalid configuration for {type(self).__name__}: {exc}"
)
if config is None:
try:
value: BaseModel = config_cls()
except ValidationError as exc:
# Required config fields with no default: surface the
# failure as PluginConfigError so callers that catch
# the documented exception type behave consistently
# with the dict path below.
raise _wrap(exc) from exc
elif isinstance(config, config_cls):
value = config
elif isinstance(config, dict):
try:
value = config_cls(**config)
except ValidationError as exc:
raise _wrap(exc) from exc
else:
# `_EmptyConfig` is an internal implementation detail for
# unparameterized plugins. Don't leak its name to authors.
expected = (
"dict"
if config_cls is _EmptyConfig
else f"{config_cls.__name__} instance or dict"
)
raise PluginConfigError(
f"Config for {type(self).__name__} must be a {expected}, "
f"not {type(config).__name__}"
)
self.config = cast(C, value)
# -- validation -----------------------------------------------------------
@staticmethod
def _validate_config_cls(config_cls: type[BaseModel]) -> None:
"""Ensure a plugin's config model is fully JSON-serializable.
Plugin configs are the distribution surface they're loaded
from JSON/YAML, rendered into Horizon/registry forms, and
published in manifests. They must round-trip through JSON
without loss. Enforced at class creation so authoring mistakes
fail loudly at import time rather than at `fastmcp plugin
manifest` / registry-render time.
To expose callable-ish behavior through config, plugin authors
should surface a string-keyed enum (e.g.
`mode: Literal["json", "markdown"] = "json"`) and resolve to
the real callable internally. Runtime Python extensibility
custom callables, connection pools, etc. belongs on the
plugin's `__init__` signature, not the Config model.
Two checks: (1) `model_json_schema()` must succeed catches
fields pydantic can't describe in JSON at all (raw callables,
classes without pydantic hooks). (2) If the config can be
built without arguments (all fields have defaults), exercise
the runtime serialization path via `model_dump(mode="json")`
catches the "partial-hooks" case where a type has
`__get_pydantic_json_schema__` but no matching serializer
(schema generation alone would silently pass).
Configs with required fields skip the dump check at class
creation we can't construct an instance without a value.
Partial-hooks violations on those fields surface on first
`Config(**data).model_dump(mode="json")`. Configs with
unresolved forward references skip the entire check; it
re-runs at manifest time once the model is complete.
"""
if not getattr(config_cls, "__pydantic_complete__", True):
return
try:
config_cls.model_json_schema()
except Exception as exc:
raise PluginError(
f"Plugin config {config_cls.__name__} is not JSON-"
f"serializable: {exc}. Every field must be expressible "
f"in JSON. Callable fields and raw Python classes without "
f"pydantic serialization hooks are not supported."
) from exc
# If the config builds without args, exercise the real
# serialization path to catch types that have a schema hook
# but no serializer.
try:
instance = config_cls()
except ValidationError as exc:
# A ValidationError here can mean two things: (1) required
# fields without defaults — can't build without user
# input, expected, skip the dump test; or (2) a default
# value failed a field validator, which is a real authoring
# bug and should surface as PluginError at class creation.
if all(err.get("type") == "missing" for err in exc.errors()):
return
raise PluginError(
f"Plugin config {config_cls.__name__} has an invalid "
f"default value: {exc}"
) from exc
except Exception as exc:
# Non-ValidationError failures (TypeError from a broken
# default_factory, RuntimeError from model_post_init, etc.)
# are also author-side bugs — wrap so the error carries
# plugin attribution rather than propagating bare.
raise PluginError(
f"Plugin config {config_cls.__name__} could not be "
f"instantiated with defaults: {exc}"
) from exc
try:
instance.model_dump(mode="json")
except Exception as exc:
raise PluginError(
f"Plugin config {config_cls.__name__} cannot be "
f"serialized to JSON at runtime: {exc}. Every field "
f"must have both a JSON schema and a JSON serializer."
) from exc
@staticmethod
def _validate_meta(meta: PluginMeta) -> None:
"""Check that the plugin's declared metadata is internally consistent."""
for dep in meta.dependencies:
try:
req = Requirement(dep)
except InvalidRequirement as exc:
raise PluginError(
f"Plugin {meta.name!r}: invalid PEP 508 requirement {dep!r}: {exc}"
) from exc
if req.name.lower().replace("_", "-") == "fastmcp":
raise PluginError(
f"Plugin {meta.name!r}: 'fastmcp' must not appear in "
f"dependencies. Use the 'fastmcp_version' field instead."
)
if meta.fastmcp_version is not None:
try:
SpecifierSet(meta.fastmcp_version)
except InvalidSpecifier as exc:
raise PluginError(
f"Plugin {meta.name!r}: invalid fastmcp_version "
f"specifier {meta.fastmcp_version!r}: {exc}"
) from exc
def check_fastmcp_compatibility(self) -> None:
"""Raise if the declared `fastmcp_version` excludes the running FastMCP."""
spec_str = self.meta.fastmcp_version
if spec_str is None:
return
spec = SpecifierSet(spec_str)
current = fastmcp.__version__
if current not in spec:
raise PluginCompatibilityError(
f"Plugin {self.meta.name!r} requires fastmcp {spec_str}, "
f"but running fastmcp is {current}."
)
# -- lifecycle ------------------------------------------------------------
def on_install(self, server: FastMCP) -> None:
"""Optional sync hook run when the plugin is attached to a server.
The framework calls this exactly once, from inside
`FastMCP.add_plugin()`, after the plugin has been recorded in the
server's plugin list but before its contribution hooks are
pulled. Default: no-op.
Override to do server-aware synchronous setup stash a server
reference, compute derived state that contribution hooks depend
on, or recursively register child plugins (the "loader" pattern):
```python
class ConfigLoader(Plugin[LoaderConfig]):
def on_install(self, server):
for spec in self.config.children:
server.add_plugin(build_plugin(spec))
```
Child plugins registered from `on_install` appear after this
plugin in `server.plugins`, preserving parent-before-child
registration order.
Async setup belongs in `run()` / `setup()`, not here. `on_install`
is synchronous because it runs inside `FastMCP.__init__` before
any event loop exists.
"""
@asynccontextmanager
async def run(self, server: FastMCP) -> AsyncIterator[None]:
"""Async context manager wrapping the plugin's runtime lifetime.
Used for **async work only** the plugin's contributions are
already installed by the time `run()` is entered. Opening database
connections, starting background tasks, hydrating an
already-installed provider with live state: all fine. Registering
additional plugins, middleware, or providers here is discouraged:
use `on_install()` for plugin composition so the server graph is
configured before runtime work begins.
The framework enters `async with plugin.run(server):` on the
server's lifespan stack once per lifespan cycle. Everything before
the `yield` runs during startup (in registration order); the
`yield` spans the server's active lifetime; everything after
runs on shutdown (reverse order).
The default implementation calls `setup(server)` before the
`yield` and `teardown()` after it, so plugins that just need
one-shot init/cleanup can keep overriding just those two methods.
Long-running plugins (channels, integration bridges, background
workers) override `run()` directly to use `async with` for
resource management and task groups:
@asynccontextmanager
async def run(self, server):
async with httpx.AsyncClient() as client:
self.client = client
yield
"""
await self.setup(server)
try:
yield
finally:
try:
await self.teardown()
except Exception:
# Exceptions during teardown are logged, not raised, so a
# broken plugin can't take down the server's shutdown
# sequence. Plugins that want different semantics should
# override `run()` directly.
logger.exception("Plugin %r raised during teardown", self.meta.name)
async def setup(self, server: FastMCP) -> None:
"""One-shot async initialization. Called by the default `run()`
before the `yield`.
Override for simple async init work open connections, warm
caches, hydrate an already-installed provider. For anything
involving long-lived resources or background tasks, override
`run()` directly instead and use `async with`.
Prefer `on_install()` for registering additional plugins or
contributions so plugin composition happens at install time, before
async runtime work begins.
"""
async def teardown(self) -> None:
"""One-shot async cleanup. Called by the default `run()` after
the `yield`.
Override for simple cleanup work close connections, flush
buffers. For resource management that would benefit from
`async with`, override `run()` directly instead.
"""
# -- contribution hooks ---------------------------------------------------
def middleware(self) -> list[Middleware]:
"""Return MCP-layer middleware to install on the server."""
return []
def transforms(self) -> list[Transform]:
"""Return component transforms (tools, resources, prompts)."""
return []
def providers(self) -> list[Provider]:
"""Return component providers."""
return []
def auth(self) -> AuthProvider | None:
"""Return the auth provider this plugin contributes, or `None`.
Any `AuthProvider` subclass is accepted a `TokenVerifier`, a
full OAuth server (`OAuthProvider` / `RemoteAuthProvider` /
`OAuthProxy`), or a pre-composed `MultiAuth`.
FastMCP's auth slot is **singular**. Across the user-declared
`auth=` and every plugin's `auth()` return, at most one
`AuthProvider` may be active. Multiple contributors raise
`PluginError` with an error that names every source so the
operator can resolve the conflict explicitly.
**Best practice for plugins that contribute auth**: expose a
config knob (conventionally `enable_auth: bool = True`) so users
who want the plugin's other features but prefer different auth
can disable it without framework-level composition rules. Return
`None` when the knob is off.
For genuine multi-source auth, users construct a `MultiAuth`
explicitly and pass it as the single `auth=` arg the framework
never auto-composes, because silent composition produces
surprising behavior at token-verification time.
The default returns `None`.
"""
return None
def capabilities(self) -> dict[str, Any]:
"""Return a partial `ServerCapabilities` dict to merge into the server's capabilities.
The returned dict follows the MCP `ServerCapabilities` shape.
Contributions from all plugins are deep-merged in registration
order, then applied on top of the server's built-in capabilities.
Later plugins can add to or override earlier plugins' entries;
this is intentional plugin order is a user-facing configuration
knob, same as middleware order.
A plugin advertising an experimental protocol extension:
```python
def capabilities(self):
return {"experimental": {"my/ext": {}}}
```
A plugin modifying a built-in capability field follows the same
shape, keyed by the `ServerCapabilities` field name.
"""
return {}
def routes(self) -> list[BaseRoute]:
"""Return custom HTTP routes to mount on the server's ASGI app.
Routes contributed here are **not authenticated by the framework**
the MCP auth provider does not gate them. They are appropriate
for webhook endpoints whose callers carry their own authentication
scheme (e.g. an HMAC-signed header), and the plugin is responsible
for verifying inbound requests inside the handler.
Routes otherwise receive the full incoming HTTP request unchanged,
including all headers the client sent. If a caller has provided
the same credentials it would use for an authenticated MCP call,
those headers are available on `request.headers` for the handler
to inspect the plugin chooses whether and how to validate them.
"""
return []
# -- introspection --------------------------------------------------------
@classmethod
def manifest(
cls,
path: str | Path | None = None,
) -> dict[str, Any] | None:
"""Return the plugin's manifest as a dict, or write it to `path` as JSON.
Does not instantiate the plugin. The manifest is a JSON-serializable
dict that combines the plugin's metadata, its config schema, and an
importable entry point. Downstream consumers (Horizon, registries,
CI tooling) read the manifest to discover plugins and render
configuration forms without installing the plugin's dependencies.
"""
meta = getattr(cls, "meta", None)
if not isinstance(meta, PluginMeta):
raise TypeError(
f"{cls.__name__} must declare a class-level "
f"'meta' attribute of type PluginMeta"
)
# Validate meta the same way instance construction does, so
# `fastmcp plugin manifest` can't emit an artifact (malformed
# PEP 508 deps, bad fastmcp_version specifier, fastmcp declared
# as a dep, ...) that downstream tooling couldn't otherwise
# have produced from a live plugin instance.
cls._validate_meta(meta)
config_cls = cls._config_cls
# Re-run the JSON-serializable check here. Plugins with forward-
# reference configs skip validation at class creation, so manifest
# emission is the publish-time boundary that has to enforce it.
cls._validate_config_cls(config_cls)
config_schema = config_cls.model_json_schema()
# `_EmptyConfig` is an internal implementation detail; don't
# leak its name or docstring into the published manifest JSON
# consumed by Horizon, registries, and CI tooling. Pydantic v2
# emits both `title` (from `__name__`) and `description` (from
# the class docstring) in `model_json_schema()`; strip both.
if config_cls is _EmptyConfig:
config_schema.pop("title", None)
config_schema.pop("description", None)
data: dict[str, Any] = {
"manifest_version": 1,
**meta.model_dump(),
"config_schema": config_schema,
"entry_point": f"{cls.__module__}:{cls.__qualname__}",
}
if path is None:
return data
target = Path(path)
target.write_text(json.dumps(data, indent=2, sort_keys=False))
return None
__all__ = [
"Plugin",
"PluginCompatibilityError",
"PluginConfigError",
"PluginError",
"PluginMeta",
]

View file

@ -0,0 +1,42 @@
"""Code-mode plugin — discovery + sandboxed Python execution in place of the tool catalog.
The `CodeMode` plugin is the public entry point:
from fastmcp import FastMCP
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("Server", plugins=[CodeMode()])
Discovery-tool factories (`Search`, `GetSchemas`, `GetTags`,
`ListTools`) and the sandbox-provider protocol (`SandboxProvider`,
`MontySandboxProvider`) are re-exported for custom composition. The
low-level `CodeModeTransform` lives in `.transform` for advanced users
who want to stack it directly with other transforms.
"""
from fastmcp.server.plugins.code_mode.discovery import (
DiscoveryToolFactory,
GetSchemas,
GetTags,
GetToolCatalog,
ListTools,
Search,
)
from fastmcp.server.plugins.code_mode.plugin import CodeMode, CodeModeConfig
from fastmcp.server.plugins.code_mode.sandbox import (
MontySandboxProvider,
SandboxProvider,
)
__all__ = [
"CodeMode",
"CodeModeConfig",
"DiscoveryToolFactory",
"GetSchemas",
"GetTags",
"GetToolCatalog",
"ListTools",
"MontySandboxProvider",
"SandboxProvider",
"Search",
]

View file

@ -0,0 +1,323 @@
"""Discovery tool factories for the CodeMode plugin.
A discovery tool is a synthetic meta-tool the LLM uses to explore the real
tool catalog before calling anything. Each factory here is a callable
that receives catalog access (`GetToolCatalog`) and returns a ready-to-
publish `Tool`. They compose via the `discovery_tools` parameter on
`CodeMode`.
The four built-in factories cover the common discovery patterns:
* `Search` query the catalog by text (BM25 by default).
* `GetSchemas` fetch parameter schemas for a named list of tools.
* `GetTags` browse the catalog grouped by tag.
* `ListTools` dump every tool at a configurable detail level.
A typical progressive-disclosure setup pairs `Search` with `GetSchemas`:
the LLM searches to find candidates, then fetches schemas only for the
tools it actually plans to call.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable, Sequence
from typing import Annotated, Literal
from fastmcp.server.context import Context
from fastmcp.server.plugins.tool_search.base import (
serialize_tools_for_output_json,
serialize_tools_for_output_markdown,
)
from fastmcp.tools.base import Tool
GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]]
"""Async callable that returns the auth-filtered tool catalog."""
SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]]
"""Async callable that searches a tool sequence by query string."""
DiscoveryToolFactory = Callable[[GetToolCatalog], Tool]
"""Factory that receives catalog access and returns a synthetic Tool."""
ToolDetailLevel = Literal["brief", "detailed", "full"]
"""Detail level for discovery tool output.
- `"brief"`: tool names and one-line descriptions
- `"detailed"`: compact markdown with parameter names, types, and required markers
- `"full"`: complete JSON schema
"""
def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str:
"""Render tools at the requested detail level.
The same detail value produces the same output format regardless of
which discovery tool calls this, so `detail="detailed"` on Search
gives identical formatting to `detail="detailed"` on GetSchemas.
"""
if not tools:
if detail == "full":
return json.dumps([], indent=2)
return "No tools matched the query."
if detail == "full":
return json.dumps(serialize_tools_for_output_json(tools), indent=2)
if detail == "detailed":
return serialize_tools_for_output_markdown(tools)
# brief
lines: list[str] = []
for tool in tools:
desc = f": {tool.description}" if tool.description else ""
lines.append(f"- {tool.name}{desc}")
return "\n".join(lines)
class Search:
"""Discovery tool factory that searches the catalog by query.
Args:
search_fn: Async callable `(tools, query) -> matching_tools`.
Defaults to BM25 ranking.
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level for search results.
`"brief"` returns tool names and descriptions only.
`"detailed"` returns compact markdown with parameter schemas.
`"full"` returns complete JSON tool definitions.
default_limit: Maximum number of results to return. The LLM can
override this per call. `None` means no limit.
"""
def __init__(
self,
*,
search_fn: SearchFn | None = None,
name: str = "search",
default_detail: ToolDetailLevel | None = None,
default_limit: int | None = None,
) -> None:
if search_fn is None:
from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform
_bm25 = BM25SearchTransform(max_results=default_limit or 50)
search_fn = _bm25._search
self._search_fn = search_fn
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "brief"
self._default_limit = default_limit
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
search_fn = self._search_fn
default_detail = self._default_detail
default_limit = self._default_limit
async def search(
query: Annotated[str, "Search query to find available tools"],
tags: Annotated[
list[str] | None,
"Filter to tools with any of these tags before searching",
] = None,
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
limit: Annotated[
int | None,
"Maximum number of results to return",
] = default_limit,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""Search for available tools by query.
Returns matching tools ranked by relevance.
"""
catalog = await get_catalog(ctx)
catalog_size = len(catalog)
tools: Sequence[Tool] = catalog
if tags:
tag_set = set(tags)
has_untagged = "untagged" in tag_set
real_tags = tag_set - {"untagged"}
tools = [
t
for t in tools
if (t.tags & real_tags) or (has_untagged and not t.tags)
]
results = await search_fn(tools, query)
if limit is not None:
results = results[:limit]
rendered = _render_tools(results, detail)
if len(results) < catalog_size and detail != "full":
n = len(results)
rendered = f"{n} of {catalog_size} tools:\n\n{rendered}"
return rendered
return Tool.from_function(fn=search, name=self._name)
class GetSchemas:
"""Discovery tool factory that returns schemas for tools by name.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level for schema results.
`"brief"` returns tool names and descriptions only.
`"detailed"` renders compact markdown with parameter names,
types, and required markers.
`"full"` returns the complete JSON schema.
"""
def __init__(
self,
*,
name: str = "get_schema",
default_detail: ToolDetailLevel | None = None,
) -> None:
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "detailed"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def get_schema(
tools: Annotated[
list[str],
"List of tool names to get schemas for",
],
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""Get parameter schemas for specific tools.
Use after searching to get the detail needed to call a tool.
"""
catalog = await get_catalog(ctx)
catalog_by_name = {t.name: t for t in catalog}
matched = [catalog_by_name[n] for n in tools if n in catalog_by_name]
not_found = [n for n in tools if n not in catalog_by_name]
if not matched and not_found:
return f"Tools not found: {', '.join(not_found)}"
if detail == "full":
data = serialize_tools_for_output_json(matched)
if not_found:
data.append({"not_found": not_found})
return json.dumps(data, indent=2)
result = _render_tools(matched, detail)
if not_found:
result += f"\n\nTools not found: {', '.join(not_found)}"
return result
return Tool.from_function(fn=get_schema, name=self._name)
class GetTags:
"""Discovery tool factory that lists tool tags from the catalog.
Reads `tool.tags` from the catalog and groups tools by tag. Tools
without tags appear under `"untagged"`.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level.
`"brief"` returns tag names with tool counts.
`"full"` lists all tools under each tag.
"""
def __init__(
self,
*,
name: str = "tags",
default_detail: Literal["brief", "full"] | None = None,
) -> None:
self._name = name
self._default_detail: Literal["brief", "full"] = default_detail or "brief"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def tags(
detail: Annotated[
Literal["brief", "full"],
"Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""List available tool tags.
Use to browse available tools by tag before searching.
"""
catalog = await get_catalog(ctx)
by_tag: dict[str, list[Tool]] = {}
for tool in catalog:
if tool.tags:
for tag in tool.tags:
by_tag.setdefault(tag, []).append(tool)
else:
by_tag.setdefault("untagged", []).append(tool)
if not by_tag:
return "No tools available."
if detail == "brief":
lines = [
f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})"
for tag, tools in sorted(by_tag.items())
]
return "\n".join(lines)
blocks: list[str] = []
for tag, tools in sorted(by_tag.items()):
lines = [f"### {tag}"]
for tool in tools:
desc = f": {tool.description}" if tool.description else ""
lines.append(f"- {tool.name}{desc}")
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
return Tool.from_function(fn=tags, name=self._name)
class ListTools:
"""Discovery tool factory that lists all tools in the catalog.
Args:
name: Name of the synthetic tool exposed to the LLM.
default_detail: Default detail level.
`"brief"` returns tool names and one-line descriptions.
`"detailed"` returns compact markdown with parameter schemas.
`"full"` returns the complete JSON schema.
"""
def __init__(
self,
*,
name: str = "list_tools",
default_detail: ToolDetailLevel | None = None,
) -> None:
self._name = name
self._default_detail: ToolDetailLevel = default_detail or "brief"
def __call__(self, get_catalog: GetToolCatalog) -> Tool:
default_detail = self._default_detail
async def list_tools(
detail: Annotated[
ToolDetailLevel,
"'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas",
] = default_detail,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str:
"""List all available tools.
Use to see the full catalog before searching or calling tools.
"""
catalog = await get_catalog(ctx)
return _render_tools(catalog, detail)
return Tool.from_function(fn=list_tools, name=self._name)

View file

@ -0,0 +1,129 @@
"""CodeMode plugin: tool execution via LLM-generated code.
`CodeMode` replaces the entire tool catalog with two classes of
meta-tool discovery tools (search, get_schema, etc.) and a single
`execute` tool that runs LLM-generated Python in a sandbox. The model
discovers what's available on demand and chains calls inside one
sandboxed code block, which dramatically cuts round-trips and context
for servers with many tools.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin
from fastmcp.server.plugins.code_mode.discovery import DiscoveryToolFactory
from fastmcp.server.plugins.code_mode.sandbox import (
MontySandboxProvider,
SandboxProvider,
)
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
from fastmcp.server.transforms import Transform
class CodeModeConfig(BaseModel):
"""Config model for the `CodeMode` plugin.
Only covers JSON-serializable settings the sandbox provider and
discovery-tool factories are passed through `CodeMode.__init__`
directly because they're real Python objects.
"""
model_config = ConfigDict(extra="forbid")
sandbox: Literal["monty"] = "monty"
"""Built-in sandbox provider to use. `"monty"` uses
`pydantic-monty`. For a custom provider, pass `sandbox_provider=...`
to `CodeMode.__init__` instead."""
sandbox_limits: dict[str, Any] | None = None
"""Resource limits for the default Monty sandbox. Keys:
`max_duration_secs`, `max_allocations`, `max_memory`,
`max_recursion_depth`, `gc_interval`. All optional."""
execute_tool_name: str = "execute"
"""Name of the generated execute tool."""
execute_description: str | None = None
"""Override the default description of the execute tool. `None`
keeps the built-in guidance."""
class CodeMode(Plugin[CodeModeConfig]):
"""Collapse the tool catalog behind discovery + code-execution meta-tools.
Users write a CodeMode-enabled server exactly like a normal server;
the plugin takes care of hiding the real tools and exposing search
/ get_schema / execute in their place.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.code_mode import CodeMode
mcp = FastMCP("Server", plugins=[CodeMode()])
```
For a custom sandbox or custom discovery-tool set, pass Python
objects through `__init__`:
```python
from fastmcp.server.plugins.code_mode import (
CodeMode,
CodeModeConfig,
GetSchemas,
ListTools,
)
mcp = FastMCP(
"Server",
plugins=[
CodeMode(
CodeModeConfig(execute_tool_name="run"),
sandbox_provider=my_custom_sandbox,
discovery_tools=[ListTools(), GetSchemas()],
)
],
)
```
"""
# `meta` is auto-derived (name="code-mode", version=None) — the right
# answer for a bundled first-party plugin. Declare `meta` explicitly
# (or use `PluginMeta.from_package(...)`) if published separately.
def __init__(
self,
config: CodeModeConfig | dict[str, Any] | None = None,
*,
sandbox_provider: SandboxProvider | None = None,
discovery_tools: list[DiscoveryToolFactory] | None = None,
) -> None:
super().__init__(config)
self._sandbox_override = sandbox_provider
self._discovery_override = discovery_tools
def transforms(self) -> list[Transform]:
sandbox = self._sandbox_override or self._build_default_sandbox()
return [
CodeModeTransform(
sandbox_provider=sandbox,
discovery_tools=self._discovery_override,
execute_tool_name=self.config.execute_tool_name,
execute_description=self.config.execute_description,
)
]
def _build_default_sandbox(self) -> SandboxProvider:
limits_dict = self.config.sandbox_limits
if limits_dict is None:
return MontySandboxProvider()
# Defer the import so Monty is only a hard dependency when
# `sandbox_limits` is actually configured.
from pydantic_monty import ResourceLimits
return MontySandboxProvider(limits=ResourceLimits(**limits_dict))

View file

@ -0,0 +1,94 @@
"""Sandbox providers for the CodeMode plugin.
A `SandboxProvider` is the component that actually executes LLM-generated
Python code. The default `MontySandboxProvider` delegates to
`pydantic-monty` for isolated execution; alternative providers can plug in
any other sandbox (remote process, WASM, a containerized worker, etc.)
by implementing the `SandboxProvider` protocol.
"""
from __future__ import annotations
import importlib
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Protocol
from fastmcp.utilities.async_utils import is_coroutine_function
if TYPE_CHECKING:
from pydantic_monty import ResourceLimits
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
if is_coroutine_function(fn):
return fn
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs)
return wrapper
class SandboxProvider(Protocol):
"""Interface for executing LLM-generated Python code in a sandbox.
WARNING: The `code` parameter passed to `run` contains untrusted,
LLM-generated Python. Implementations MUST execute it in an isolated
sandbox never with plain `exec()`. Use `MontySandboxProvider`
(backed by `pydantic-monty`) for production workloads.
"""
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any: ...
class MontySandboxProvider:
"""Sandbox provider backed by `pydantic-monty`.
Args:
limits: Resource limits for sandbox execution. Supported keys:
`max_duration_secs` (float), `max_allocations` (int),
`max_memory` (int), `max_recursion_depth` (int),
`gc_interval` (int). All are optional; omit a key to leave
that limit uncapped.
"""
def __init__(
self,
*,
limits: ResourceLimits | None = None,
) -> None:
self.limits = limits
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Callable[..., Any]] | None = None,
) -> Any:
try:
pydantic_monty = importlib.import_module("pydantic_monty")
except ModuleNotFoundError as exc:
raise ImportError(
"CodeMode requires pydantic-monty for the Monty sandbox provider. "
"Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider."
) from exc
inputs = inputs or {}
async_functions = {
key: _ensure_async(value)
for key, value in (external_functions or {}).items()
}
monty = pydantic_monty.Monty(code, inputs=list(inputs))
return await monty.run_async(
inputs=inputs or None,
external_functions=async_functions or None,
limits=self.limits,
)

View file

@ -0,0 +1,186 @@
"""Low-level transform that powers the CodeMode plugin.
`CodeModeTransform` replaces the tool catalog with two classes of
meta-tool: configurable **discovery tools** (search, get_schema, etc.)
that let the LLM explore what's available, and a single **execute tool**
that runs LLM-generated Python in a sandbox with `call_tool(...)`
available in scope.
Most users should configure CodeMode through the `CodeMode` plugin
(`fastmcp.server.plugins.code_mode`). The transform is exposed for
advanced composition users who want to stack it with other transforms
directly or embed it in a custom plugin.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Annotated, Any
from mcp.types import TextContent
from pydantic import Field
from fastmcp.exceptions import NotFoundError
from fastmcp.server.context import Context
from fastmcp.server.plugins.code_mode.discovery import (
DiscoveryToolFactory,
GetSchemas,
Search,
)
from fastmcp.server.plugins.code_mode.sandbox import (
MontySandboxProvider,
SandboxProvider,
)
from fastmcp.server.transforms import GetToolNext
from fastmcp.server.transforms.catalog import CatalogTransform
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.versions import VersionSpec
def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str:
"""Convert a ToolResult for use in the sandbox.
- Output schema present structured_content dict (matches the schema)
- Otherwise concatenated text content as a string
"""
if result.structured_content is not None:
return result.structured_content
parts: list[str] = []
for content in result.content:
if isinstance(content, TextContent):
parts.append(content.text)
else:
parts.append(str(content))
return "\n".join(parts)
def _default_discovery_tools() -> list[DiscoveryToolFactory]:
return [Search(), GetSchemas()]
class CodeModeTransform(CatalogTransform):
"""Transform that collapses all tools into discovery + execute meta-tools.
Discovery tools are composable via the `discovery_tools` parameter.
Each is a callable that receives catalog access and returns a `Tool`.
By default, `Search` and `GetSchemas` are included for progressive
disclosure: search finds candidates, get_schema retrieves parameter
details, and execute runs code.
The `execute` tool is always present and provides a sandboxed Python
environment with `call_tool(name, params)` in scope.
"""
def __init__(
self,
*,
sandbox_provider: SandboxProvider | None = None,
discovery_tools: list[DiscoveryToolFactory] | None = None,
execute_tool_name: str = "execute",
execute_description: str | None = None,
) -> None:
super().__init__()
self.execute_tool_name = execute_tool_name
self.execute_description = execute_description
self.sandbox_provider = sandbox_provider or MontySandboxProvider()
self._discovery_factories = (
discovery_tools
if discovery_tools is not None
else _default_discovery_tools()
)
self._built_discovery_tools: list[Tool] | None = None
self._cached_execute_tool: Tool | None = None
def _build_discovery_tools(self) -> list[Tool]:
if self._built_discovery_tools is None:
tools = [
factory(self.get_tool_catalog) for factory in self._discovery_factories
]
names = {t.name for t in tools}
if self.execute_tool_name in names:
raise ValueError(
f"Discovery tool name '{self.execute_tool_name}' "
f"collides with execute_tool_name."
)
if len(names) != len(tools):
raise ValueError("Discovery tools must have unique names.")
self._built_discovery_tools = tools
return self._built_discovery_tools
async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [*self._build_discovery_tools(), self._get_execute_tool()]
async def get_tool(
self,
name: str,
call_next: GetToolNext,
*,
version: VersionSpec | None = None,
) -> Tool | None:
for tool in self._build_discovery_tools():
if tool.name == name:
return tool
if name == self.execute_tool_name:
return self._get_execute_tool()
return await call_next(name, version=version)
def _build_execute_description(self) -> str:
if self.execute_description is not None:
return self.execute_description
return (
"Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n"
"Use `return` to produce output.\n"
"Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
)
@staticmethod
def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None:
"""Find a tool by name from a pre-fetched list."""
for tool in tools:
if tool.name == name:
return tool
return None
def _get_execute_tool(self) -> Tool:
if self._cached_execute_tool is None:
self._cached_execute_tool = self._make_execute_tool()
return self._cached_execute_tool
def _make_execute_tool(self) -> Tool:
transform = self
async def execute(
code: Annotated[
str,
Field(
description=(
"Python async code to execute tool calls via call_tool(name, arguments)"
)
),
],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> Any:
"""Execute tool calls using Python code."""
async def call_tool(tool_name: str, params: dict[str, Any]) -> Any:
backend_tools = await transform.get_tool_catalog(ctx)
tool = transform._find_tool(tool_name, backend_tools)
if tool is None:
raise NotFoundError(f"Unknown tool: {tool_name}")
result = await ctx.fastmcp.call_tool(tool.name, params)
return _unwrap_tool_result(result)
return await transform.sandbox_provider.run(
code,
external_functions={"call_tool": call_tool},
)
return Tool.from_function(
fn=execute,
name=self.execute_tool_name,
description=self._build_execute_description(),
)

View file

@ -0,0 +1,21 @@
"""OpenAPI plugin — mount an OpenAPI spec as MCP tools/resources.
from fastmcp import FastMCP
from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig
mcp = FastMCP(
"Petstore",
plugins=[OpenAPI(OpenAPIConfig(spec=petstore_spec))],
)
Typed `RouteMap` + `MCPType` are re-exported for the Python-only
escape hatch on `OpenAPI.__init__(route_maps=...)`. Everything else
(component classes, provider class, callable type aliases) lives on the
submodules import from `.provider`, `.components`, `.routing` directly
if you need them.
"""
from fastmcp.server.plugins.openapi.plugin import OpenAPI, OpenAPIConfig
from fastmcp.server.plugins.openapi.routing import MCPType, RouteMap
__all__ = ["MCPType", "OpenAPI", "OpenAPIConfig", "RouteMap"]

View file

@ -0,0 +1,421 @@
"""OpenAPI component classes: Tool, Resource, and ResourceTemplate."""
from __future__ import annotations
import json
import re
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
import httpx
from mcp.types import ToolAnnotations
from pydantic.networks import AnyUrl
import fastmcp
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.resources import (
Resource,
ResourceContent,
ResourceResult,
ResourceTemplate,
)
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
if TYPE_CHECKING:
from fastmcp.server import Context
_SAFE_HEADERS = frozenset(
{
"accept",
"accept-encoding",
"accept-language",
"cache-control",
"connection",
"content-length",
"content-type",
"host",
"user-agent",
}
)
def _redact_headers(headers: httpx.Headers) -> dict[str, str]:
return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()}
__all__ = [
"OpenAPIResource",
"OpenAPIResourceTemplate",
"OpenAPITool",
"_extract_mime_type_from_route",
]
logger = get_logger(__name__)
# Default MIME type when no response content type can be inferred
_DEFAULT_MIME_TYPE = "application/json"
def _extract_mime_type_from_route(route: HTTPRoute) -> str:
"""Extract the primary MIME type from an HTTPRoute's response definitions.
Looks for the first successful response (2xx) and returns its content type.
Prefers JSON-compatible types when multiple are available.
Falls back to "application/json" when no response content type is declared.
"""
if not route.responses:
return _DEFAULT_MIME_TYPE
# Priority order for success status codes
success_codes = ["200", "201", "202", "204"]
response_info = None
for status_code in success_codes:
if status_code in route.responses:
response_info = route.responses[status_code]
break
# If no explicit success codes, try any 2xx response
if response_info is None:
for status_code, resp_info in route.responses.items():
if status_code.startswith("2"):
response_info = resp_info
break
if response_info is None or not response_info.content_schema:
return _DEFAULT_MIME_TYPE
# If there's only one content type, use it directly
content_types = list(response_info.content_schema.keys())
if len(content_types) == 1:
return content_types[0]
# When multiple types exist, prefer JSON-compatible types
json_compatible_types = [
"application/json",
"application/vnd.api+json",
"application/hal+json",
"application/ld+json",
"text/json",
]
for ct in json_compatible_types:
if ct in response_info.content_schema:
return ct
# Fall back to the first available content type
return content_types[0]
def _slugify(text: str) -> str:
"""Convert text to a URL-friendly slug format.
Only contains lowercase letters, uppercase letters, numbers, and underscores.
"""
if not text:
return ""
# Replace spaces and common separators with underscores
slug = re.sub(r"[\s\-\.]+", "_", text)
# Remove non-alphanumeric characters except underscores
slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
# Remove multiple consecutive underscores
slug = re.sub(r"_+", "_", slug)
# Remove leading/trailing underscores
slug = slug.strip("_")
return slug
class OpenAPITool(Tool):
"""Tool implementation for OpenAPI endpoints."""
task_config: TaskConfig = TaskConfig(mode="forbidden")
def __init__(
self,
client: httpx.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
name: str,
description: str,
parameters: dict[str, Any],
output_schema: dict[str, Any] | None = None,
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
serializer: Callable[[Any], str] | None = None, # Deprecated
):
if serializer is not None and fastmcp.settings.deprecation_warnings:
warnings.warn(
"The `serializer` parameter is deprecated. "
"Return ToolResult from your tools for full control over serialization. "
"See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.",
FastMCPDeprecationWarning,
stacklevel=2,
)
super().__init__(
name=name,
description=description,
parameters=parameters,
output_schema=output_schema,
tags=tags or set(),
annotations=annotations,
serializer=serializer,
)
self._client = client
self._route = route
self._director = director
def __repr__(self) -> str:
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Execute the HTTP request using RequestDirector."""
# Build the request — errors here are programming/schema issues,
# not HTTP failures, so we catch them separately.
try:
base_url = str(self._client.base_url) or "http://localhost"
request = self._director.build(self._route, arguments, base_url)
if self._client.headers:
for key, value in self._client.headers.items():
if key not in request.headers:
request.headers[key] = value
mcp_headers = get_http_headers()
if mcp_headers:
for key, value in mcp_headers.items():
if key not in request.headers:
request.headers[key] = value
except Exception as e:
raise ValueError(
f"Error building request for {self._route.method.upper()} "
f"{self._route.path}: {type(e).__name__}: {e}"
) from e
# Send the request and process the response.
try:
logger.debug(
f"run - sending request; headers: {_redact_headers(request.headers)}"
)
response = await self._client.send(request)
response.raise_for_status()
# Try to parse as JSON first
try:
result = response.json()
# Handle structured content based on output schema
if self.output_schema is not None:
if self.output_schema.get("x-fastmcp-wrap-result"):
structured_output = {"result": result}
else:
structured_output = result
elif not isinstance(result, dict):
structured_output = {"result": result}
else:
structured_output = result
# Structured content must be a dict for the MCP protocol.
# Wrap non-dict values that slipped through (e.g. a backend
# returning an array when the schema declared an object).
if not isinstance(structured_output, dict):
structured_output = {"result": structured_output}
return ToolResult(structured_content=structured_output)
except json.JSONDecodeError:
return ToolResult(content=response.text)
except httpx.HTTPStatusError as e:
error_message = (
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
)
try:
error_data = e.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if e.response.text:
error_message += f" - {e.response.text}"
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResource(Resource):
"""Resource implementation for OpenAPI endpoints."""
task_config: TaskConfig = TaskConfig(mode="forbidden")
def __init__(
self,
client: httpx.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri: str,
name: str,
description: str,
mime_type: str = "application/json",
tags: set[str] | None = None,
):
super().__init__(
uri=AnyUrl(uri),
name=name,
description=description,
mime_type=mime_type,
tags=tags or set(),
)
self._client = client
self._route = route
self._director = director
def __repr__(self) -> str:
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
async def read(self) -> ResourceResult:
"""Fetch the resource data by making an HTTP request."""
try:
path = self._route.path
resource_uri = str(self.uri)
# If this is a templated resource, extract path parameters from the URI
if "{" in path and "}" in path:
parts = resource_uri.split("/")
if len(parts) > 1:
path_params = {}
param_matches = re.findall(r"\{([^}]+)\}", path)
if param_matches:
param_matches.sort(reverse=True)
expected_param_count = len(parts) - 1
for i, param_name in enumerate(param_matches):
if i < expected_param_count:
param_value = parts[-1 - i]
path_params[param_name] = param_value
for param_name, param_value in path_params.items():
path = path.replace(f"{{{param_name}}}", str(param_value))
# Build headers with correct precedence
headers: dict[str, str] = {}
if self._client.headers:
headers.update(self._client.headers)
mcp_headers = get_http_headers()
if mcp_headers:
headers.update(mcp_headers)
response = await self._client.request(
method=self._route.method,
url=path,
headers=headers,
)
response.raise_for_status()
content_type = response.headers.get("content-type", "").lower()
if "application/json" in content_type:
result = response.json()
return ResourceResult(
contents=[
ResourceContent(
content=json.dumps(result), mime_type="application/json"
)
]
)
elif any(ct in content_type for ct in ["text/", "application/xml"]):
return ResourceResult(
contents=[
ResourceContent(content=response.text, mime_type=self.mime_type)
]
)
else:
return ResourceResult(
contents=[
ResourceContent(
content=response.content, mime_type=self.mime_type
)
]
)
except httpx.HTTPStatusError as e:
error_message = (
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
)
try:
error_data = e.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if e.response.text:
error_message += f" - {e.response.text}"
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResourceTemplate(ResourceTemplate):
"""Resource template implementation for OpenAPI endpoints."""
task_config: TaskConfig = TaskConfig(mode="forbidden")
def __init__(
self,
client: httpx.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri_template: str,
name: str,
description: str,
parameters: dict[str, Any],
tags: set[str] | None = None,
mime_type: str = _DEFAULT_MIME_TYPE,
):
super().__init__(
uri_template=uri_template,
name=name,
description=description,
parameters=parameters,
tags=tags or set(),
mime_type=mime_type,
)
self._client = client
self._route = route
self._director = director
def __repr__(self) -> str:
return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
async def create_resource(
self,
uri: str,
params: dict[str, Any],
context: Context | None = None,
) -> Resource:
"""Create a resource with the given parameters."""
uri_parts = [f"{key}={value}" for key, value in params.items()]
return OpenAPIResource(
client=self._client,
route=self._route,
director=self._director,
uri=uri,
name=f"{self.name}-{'-'.join(uri_parts)}",
description=self.description or f"Resource for {self._route.path}",
mime_type=self.mime_type,
tags=set(self._route.tags or []),
)

View file

@ -0,0 +1,247 @@
"""OpenAPI plugin: wrap an OpenAPI spec into an MCP server via the
`OpenAPIProvider`.
The plugin is the JSON-configurable entry point for the OpenAPI
integration. Spec, base URL, headers, timeout, and route mappings can
all be declared in a plugin config (useful for `plugins.json`, Horizon
config forms, or anywhere else you want to spin up an OpenAPI server
without writing Python). For scenarios that need a custom
`httpx.AsyncClient` or callables (`route_map_fn`, `mcp_component_fn`),
pass them through `__init__` directly.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Literal
import httpx
from pydantic import BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin, PluginMeta
from fastmcp.server.plugins.openapi.provider import (
OpenAPIProvider,
resolve_spec_base_url,
)
from fastmcp.server.plugins.openapi.routing import (
ComponentFn,
MCPType,
RouteMap,
RouteMapFn,
)
from fastmcp.server.providers import Provider
from fastmcp.utilities.openapi.models import HttpMethod
class RouteMapDict(BaseModel):
"""JSON-serializable form of `RouteMap`.
Converted to a real `RouteMap` when the plugin builds the provider.
The `pattern` field is always a regex string (the typed `RouteMap`
accepts a compiled `Pattern` too, but Config stays JSON-friendly).
"""
model_config = ConfigDict(extra="forbid")
mcp_type: Literal["TOOL", "RESOURCE", "RESOURCE_TEMPLATE", "EXCLUDE"]
"""Target component type. Matches `MCPType` enum values."""
methods: list[HttpMethod] | Literal["*"] = "*"
"""HTTP methods to match (e.g. `["GET", "POST"]`) or `"*"` for any."""
pattern: str = r".*"
"""Regex pattern matched against the route path."""
tags: list[str] = []
"""Route tags that must all be present for this mapping to apply."""
mcp_tags: list[str] = []
"""Tags to attach to the generated MCP component."""
def to_route_map(self) -> RouteMap:
methods: list[HttpMethod] | Literal["*"] = (
"*" if self.methods == "*" else list(self.methods)
)
return RouteMap(
methods=methods,
pattern=self.pattern,
tags=set(self.tags),
mcp_type=MCPType[self.mcp_type],
mcp_tags=set(self.mcp_tags),
)
class OpenAPIConfig(BaseModel):
"""Config model for the `OpenAPI` plugin.
Exactly one of `spec` or `spec_path` must be set the check fires
when the plugin builds its provider, not at Config construction,
so that `OpenAPIConfig()` with no args still satisfies the
plugin-framework's defaults-are-instantiable contract.
For specs that need to be fetched from a URL at startup, fetch the
dict in your application code and pass it via `spec=...`.
"""
model_config = ConfigDict(extra="forbid")
spec: dict[str, Any] | None = None
"""Inline OpenAPI spec as a dict."""
spec_path: str | None = None
"""Path to a local JSON file containing the OpenAPI spec."""
base_url: str | None = None
"""Base URL for the default httpx client. If omitted, the first
server URL from the spec is used."""
headers: dict[str, str] | None = None
"""Default headers added to every request the generated client
makes."""
timeout_secs: float = 30.0
"""Default timeout (seconds) for the generated httpx client."""
mcp_names: dict[str, str] | None = None
"""Mapping from OpenAPI `operationId` to the MCP component name
that gets generated for it."""
tags: list[str] = []
"""Tags applied to every generated MCP component."""
validate_output: bool = True
"""When true (default), generated tools use the OpenAPI response
schema for output validation. Set false to accept any shape."""
route_maps: list[RouteMapDict] = []
"""Ordered route-mapping rules. First match wins. If omitted, all
routes become tools."""
class OpenAPI(Plugin[OpenAPIConfig]):
"""Mount an OpenAPI spec as an MCP server via a plugin.
Everything declarative (spec, base URL, headers, route mappings)
goes in `OpenAPIConfig`. Python-only knobs custom `httpx.AsyncClient`,
route-mapping callables, component customization go in `__init__`
kwargs.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig
# Declarative (JSON-friendly):
mcp = FastMCP(
"Petstore",
plugins=[
OpenAPI(
OpenAPIConfig(
spec=petstore_spec,
base_url="https://api.example.com",
headers={"Authorization": "Bearer ..."},
)
)
],
)
# With a custom httpx client (shared auth, retries, etc.):
custom_client = httpx.AsyncClient(...)
mcp = FastMCP(
"Petstore",
plugins=[
OpenAPI(
OpenAPIConfig(spec=petstore_spec),
client=custom_client,
)
],
)
```
"""
# "OpenAPI" is a single technical term; the auto-kebab would split
# it into "open-api", which is uglier than the established spelling.
meta = PluginMeta(name="openapi")
def __init__(
self,
config: OpenAPIConfig | dict[str, Any] | None = None,
*,
client: httpx.AsyncClient | None = None,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
mcp_component_fn: ComponentFn | None = None,
) -> None:
super().__init__(config)
self._client_override = client
self._route_maps_override = route_maps
self._route_map_fn = route_map_fn
self._mcp_component_fn = mcp_component_fn
def providers(self) -> list[Provider]:
spec = self._load_spec()
if self._client_override is not None:
client = self._client_override
# User-supplied client: they own the lifecycle.
owns_client: bool | None = None
else:
client = self._build_default_client(spec)
# Plugin built the client, so the provider lifespan must
# close it on shutdown (default ownership heuristic would
# miss this since `client` is not None by the time we pass
# it in).
owns_client = True
route_maps = self._resolve_route_maps()
return [
OpenAPIProvider(
openapi_spec=spec,
client=client,
route_maps=route_maps,
route_map_fn=self._route_map_fn,
mcp_component_fn=self._mcp_component_fn,
mcp_names=self.config.mcp_names,
tags=set(self.config.tags) if self.config.tags else None,
validate_output=self.config.validate_output,
_owns_client=owns_client,
)
]
def _load_spec(self) -> dict[str, Any]:
if self.config.spec is not None and self.config.spec_path is not None:
raise ValueError(
"OpenAPIConfig requires exactly one of `spec` or `spec_path`, not both."
)
if self.config.spec is not None:
return self.config.spec
if self.config.spec_path is not None:
# Force UTF-8 rather than relying on the process locale —
# OpenAPI specs can carry non-ASCII descriptions and we want
# cross-platform (e.g. Windows cp1252) loads to work.
return json.loads(Path(self.config.spec_path).read_text(encoding="utf-8"))
raise ValueError(
"OpenAPIConfig requires `spec` (inline dict) or `spec_path` "
"(local JSON file) to be set."
)
def _build_default_client(self, spec: dict[str, Any]) -> httpx.AsyncClient:
kwargs: dict[str, Any] = {
"base_url": self.config.base_url or resolve_spec_base_url(spec),
"timeout": self.config.timeout_secs,
}
if self.config.headers:
kwargs["headers"] = self.config.headers
return httpx.AsyncClient(**kwargs)
def _resolve_route_maps(self) -> list[RouteMap] | None:
# Typed override wins over dict-form config so power users who
# pass real RouteMap objects aren't shadowed by an empty default.
if self._route_maps_override is not None:
return self._route_maps_override
if self.config.route_maps:
return [rm.to_route_map() for rm in self.config.route_maps]
return None
__all__ = ["OpenAPI", "OpenAPIConfig", "RouteMapDict"]

View file

@ -0,0 +1,459 @@
"""OpenAPIProvider for creating MCP components from OpenAPI specifications."""
from __future__ import annotations
from collections import Counter
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, Literal, cast
import httpx
from jsonschema_path import SchemaPath
from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.plugins.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
_extract_mime_type_from_route,
_slugify,
)
from fastmcp.server.plugins.openapi.routing import (
DEFAULT_ROUTE_MAPPINGS,
ComponentFn,
MCPType,
RouteMap,
RouteMapFn,
_determine_route_type,
)
from fastmcp.server.providers.base import Provider
from fastmcp.tools.base import Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import (
HTTPRoute,
extract_output_schema_from_responses,
parse_openapi_to_http_routes,
)
from fastmcp.utilities.openapi.director import RequestDirector
from fastmcp.utilities.versions import VersionSpec, version_sort_key
__all__ = [
"OpenAPIProvider",
]
logger = get_logger(__name__)
DEFAULT_TIMEOUT: float = 30.0
def resolve_spec_base_url(openapi_spec: dict[str, Any]) -> str:
"""Resolve the first `servers[0].url` in an OpenAPI spec, substituting
any `servers[0].variables[name].default` values into `{name}`
placeholders.
Raised to module level so callers that build their own httpx client
(e.g. the `OpenAPI` plugin applying user-configured headers/timeout)
can still honor spec server templates without duplicating the
substitution logic.
"""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError(
"No server URL found in OpenAPI spec. Either add a 'servers' "
"entry to the spec or provide an httpx.AsyncClient explicitly."
)
base_url = servers[0]["url"]
variables = servers[0].get("variables", {})
for name, var in variables.items():
base_url = base_url.replace(f"{{{name}}}", var.get("default", ""))
return base_url
class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification.
Components are created eagerly during initialization by parsing the OpenAPI
spec. Each component makes HTTP calls to the described API endpoints.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server")
mcp.add_provider(provider)
```
"""
def __init__(
self,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient | None = None,
*,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
mcp_component_fn: ComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
tags: set[str] | None = None,
validate_output: bool = True,
_owns_client: bool | None = None,
):
"""Initialize provider by parsing OpenAPI spec and creating components.
Args:
openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx AsyncClient for making HTTP requests.
If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
To customize timeout or other settings, pass your own client.
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
tags: Optional set of tags to add to all components
validate_output: If True (default), tools use the output schema
extracted from the OpenAPI spec for response validation. If
False, a permissive schema is used instead, allowing any
response structure while still returning structured JSON.
_owns_client: Private opt-in for callers (like the OpenAPI plugin)
that built `client` themselves and want the provider's lifespan
to close it on shutdown. Leave `None` for the default
"own it iff we built it here" behavior.
"""
super().__init__()
if _owns_client is None:
_owns_client = client is None
self._owns_client = _owns_client
if client is None:
client = self._create_default_client(openapi_spec)
self._client = client
self._mcp_component_fn = mcp_component_fn
self._validate_output = validate_output
# Keep track of names to detect collisions
self._used_names: dict[str, Counter[str]] = {
"tool": Counter(),
"resource": Counter(),
"resource_template": Counter(),
"prompt": Counter(),
}
# Pre-created component storage
self._tools: dict[str, OpenAPITool] = {}
self._resources: dict[str, OpenAPIResource] = {}
self._templates: dict[str, OpenAPIResourceTemplate] = {}
# Create openapi-core Spec and RequestDirector
try:
self._spec = SchemaPath.from_dict(cast(Any, openapi_spec))
self._director = RequestDirector(self._spec)
except Exception as e:
logger.exception("Failed to initialize RequestDirector")
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
http_routes = parse_openapi_to_http_routes(openapi_spec)
# Process routes
route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
for route in http_routes:
route_map = _determine_route_type(route, route_maps)
route_type = route_map.mcp_type
if route_map_fn is not None:
try:
result = route_map_fn(route, route_type)
if result is not None:
route_type = result
logger.debug(
f"Route {route.method} {route.path} mapping customized: "
f"type={route_type.name}"
)
except Exception as e:
logger.warning(
f"Error in route_map_fn for {route.method} {route.path}: {e}. "
f"Using default values."
)
component_name = self._generate_default_name(route, mcp_names)
route_tags = set(route.tags) | route_map.mcp_tags | (tags or set())
if route_type == MCPType.TOOL:
self._create_openapi_tool(route, component_name, tags=route_tags)
elif route_type == MCPType.RESOURCE:
self._create_openapi_resource(route, component_name, tags=route_tags)
elif route_type == MCPType.RESOURCE_TEMPLATE:
self._create_openapi_template(route, component_name, tags=route_tags)
elif route_type == MCPType.EXCLUDE:
logger.debug(f"Excluding route: {route.method} {route.path}")
logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes")
@classmethod
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
return httpx.AsyncClient(
base_url=resolve_spec_base_url(openapi_spec),
timeout=DEFAULT_TIMEOUT,
)
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
"""Manage the lifecycle of the auto-created httpx client."""
if self._owns_client:
async with self._client:
yield
else:
yield
def _generate_default_name(
self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None
) -> str:
"""Generate a default name from the route."""
mcp_names_map = mcp_names_map or {}
if route.operation_id:
if route.operation_id in mcp_names_map:
name = mcp_names_map[route.operation_id]
else:
name = route.operation_id.split("__")[0]
else:
name = route.summary or f"{route.method}_{route.path}"
name = _slugify(name)
if len(name) > 56:
name = name[:56]
return name
def _get_unique_name(
self,
name: str,
component_type: Literal["tool", "resource", "resource_template", "prompt"],
) -> str:
"""Ensure the name is unique by appending numbers if needed."""
self._used_names[component_type][name] += 1
if self._used_names[component_type][name] == 1:
return name
new_name = f"{name}_{self._used_names[component_type][name]}"
logger.debug(
f"Name collision: '{name}' exists as {component_type}. Using '{new_name}'."
)
return new_name
def _create_openapi_tool(
self,
route: HTTPRoute,
name: str,
tags: set[str],
) -> None:
"""Create and register an OpenAPITool."""
combined_schema = route.flat_param_schema
output_schema = extract_output_schema_from_responses(
route.responses,
route.response_schemas,
route.openapi_version,
)
if not self._validate_output and output_schema is not None:
# Use a permissive schema that accepts any object, preserving
# the wrap-result flag so non-object responses still get wrapped
permissive: dict[str, Any] = {
"type": "object",
"additionalProperties": True,
}
if output_schema.get("x-fastmcp-wrap-result"):
permissive["x-fastmcp-wrap-result"] = True
output_schema = permissive
tool_name = self._get_unique_name(name, "tool")
base_description = (
route.description
or route.summary
or f"Executes {route.method} {route.path}"
)
tool = OpenAPITool(
client=self._client,
route=route,
director=self._director,
name=tool_name,
description=base_description,
parameters=combined_schema,
output_schema=output_schema,
tags=set(route.tags or []) | tags,
)
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, tool)
logger.debug(f"Tool {tool_name} customized by component_fn")
except Exception as e:
logger.warning(f"Error in component_fn for tool {tool_name}: {e}")
self._tools[tool.name] = tool
def _create_openapi_resource(
self,
route: HTTPRoute,
name: str,
tags: set[str],
) -> None:
"""Create and register an OpenAPIResource."""
resource_name = self._get_unique_name(name, "resource")
resource_uri = f"resource://{resource_name}"
base_description = (
route.description or route.summary or f"Represents {route.path}"
)
resource = OpenAPIResource(
client=self._client,
route=route,
director=self._director,
uri=resource_uri,
name=resource_name,
description=base_description,
mime_type=_extract_mime_type_from_route(route),
tags=set(route.tags or []) | tags,
)
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, resource)
logger.debug(f"Resource {resource_uri} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for resource {resource_uri}: {e}"
)
self._resources[str(resource.uri)] = resource
def _create_openapi_template(
self,
route: HTTPRoute,
name: str,
tags: set[str],
) -> None:
"""Create and register an OpenAPIResourceTemplate."""
template_name = self._get_unique_name(name, "resource_template")
path_params = sorted(p.name for p in route.parameters if p.location == "path")
uri_template_str = f"resource://{template_name}"
if path_params:
uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
base_description = (
route.description or route.summary or f"Template for {route.path}"
)
template_params_schema = {
"type": "object",
"properties": {
p.name: {
**(p.schema_.copy() if isinstance(p.schema_, dict) else {}),
**(
{"description": p.description}
if p.description
and not (
isinstance(p.schema_, dict) and "description" in p.schema_
)
else {}
),
}
for p in route.parameters
if p.location == "path"
},
"required": [
p.name for p in route.parameters if p.location == "path" and p.required
],
}
template = OpenAPIResourceTemplate(
client=self._client,
route=route,
director=self._director,
uri_template=uri_template_str,
name=template_name,
description=base_description,
parameters=template_params_schema,
tags=set(route.tags or []) | tags,
mime_type=_extract_mime_type_from_route(route),
)
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, template)
logger.debug(f"Template {uri_template_str} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for template {uri_template_str}: {e}"
)
self._templates[template.uri_template] = template
# -------------------------------------------------------------------------
# Provider interface
# -------------------------------------------------------------------------
async def _list_tools(self) -> Sequence[Tool]:
"""Return all tools created from the OpenAPI spec."""
return list(self._tools.values())
async def _get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name."""
tool = self._tools.get(name)
if tool is None:
return None
if version is not None and not version.matches(tool.version):
return None
return tool
async def _list_resources(self) -> Sequence[Resource]:
"""Return all resources created from the OpenAPI spec."""
return list(self._resources.values())
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI."""
resource = self._resources.get(uri)
if resource is None:
return None
if version is not None and not version.matches(resource.version):
return None
return resource
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates created from the OpenAPI spec."""
return list(self._templates.values())
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
matching = [t for t in self._templates.values() if t.matches(uri) is not None]
if not matching:
return None
if version is not None:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
async def _list_prompts(self) -> Sequence[Prompt]:
"""Return empty list - OpenAPI doesn't create prompts."""
return []
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return empty list - OpenAPI components don't support tasks."""
return []

View file

@ -0,0 +1,109 @@
"""Route mapping logic for OpenAPI operations."""
from __future__ import annotations
import enum
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from re import Pattern
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from fastmcp.server.plugins.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HttpMethod, HTTPRoute
__all__ = [
"ComponentFn",
"MCPType",
"RouteMap",
"RouteMapFn",
]
logger = get_logger(__name__)
# Type definitions for the mapping functions
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
ComponentFn = Callable[
[
HTTPRoute,
"OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
],
None,
]
class MCPType(enum.Enum):
"""Type of FastMCP component to create from a route.
Enum values:
TOOL: Convert the route to a callable Tool
RESOURCE: Convert the route to a Resource (typically GET endpoints)
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
EXCLUDE: Exclude the route from being converted to any MCP component
"""
TOOL = "TOOL"
RESOURCE = "RESOURCE"
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
EXCLUDE = "EXCLUDE"
@dataclass(kw_only=True)
class RouteMap:
"""Mapping configuration for HTTP routes to FastMCP component types."""
methods: list[HttpMethod] | Literal["*"] = field(default="*")
pattern: Pattern[str] | str = field(default=r".*")
tags: set[str] = field(
default_factory=set,
metadata={"description": "A set of tags to match. All tags must match."},
)
mcp_type: MCPType = field(
metadata={"description": "The type of FastMCP component to create."},
)
mcp_tags: set[str] = field(
default_factory=set,
metadata={
"description": "A set of tags to apply to the generated FastMCP component."
},
)
# Default route mapping: all routes become tools.
DEFAULT_ROUTE_MAPPINGS = [
RouteMap(mcp_type=MCPType.TOOL),
]
def _determine_route_type(
route: HTTPRoute,
mappings: list[RouteMap],
) -> RouteMap:
"""Determine the FastMCP component type based on the route and mappings."""
for route_map in mappings:
if route_map.methods == "*" or route.method in route_map.methods:
if isinstance(route_map.pattern, Pattern):
pattern_matches = route_map.pattern.search(route.path)
else:
pattern_matches = re.search(route_map.pattern, route.path)
if pattern_matches:
if route_map.tags:
route_tags_set = set(route.tags or [])
if not route_map.tags.issubset(route_tags_set):
continue
logger.debug(
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
)
return route_map
return RouteMap(mcp_type=MCPType.TOOL)

View file

@ -0,0 +1,17 @@
"""PromptsAsTools plugin — expose MCP prompts as callable tools.
from fastmcp import FastMCP
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("Server", plugins=[PromptsAsTools()])
The low-level `PromptsAsToolsTransform` lives in `.transform` for
advanced users who want to compose it directly with other transforms.
"""
from fastmcp.server.plugins.prompts_as_tools.plugin import (
PromptsAsTools,
PromptsAsToolsConfig,
)
__all__ = ["PromptsAsTools", "PromptsAsToolsConfig"]

View file

@ -0,0 +1,41 @@
"""PromptsAsTools plugin: expose MCP prompts as callable tools."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin
from fastmcp.server.plugins.prompts_as_tools.transform import PromptsAsToolsTransform
from fastmcp.server.transforms import Transform
class PromptsAsToolsConfig(BaseModel):
"""Config model for the `PromptsAsTools` plugin.
Currently empty included so plugin configs loaded from JSON/YAML
can still reference this plugin by name, and so future
per-deployment tool-name overrides have somewhere to land.
"""
model_config = ConfigDict(extra="forbid")
class PromptsAsTools(Plugin[PromptsAsToolsConfig]):
"""Append `list_prompts` and `get_prompt` synthetic tools to the catalog.
For clients that only speak the tools protocol, this plugin exposes
prompt discovery and rendering as regular tool calls. The generated
tools route through `ctx.fastmcp` at request time, so middleware,
auth, and visibility apply automatically.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("Server", plugins=[PromptsAsTools()])
```
"""
def transforms(self) -> list[Transform]:
return [PromptsAsToolsTransform()]

View file

@ -0,0 +1,157 @@
"""Low-level transform that powers the PromptsAsTools plugin.
`PromptsAsToolsTransform` appends two synthetic tools `list_prompts`
and `get_prompt` to the tool catalog, so clients that only speak the
tools protocol can still drive prompt discovery and rendering. Both
generated tools route through `get_context().fastmcp` at request time,
so middleware, auth, and visibility all apply exactly as they would for
direct `prompts/*` calls.
Most users should configure this through the `PromptsAsTools` plugin
(`fastmcp.server.plugins.prompts_as_tools`). The transform is exposed
for advanced composition and for backcompat with the old
`fastmcp.server.transforms.prompts_as_tools` path.
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from typing import TYPE_CHECKING, Annotated, Any
from mcp.types import TextContent
from fastmcp.server.dependencies import get_context
from fastmcp.server.transforms import GetToolNext, Transform
from fastmcp.tools.base import Tool
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.server.providers.base import Provider
class PromptsAsToolsTransform(Transform):
"""Transform that adds `list_prompts` and `get_prompt` synthetic tools.
The generated tools call back into the server via `ctx.fastmcp` at
request time, so server middleware (auth, visibility, rate limiting)
applies automatically.
The `provider` argument exists purely for intent if passed, it
must be a FastMCP server instance (raw providers don't expose an
`add_transform` path anyway). The plugin wrapper constructs this
transform without a provider.
Example:
```python
mcp = FastMCP("Server")
mcp.add_transform(PromptsAsToolsTransform(mcp))
```
"""
def __init__(self, provider: Provider | None = None) -> None:
if provider is not None:
from fastmcp.server.server import FastMCP
if not isinstance(provider, FastMCP):
raise TypeError(
"PromptsAsToolsTransform accepts a FastMCP server instance, "
f"not a {type(provider).__name__}. The generated tools route "
"through the server's middleware chain at runtime for auth "
"and visibility. Pass your FastMCP server, or omit the "
"argument entirely when using the plugin wrapper."
)
self._provider = provider
def __repr__(self) -> str:
return f"PromptsAsToolsTransform({self._provider!r})"
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [
*tools,
self._make_list_prompts_tool(),
self._make_get_prompt_tool(),
]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
if name == "list_prompts":
return self._make_list_prompts_tool()
if name == "get_prompt":
return self._make_get_prompt_tool()
return await call_next(name, version=version)
def _make_list_prompts_tool(self) -> Tool:
async def list_prompts() -> str:
"""List all available prompts.
Returns JSON with prompt metadata including name, description,
and optional arguments.
"""
ctx = get_context()
prompts = await ctx.fastmcp.list_prompts()
result: list[dict[str, Any]] = []
for p in prompts:
result.append( # noqa: PERF401
{
"name": p.name,
"description": p.description,
"arguments": [
{
"name": arg.name,
"description": arg.description,
"required": arg.required,
}
for arg in (p.arguments or [])
],
}
)
return json.dumps(result, indent=2)
return Tool.from_function(fn=list_prompts)
def _make_get_prompt_tool(self) -> Tool:
async def get_prompt(
name: Annotated[str, "The name of the prompt to get"],
arguments: Annotated[
dict[str, Any] | None,
"Optional arguments for the prompt",
] = None,
) -> str:
"""Get a prompt by name with optional arguments.
Returns the rendered prompt as JSON with a messages array.
Arguments should be provided as a dict mapping argument names
to values.
"""
ctx = get_context()
result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {})
return _format_prompt_result(result)
return Tool.from_function(fn=get_prompt)
def _format_prompt_result(result: Any) -> str:
"""Format PromptResult for tool output.
Returns JSON with the messages array. Preserves embedded resources
as structured JSON objects.
"""
messages = []
for msg in result.messages:
if isinstance(msg.content, TextContent):
content = msg.content.text
else:
content = msg.content.model_dump(mode="json", exclude_none=True)
messages.append(
{
"role": msg.role,
"content": content,
}
)
return json.dumps({"messages": messages}, indent=2)

View file

@ -0,0 +1,17 @@
"""ResourcesAsTools plugin — expose MCP resources as callable tools.
from fastmcp import FastMCP
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("Server", plugins=[ResourcesAsTools()])
The low-level `ResourcesAsToolsTransform` lives in `.transform` for
advanced users who want to compose it directly with other transforms.
"""
from fastmcp.server.plugins.resources_as_tools.plugin import (
ResourcesAsTools,
ResourcesAsToolsConfig,
)
__all__ = ["ResourcesAsTools", "ResourcesAsToolsConfig"]

View file

@ -0,0 +1,43 @@
"""ResourcesAsTools plugin: expose MCP resources as callable tools."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin
from fastmcp.server.plugins.resources_as_tools.transform import (
ResourcesAsToolsTransform,
)
from fastmcp.server.transforms import Transform
class ResourcesAsToolsConfig(BaseModel):
"""Config model for the `ResourcesAsTools` plugin.
Currently empty included so plugin configs loaded from JSON/YAML
can still reference this plugin by name, and so future
per-deployment tool-name overrides have somewhere to land.
"""
model_config = ConfigDict(extra="forbid")
class ResourcesAsTools(Plugin[ResourcesAsToolsConfig]):
"""Append `list_resources` and `read_resource` synthetic tools to the catalog.
For clients that only speak the tools protocol, this plugin exposes
resource discovery and reads as regular tool calls. The generated
tools route through `ctx.fastmcp` at request time, so middleware,
auth, and visibility apply automatically.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("Server", plugins=[ResourcesAsTools()])
```
"""
def transforms(self) -> list[Transform]:
return [ResourcesAsToolsTransform()]

View file

@ -0,0 +1,168 @@
"""Low-level transform that powers the ResourcesAsTools plugin.
`ResourcesAsToolsTransform` appends two synthetic tools
`list_resources` and `read_resource` to the tool catalog, so clients
that only speak the tools protocol can still drive resource discovery
and reads. Both generated tools route through `get_context().fastmcp`
at request time, so middleware, auth, and visibility all apply exactly
as they would for direct `resources/*` calls.
Most users should configure this through the `ResourcesAsTools` plugin
(`fastmcp.server.plugins.resources_as_tools`). The transform is exposed
for advanced composition and for backcompat with the old
`fastmcp.server.transforms.resources_as_tools` path.
"""
from __future__ import annotations
import base64
import json
from collections.abc import Sequence
from typing import TYPE_CHECKING, Annotated, Any
from mcp.types import ToolAnnotations
from fastmcp.server.dependencies import get_context
from fastmcp.server.transforms import GetToolNext, Transform
from fastmcp.tools.base import Tool
from fastmcp.utilities.versions import VersionSpec
_DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True)
if TYPE_CHECKING:
from fastmcp.server.providers.base import Provider
class ResourcesAsToolsTransform(Transform):
"""Transform that adds `list_resources` and `read_resource` synthetic tools.
The generated tools call back into the server via `ctx.fastmcp` at
request time, so server middleware (auth, visibility, rate limiting)
applies automatically.
The `provider` argument exists purely for intent if passed, it
must be a FastMCP server instance (raw providers don't expose an
`add_transform` path anyway). The plugin wrapper constructs this
transform without a provider.
Example:
```python
mcp = FastMCP("Server")
mcp.add_transform(ResourcesAsToolsTransform(mcp))
```
"""
def __init__(self, provider: Provider | None = None) -> None:
if provider is not None:
from fastmcp.server.server import FastMCP
if not isinstance(provider, FastMCP):
raise TypeError(
"ResourcesAsToolsTransform accepts a FastMCP server instance, "
f"not a {type(provider).__name__}. The generated tools route "
"through the server's middleware chain at runtime for auth "
"and visibility. Pass your FastMCP server, or omit the "
"argument entirely when using the plugin wrapper."
)
self._provider = provider
def __repr__(self) -> str:
return f"ResourcesAsToolsTransform({self._provider!r})"
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
return [
*tools,
self._make_list_resources_tool(),
self._make_read_resource_tool(),
]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
if name == "list_resources":
return self._make_list_resources_tool()
if name == "read_resource":
return self._make_read_resource_tool()
return await call_next(name, version=version)
def _make_list_resources_tool(self) -> Tool:
async def list_resources() -> str:
"""List all available resources and resource templates.
Returns JSON with resource metadata. Static resources have a
'uri' field, while templates have a 'uri_template' field with
placeholders like {name}.
"""
ctx = get_context()
resources = await ctx.fastmcp.list_resources()
templates = await ctx.fastmcp.list_resource_templates()
result: list[dict[str, Any]] = []
for r in resources:
result.append( # noqa: PERF401
{
"uri": str(r.uri),
"name": r.name,
"description": r.description,
"mime_type": r.mime_type,
}
)
for t in templates:
result.append( # noqa: PERF401
{
"uri_template": t.uri_template,
"name": t.name,
"description": t.description,
}
)
return json.dumps(result, indent=2)
return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS)
def _make_read_resource_tool(self) -> Tool:
async def read_resource(
uri: Annotated[str, "The URI of the resource to read"],
) -> str:
"""Read a resource by its URI.
For static resources, provide the exact URI. For templated
resources, provide the URI with template parameters filled in.
Returns the resource content as a string. Binary content is
base64-encoded.
"""
ctx = get_context()
result = await ctx.fastmcp.read_resource(uri)
return _format_result(result)
return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS)
def _format_result(result: Any) -> str:
"""Format ResourceResult for tool output.
Single text content is returned as-is. Single binary content is
base64-encoded. Multiple contents are JSON-encoded.
"""
if len(result.contents) == 1:
content = result.contents[0].content
if isinstance(content, bytes):
return base64.b64encode(content).decode()
return content
return json.dumps(
[
{
"content": (
c.content
if isinstance(c.content, str)
else base64.b64encode(c.content).decode()
),
"mime_type": c.mime_type,
}
for c in result.contents
]
)

View file

@ -0,0 +1,15 @@
"""Skills plugin — expose agent skill folders as MCP resources.
from fastmcp import FastMCP
from fastmcp.server.plugins.skills import Skills, SkillsConfig
mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))])
The underlying `SkillProvider` and `SkillsDirectoryProvider` classes
live on `.skill_provider` and `.directory_provider` submodules for
direct-composition use cases; the plugin is the canonical entry point.
"""
from fastmcp.server.plugins.skills.plugin import Skills, SkillsConfig
__all__ = ["Skills", "SkillsConfig"]

View file

@ -0,0 +1,44 @@
"""Claude-specific skills provider for Claude Code skills."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
class ClaudeSkillsProvider(SkillsDirectoryProvider):
"""Provider for Claude Code skills from ~/.claude/skills/.
A convenience subclass that sets the default root to Claude's skills location.
Args:
reload: If True, re-scan on every request. Defaults to False.
supporting_files: How supporting files are exposed:
- "template": Accessed via ResourceTemplate, hidden from list_resources().
- "resources": Each file exposed as individual Resource in list_resources().
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.skills import ClaudeSkillsProvider
mcp = FastMCP("Claude Skills")
mcp.add_provider(ClaudeSkillsProvider()) # Uses default location
```
"""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".claude" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)

View file

@ -0,0 +1,153 @@
"""Directory scanning provider for discovering multiple skills."""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from typing import Literal
from fastmcp.resources.base import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.versions import VersionSpec
logger = get_logger(__name__)
class SkillsDirectoryProvider(AggregateProvider):
"""Provider that scans directories and creates a SkillProvider per skill folder.
This extends AggregateProvider to combine multiple SkillProviders into one.
Each subdirectory containing a main file (default: SKILL.md) becomes a skill.
Can scan multiple root directories - if a skill name appears in multiple roots,
the first one found wins.
Args:
roots: Root directory(ies) containing skill folders. Can be a single path
or a sequence of paths.
reload: If True, re-discover skills on each request. Defaults to False.
main_file_name: Name of the main skill file. Defaults to "SKILL.md".
supporting_files: How supporting files are exposed in child SkillProviders:
- "template": Accessed via ResourceTemplate, hidden from list_resources().
- "resources": Each file exposed as individual Resource in list_resources().
Example:
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.plugins.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills")
# Single directory
mcp.add_provider(SkillsDirectoryProvider(
roots=Path.home() / ".claude" / "skills",
reload=True, # Re-scan on each request
))
# Multiple directories
mcp.add_provider(SkillsDirectoryProvider(
roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"],
))
```
"""
def __init__(
self,
roots: str | Path | Sequence[str | Path],
reload: bool = False,
main_file_name: str = "SKILL.md",
supporting_files: Literal["template", "resources"] = "template",
) -> None:
super().__init__()
# Normalize to sequence: single path becomes list
if isinstance(roots, (str, Path)):
roots = [roots]
self._roots = [Path(r).resolve() for r in roots]
self._reload = reload
self._main_file_name = main_file_name
self._supporting_files = supporting_files
self._discovered = False
# Discover skills at init
self._discover_skills()
def _discover_skills(self) -> None:
"""Scan root directories and create SkillProvider per valid skill folder."""
# Clear existing providers if reloading
self.providers.clear()
seen_skill_names: set[str] = set()
for root in self._roots:
if not root.exists():
logger.debug(f"Skills root does not exist: {root}")
continue
for skill_dir in root.iterdir():
if not skill_dir.is_dir():
continue
main_file = skill_dir / self._main_file_name
if not main_file.exists():
continue
skill_name = skill_dir.name
# Skip if we've already seen this skill name (first wins)
if skill_name in seen_skill_names:
logger.debug(
f"Skipping duplicate skill '{skill_name}' from {root} "
f"(already found in earlier root)"
)
continue
try:
provider = SkillProvider(
skill_path=skill_dir,
main_file_name=self._main_file_name,
supporting_files=self._supporting_files,
)
self.providers.append(provider)
seen_skill_names.add(skill_name)
except (FileNotFoundError, PermissionError, OSError):
logger.exception(f"Failed to load skill: {skill_dir.name}")
self._discovered = True
logger.debug(
f"SkillsDirectoryProvider loaded {len(self.providers)} skills "
f"from {len(self._roots)} root(s)"
)
async def _ensure_discovered(self) -> None:
"""Ensure skills are discovered, rediscovering if reload is enabled."""
if self._reload or not self._discovered:
self._discover_skills()
# Override list methods to support reload
async def _list_resources(self) -> Sequence[Resource]:
await self._ensure_discovered()
return await super()._list_resources()
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
await self._ensure_discovered()
return await super()._list_resource_templates()
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
await self._ensure_discovered()
return await super()._get_resource(uri, version)
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
await self._ensure_discovered()
return await super()._get_resource_template(uri, version)
def __repr__(self) -> str:
roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots
return (
f"SkillsDirectoryProvider(roots={roots_repr!r}, "
f"reload={self._reload}, skills={len(self.providers)})"
)

View file

@ -0,0 +1,159 @@
"""Skills plugin: expose agent skill folders as MCP resources."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
from fastmcp.server.providers import Provider
# Vendor-name → list of skill-root paths. Captures the same preset
# paths the vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`,
# etc.) used to hardcode. The dict lets `Skills(SkillsConfig(vendor="claude"))`
# replace seven separate subclass names with one plugin + an enum value.
VENDOR_PATHS: dict[str, list[Path]] = {
"claude": [Path.home() / ".claude" / "skills"],
"cursor": [Path.home() / ".cursor" / "skills"],
# VSCode and Copilot both resolve to ~/.copilot/skills in the pre-plugin
# vendor subclasses; preserved verbatim for backcompat.
"vscode": [Path.home() / ".copilot" / "skills"],
"copilot": [Path.home() / ".copilot" / "skills"],
"codex": [Path("/etc/codex/skills"), Path.home() / ".codex" / "skills"],
"gemini": [Path.home() / ".gemini" / "skills"],
"goose": [Path.home() / ".config" / "agents" / "skills"],
"opencode": [Path.home() / ".config" / "opencode" / "skills"],
}
Vendor = Literal[
"claude",
"copilot",
"codex",
"cursor",
"gemini",
"goose",
"opencode",
"vscode",
]
class SkillsConfig(BaseModel):
"""Config model for the `Skills` plugin.
Exactly one of `path`, `directory`, or `vendor` must be set. The
check fires when the plugin builds its provider, not at config
construction, so `SkillsConfig()` with no args still satisfies the
plugin-framework's defaults-are-instantiable contract.
"""
model_config = ConfigDict(extra="forbid")
path: str | None = None
"""Path to a single skill folder. Equivalent to the old
`SkillProvider(path)` construction."""
directory: str | list[str] | None = None
"""One or more directories to scan for skill subfolders. Equivalent
to `SkillsDirectoryProvider(roots=...)`."""
vendor: Vendor | None = None
"""Preset for a known vendor tool — resolves to that tool's
conventional skills directory. Covers the set that the old
`ClaudeSkillsProvider`, `CursorSkillsProvider`, etc. subclasses
hardcoded."""
reload: bool = False
"""Re-scan on each request. Useful in development; leave off in
production where the skill catalog doesn't change."""
main_file_name: str = "SKILL.md"
"""Name of the main file inside a skill folder."""
supporting_files: Literal["template", "resources"] = "template"
"""How non-main files inside a skill folder are exposed.
- `"template"`: accessed via a single `ResourceTemplate`, hidden
from `list_resources()`.
- `"resources"`: each file becomes its own `Resource` in
`list_resources()`.
"""
class Skills(Plugin[SkillsConfig]):
"""Mount agent skill folders as MCP resources.
One plugin covers all three entry points the pre-plugin API
exposed as separate provider classes: single-folder,
scan-a-directory, and vendor-preset.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.skills import Skills, SkillsConfig
# Vendor preset — the common case:
mcp = FastMCP(
"skills",
plugins=[Skills(SkillsConfig(vendor="claude"))],
)
# Custom directory:
mcp = FastMCP(
"skills",
plugins=[Skills(SkillsConfig(directory="./skills"))],
)
# Single skill folder:
mcp = FastMCP(
"skills",
plugins=[Skills(SkillsConfig(path="./skills/pdf-processing"))],
)
```
"""
def providers(self) -> list[Provider]:
return [self._build_provider()]
def _build_provider(self) -> Provider:
sources_set = sum(
bool(x)
for x in (self.config.path, self.config.directory, self.config.vendor)
)
if sources_set == 0:
raise ValueError(
"SkillsConfig requires one of `path`, `directory`, or `vendor`."
)
if sources_set > 1:
raise ValueError(
"SkillsConfig requires exactly one of `path`, `directory`, or "
"`vendor` — got multiple."
)
if self.config.path is not None:
return SkillProvider(
skill_path=self.config.path,
main_file_name=self.config.main_file_name,
supporting_files=self.config.supporting_files,
)
if self.config.vendor is not None:
roots: Any = VENDOR_PATHS[self.config.vendor]
else:
# directory mode — accept str or list[str]
assert self.config.directory is not None
roots = (
[self.config.directory]
if isinstance(self.config.directory, str)
else list(self.config.directory)
)
return SkillsDirectoryProvider(
roots=roots,
reload=self.config.reload,
main_file_name=self.config.main_file_name,
supporting_files=self.config.supporting_files,
)

View file

@ -0,0 +1,449 @@
"""Basic skill provider for handling a single skill folder."""
from __future__ import annotations
import json
import mimetypes
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Literal, cast
from pydantic import AnyUrl
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.plugins.skills._common import (
SkillInfo,
parse_frontmatter,
scan_skill_files,
)
from fastmcp.server.providers.base import Provider
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.versions import VersionSpec
logger = get_logger(__name__)
# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this)
mimetypes.add_type("text/markdown", ".md")
# -----------------------------------------------------------------------------
# Skill-specific Resource and ResourceTemplate subclasses
# -----------------------------------------------------------------------------
class SkillResource(Resource):
"""A resource representing a skill's main file or manifest."""
skill_info: SkillInfo
is_manifest: bool = False
def get_meta(self) -> dict[str, Any]:
meta = super().get_meta()
fastmcp = cast(dict[str, Any], meta["fastmcp"])
fastmcp["skill"] = {
"name": self.skill_info.name,
"is_manifest": self.is_manifest,
}
return meta
async def read(self) -> str | bytes | ResourceResult:
"""Read the resource content."""
if self.is_manifest:
return self._generate_manifest()
else:
main_file_path = self.skill_info.path / self.skill_info.main_file
return main_file_path.read_text()
def _generate_manifest(self) -> str:
"""Generate JSON manifest for the skill."""
manifest = {
"skill": self.skill_info.name,
"files": [
{"path": f.path, "size": f.size, "hash": f.hash}
for f in self.skill_info.files
],
}
return json.dumps(manifest, indent=2)
class SkillFileTemplate(ResourceTemplate):
"""A template for accessing files within a skill."""
skill_info: SkillInfo
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
"""Read a file from the skill directory."""
file_path = arguments.get("path", "")
full_path = self.skill_info.path / file_path
# Security: ensure path doesn't escape skill directory
try:
full_path = full_path.resolve()
if not full_path.is_relative_to(self.skill_info.path):
raise ValueError(f"Path {file_path} escapes skill directory")
except ValueError as e:
raise ValueError(f"Invalid path: {e}") from e
if not full_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if not full_path.is_file():
raise ValueError(f"Not a file: {file_path}")
# Determine if binary or text based on mime type
mime_type, _ = mimetypes.guess_type(str(full_path))
if mime_type and mime_type.startswith("text/"):
return full_path.read_text()
else:
return full_path.read_bytes()
async def _read( # type: ignore[override]
self,
uri: str,
params: dict[str, Any],
task_meta: Any = None,
) -> ResourceResult: # ty:ignore[invalid-method-override]
"""Server entry point - read file directly without creating ephemeral resource.
Note: task_meta is ignored - this template doesn't support background tasks.
"""
# Call read() directly and convert to ResourceResult
result = await self.read(arguments=params)
return self.convert_result(result)
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
"""Create a resource for the given URI and parameters.
Note: This is not typically used since _read() handles file reading directly.
Provided for compatibility with the ResourceTemplate interface.
"""
file_path = params.get("path", "")
full_path = (self.skill_info.path / file_path).resolve()
# Security: ensure path doesn't escape skill directory
if not full_path.is_relative_to(self.skill_info.path):
raise ValueError(f"Path {file_path} escapes skill directory")
mime_type, _ = mimetypes.guess_type(str(full_path))
# Create a SkillFileResource that can read the file
return SkillFileResource(
uri=AnyUrl(uri),
name=f"{self.skill_info.name}/{file_path}",
description=f"File from {self.skill_info.name} skill",
mime_type=mime_type or "application/octet-stream",
skill_info=self.skill_info,
file_path=file_path,
)
class SkillFileResource(Resource):
"""A resource representing a specific file within a skill."""
skill_info: SkillInfo
file_path: str
def get_meta(self) -> dict[str, Any]:
meta = super().get_meta()
fastmcp = cast(dict[str, Any], meta["fastmcp"])
fastmcp["skill"] = {
"name": self.skill_info.name,
}
return meta
async def read(self) -> str | bytes | ResourceResult:
"""Read the file content."""
full_path = self.skill_info.path / self.file_path
# Security check
full_path = full_path.resolve()
if not full_path.is_relative_to(self.skill_info.path):
raise ValueError(f"Path {self.file_path} escapes skill directory")
if not full_path.exists():
raise FileNotFoundError(f"File not found: {self.file_path}")
mime_type, _ = mimetypes.guess_type(str(full_path))
if mime_type and mime_type.startswith("text/"):
return full_path.read_text()
else:
return full_path.read_bytes()
# -----------------------------------------------------------------------------
# SkillProvider - handles a SINGLE skill folder
# -----------------------------------------------------------------------------
class SkillProvider(Provider):
"""Provider that exposes a single skill folder as MCP resources.
Each skill folder must contain a main file (default: SKILL.md) and may
contain additional supporting files.
Exposes:
- A Resource for the main file (skill://{name}/SKILL.md)
- A Resource for the synthetic manifest (skill://{name}/_manifest)
- Supporting files via ResourceTemplate or Resources (configurable)
Args:
skill_path: Path to the skill directory.
main_file_name: Name of the main skill file. Defaults to "SKILL.md".
supporting_files: How supporting files (everything except main file and
manifest) are exposed to clients:
- "template": Accessed via ResourceTemplate, hidden from list_resources().
Clients discover files by reading the manifest first.
- "resources": Each file exposed as individual Resource in list_resources().
Full enumeration upfront.
Example:
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.plugins.skills import SkillProvider
mcp = FastMCP("My Skill")
mcp.add_provider(SkillProvider(
Path.home() / ".claude/skills/pdf-processing"
))
```
"""
def __init__(
self,
skill_path: str | Path,
main_file_name: str = "SKILL.md",
supporting_files: Literal["template", "resources"] = "template",
) -> None:
super().__init__()
self._skill_path = Path(skill_path).resolve()
self._main_file_name = main_file_name
self._supporting_files = supporting_files
self._skill_info: SkillInfo | None = None
# Load at init to catch errors early
self._load_skill()
def _load_skill(self) -> None:
"""Load and parse the skill directory."""
main_file = self._skill_path / self._main_file_name
if not self._skill_path.exists():
raise FileNotFoundError(f"Skill directory not found: {self._skill_path}")
if not main_file.exists():
raise FileNotFoundError(
f"Main skill file not found: {main_file}. "
f"Expected {self._main_file_name} in {self._skill_path}"
)
content = main_file.read_text()
frontmatter, body = parse_frontmatter(content)
# Get description from frontmatter or first non-empty line
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line[:200]
break
elif line.startswith("#"):
description = line.lstrip("#").strip()[:200]
break
# Scan all files in the skill directory
files = scan_skill_files(self._skill_path)
self._skill_info = SkillInfo(
name=self._skill_path.name,
description=description or f"Skill: {self._skill_path.name}",
path=self._skill_path,
main_file=self._main_file_name,
files=files,
frontmatter=frontmatter,
)
logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}")
@property
def skill_info(self) -> SkillInfo:
"""Get the loaded skill info."""
if self._skill_info is None:
raise RuntimeError("Skill not loaded")
return self._skill_info
# -------------------------------------------------------------------------
# Provider interface implementation
# -------------------------------------------------------------------------
async def _list_resources(self) -> Sequence[Resource]:
"""List skill resources."""
skill = self.skill_info
resources: list[Resource] = []
# Main skill file
resources.append(
SkillResource(
uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"),
name=f"{skill.name}/{self._main_file_name}",
description=skill.description,
mime_type="text/markdown",
skill_info=skill,
is_manifest=False,
)
)
# Synthetic manifest
resources.append(
SkillResource(
uri=AnyUrl(f"skill://{skill.name}/_manifest"),
name=f"{skill.name}/_manifest",
description=f"File listing for {skill.name}",
mime_type="application/json",
skill_info=skill,
is_manifest=True,
)
)
# If supporting_files="resources", add all supporting files as resources
if self._supporting_files == "resources":
for file_info in skill.files:
# Skip main file and manifest (already added)
if file_info.path == self._main_file_name:
continue
mime_type, _ = mimetypes.guess_type(file_info.path)
resources.append(
SkillFileResource(
uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"),
name=f"{skill.name}/{file_info.path}",
description=f"File from {skill.name} skill",
mime_type=mime_type or "application/octet-stream",
skill_info=skill,
file_path=file_info.path,
)
)
return resources
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI."""
skill = self.skill_info
# Parse URI: skill://{skill_name}/{file_path}
if not uri.startswith("skill://"):
return None
path_part = uri[len("skill://") :]
parts = path_part.split("/", 1)
if len(parts) != 2:
return None
skill_name, file_path = parts
if skill_name != skill.name:
return None
if file_path == "_manifest":
return SkillResource(
uri=AnyUrl(uri),
name=f"{skill_name}/_manifest",
description=f"File listing for {skill_name}",
mime_type="application/json",
skill_info=skill,
is_manifest=True,
)
elif file_path == self._main_file_name:
return SkillResource(
uri=AnyUrl(uri),
name=f"{skill_name}/{self._main_file_name}",
description=skill.description,
mime_type="text/markdown",
skill_info=skill,
is_manifest=False,
)
elif self._supporting_files == "resources":
# Check if it's a known supporting file
for file_info in skill.files:
if file_info.path == file_path:
mime_type, _ = mimetypes.guess_type(file_path)
return SkillFileResource(
uri=AnyUrl(uri),
name=f"{skill_name}/{file_path}",
description=f"File from {skill_name} skill",
mime_type=mime_type or "application/octet-stream",
skill_info=skill,
file_path=file_path,
)
return None
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List resource templates for accessing files within the skill."""
# Only expose template if supporting_files="template"
if self._supporting_files != "template":
return []
skill = self.skill_info
return [
SkillFileTemplate(
uri_template=f"skill://{skill.name}/{{path*}}",
name=f"{skill.name}_files",
description=f"Access files within {skill.name}",
mime_type="application/octet-stream",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
skill_info=skill,
)
]
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
# Only match if supporting_files="template"
if self._supporting_files != "template":
return None
skill = self.skill_info
if not uri.startswith("skill://"):
return None
path_part = uri[len("skill://") :]
parts = path_part.split("/", 1)
if len(parts) != 2:
return None
skill_name, file_path = parts
if skill_name != skill.name:
return None
# Don't match known resources (main file, manifest)
if file_path == "_manifest" or file_path == self._main_file_name:
return None
return SkillFileTemplate(
uri_template=f"skill://{skill.name}/{{path*}}",
name=f"{skill.name}_files",
description=f"Access files within {skill.name}",
mime_type="application/octet-stream",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
skill_info=skill,
)
def __repr__(self) -> str:
return (
f"SkillProvider(skill_path={self._skill_path!r}, "
f"supporting_files={self._supporting_files!r})"
)

View file

@ -0,0 +1,142 @@
"""Vendor-specific skills providers for various AI coding platforms."""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
class CursorSkillsProvider(SkillsDirectoryProvider):
"""Cursor skills from ~/.cursor/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".cursor" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class VSCodeSkillsProvider(SkillsDirectoryProvider):
"""VS Code skills from ~/.copilot/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".copilot" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class CodexSkillsProvider(SkillsDirectoryProvider):
"""Codex skills from /etc/codex/skills/ and ~/.codex/skills/.
Scans both system-level and user-level directories. System skills take
precedence if duplicates exist.
"""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
system_root = Path("/etc/codex/skills")
user_root = Path.home() / ".codex" / "skills"
# Include both paths (system first, then user)
roots = [system_root, user_root]
super().__init__(
roots=roots,
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class GeminiSkillsProvider(SkillsDirectoryProvider):
"""Gemini skills from ~/.gemini/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".gemini" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class GooseSkillsProvider(SkillsDirectoryProvider):
"""Goose skills from ~/.config/agents/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".config" / "agents" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class CopilotSkillsProvider(SkillsDirectoryProvider):
"""GitHub Copilot skills from ~/.copilot/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".copilot" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class OpenCodeSkillsProvider(SkillsDirectoryProvider):
"""OpenCode skills from ~/.config/opencode/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".config" / "opencode" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)

View file

@ -0,0 +1,18 @@
"""Tool-search plugin — replace the tool catalog with a search interface.
The `ToolSearch` plugin is the public entry point:
from fastmcp import FastMCP
from fastmcp.server.plugins.tool_search import ToolSearch
mcp = FastMCP("Server", plugins=[ToolSearch()])
Transform classes (`BM25SearchTransform`, `RegexSearchTransform`,
`BaseSearchTransform`) live in `.bm25`, `.regex`, `.base` submodules
for advanced composition (custom transform stacks) but are not
re-exported here import from the submodule path when needed.
"""
from fastmcp.server.plugins.tool_search.plugin import ToolSearch, ToolSearchConfig
__all__ = ["ToolSearch", "ToolSearchConfig"]

View file

@ -0,0 +1,265 @@
"""Base class for search transforms.
Search transforms replace `list_tools()` output with a small set of
synthetic tools a search tool and a call-tool proxy so LLMs can
discover tools on demand instead of receiving the full catalog.
These classes are the implementation layer of the `ToolSearch` plugin.
Typical usage is via the plugin:
from fastmcp import FastMCP
from fastmcp.server.plugins.tool_search import ToolSearch
mcp = FastMCP("Server", plugins=[ToolSearch()])
`BM25SearchTransform` and `RegexSearchTransform` are exposed for
advanced composition (custom transform stacks) but the plugin is the
recommended entry point.
"""
from abc import abstractmethod
from collections.abc import Awaitable, Callable, Sequence
from typing import Annotated, Any
from fastmcp.server.context import Context
from fastmcp.server.transforms import GetToolNext
from fastmcp.server.transforms.catalog import CatalogTransform
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.versions import VersionSpec
def _extract_searchable_text(tool: Tool) -> str:
"""Combine tool name, description, and parameter info into searchable text."""
parts = [tool.name]
if tool.description:
parts.append(tool.description)
schema = tool.parameters
if schema:
properties = schema.get("properties", {})
for param_name, param_info in properties.items():
parts.append(param_name)
if isinstance(param_info, dict):
desc = param_info.get("description", "")
if desc:
parts.append(desc)
return " ".join(parts)
def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]:
"""Serialize tools to the same dict format as `list_tools` output."""
return [
tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools
]
SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]]
async def _invoke_serializer(
serializer: SearchResultSerializer, tools: Sequence[Tool]
) -> Any:
"""Call a serializer and await the result if it returns a coroutine."""
result = serializer(tools)
if isinstance(result, Awaitable):
return await result
return result
def _union_type(branches: list[Any]) -> str:
branch_types = list(dict.fromkeys(_schema_type(b) for b in branches))
if "null" not in branch_types:
return " | ".join(branch_types) if branch_types else "any"
non_null = [b for b in branch_types if b != "null"]
if not non_null:
return "null"
return f"{' | '.join(non_null)}?"
def _schema_type(schema: Any) -> str:
# Intentionally heuristic: the goal is a concise readable label, not a
# complete type system. Malformed schemas (e.g. {"type": ""}) → "any".
if not isinstance(schema, dict):
return "any"
t = schema.get("type")
if isinstance(t, str) and t:
if t == "array":
return f"{_schema_type(schema.get('items'))}[]"
if t == "null":
return "null"
return t
if "$ref" in schema:
return "object"
if "anyOf" in schema:
return _union_type(schema["anyOf"])
if "oneOf" in schema:
return _union_type(schema["oneOf"])
if "allOf" in schema:
# allOf = intersection / Pydantic composed model → always an object
return "object"
return "object" if "properties" in schema else "any"
def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]:
lines = [f"**{title}**"]
if not isinstance(schema, dict):
lines.append("- `value` (any)")
return lines
props = schema.get("properties")
raw_required = schema.get("required")
req = set(raw_required) if isinstance(raw_required, list) else set()
if props is None:
# Not a properties-based schema — treat as a single unnamed value.
lines.append(f"- `value` ({_schema_type(schema)})")
return lines
if not props:
# Object schema with no properties — zero-argument tool.
lines.append("*(no parameters)*")
return lines
for name, field in props.items():
required = ", required" if name in req else ""
lines.append(f"- `{name}` ({_schema_type(field)}{required})")
return lines
def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str:
"""Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON."""
if not tools:
return "No tools matched the query."
blocks: list[str] = []
for tool in tools:
lines = [f"### {tool.name}"]
if tool.description:
lines.extend(["", tool.description.strip()])
lines.extend(["", *_schema_section(tool.parameters, "Parameters")])
if tool.output_schema is not None:
lines.extend(["", *_schema_section(tool.output_schema, "Returns")])
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
class BaseSearchTransform(CatalogTransform):
"""Replace the tool listing with a search interface.
When this transform is active, `list_tools()` returns only:
* Any tools listed in `always_visible` (pinned).
* A **search tool** that finds tools matching a query.
* A **call_tool** proxy that executes tools discovered via search.
Hidden tools remain callable `get_tool()` delegates unknown
names downstream, so direct calls and the call-tool proxy both work.
Search results respect the full auth pipeline: middleware, visibility
transforms, and component-level auth checks all apply.
Args:
max_results: Maximum number of tools returned per search.
always_visible: Tool names that stay in the `list_tools`
output alongside the synthetic search/call tools.
search_tool_name: Name of the generated search tool.
call_tool_name: Name of the generated call-tool proxy.
"""
def __init__(
self,
*,
max_results: int = 5,
always_visible: list[str] | None = None,
search_tool_name: str = "search_tools",
call_tool_name: str = "call_tool",
search_result_serializer: SearchResultSerializer | None = None,
) -> None:
super().__init__()
self._max_results = max_results
self._always_visible = set(always_visible or [])
self._search_tool_name = search_tool_name
self._call_tool_name = call_tool_name
self._search_result_serializer: SearchResultSerializer = (
search_result_serializer or serialize_tools_for_output_json
)
# ------------------------------------------------------------------
# Transform interface
# ------------------------------------------------------------------
async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
"""Replace the catalog with pinned + synthetic search/call tools."""
pinned = [t for t in tools if t.name in self._always_visible]
return [*pinned, self._make_search_tool(), self._make_call_tool()]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Intercept synthetic tool names; delegate everything else."""
if name == self._search_tool_name:
return self._make_search_tool()
if name == self._call_tool_name:
return self._make_call_tool()
return await call_next(name, version=version)
# ------------------------------------------------------------------
# Synthetic tools
# ------------------------------------------------------------------
@abstractmethod
def _make_search_tool(self) -> Tool:
"""Create the search tool. Subclasses define the parameter schema."""
...
def _make_call_tool(self) -> Tool:
"""Create the call_tool proxy that executes discovered tools."""
transform = self
search_name = self._search_tool_name
call_name = self._call_tool_name
async def call_tool(
name: Annotated[str, "The name of the tool to call"],
arguments: Annotated[
dict[str, Any] | None, "Arguments to pass to the tool"
] = None,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> ToolResult:
if name in {transform._call_tool_name, transform._search_tool_name}:
raise ValueError(
f"{name!r} is a synthetic search tool and cannot be "
f"called via the {call_name!r} proxy"
)
return await ctx.fastmcp.call_tool(name, arguments)
return Tool.from_function(
fn=call_tool,
name=call_name,
description=(
f"Call a tool by name with the given arguments. "
f"Use this to execute tools discovered via {search_name!r}."
),
)
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
async def _render_results(self, tools: Sequence[Tool]) -> Any:
return await _invoke_serializer(self._search_result_serializer, tools)
# ------------------------------------------------------------------
# Catalog access
# ------------------------------------------------------------------
async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]:
"""Get the auth-filtered tool catalog, excluding pinned tools."""
tools = await self.get_tool_catalog(ctx)
return [t for t in tools if t.name not in self._always_visible]
# ------------------------------------------------------------------
# Abstract search
# ------------------------------------------------------------------
@abstractmethod
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
"""Search the given tools and return matches."""
...

View file

@ -0,0 +1,152 @@
"""BM25-based search transform."""
import hashlib
import math
import re
from collections.abc import Sequence
from typing import Annotated, Any
from fastmcp.server.context import Context
from fastmcp.server.plugins.tool_search.base import (
BaseSearchTransform,
SearchResultSerializer,
_extract_searchable_text,
)
from fastmcp.tools.base import Tool
def _tokenize(text: str) -> list[str]:
"""Lowercase, split on non-alphanumeric, filter short tokens."""
return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1]
class _BM25Index:
"""Self-contained BM25 Okapi index."""
def __init__(self, k1: float = 1.5, b: float = 0.75) -> None:
self.k1 = k1
self.b = b
self._doc_tokens: list[list[str]] = []
self._doc_lengths: list[int] = []
self._avg_dl: float = 0.0
self._df: dict[str, int] = {}
self._tf: list[dict[str, int]] = []
self._n: int = 0
def build(self, documents: list[str]) -> None:
self._doc_tokens = [_tokenize(doc) for doc in documents]
self._doc_lengths = [len(tokens) for tokens in self._doc_tokens]
self._n = len(documents)
self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0
self._df = {}
self._tf = []
for tokens in self._doc_tokens:
tf: dict[str, int] = {}
seen: set[str] = set()
for token in tokens:
tf[token] = tf.get(token, 0) + 1
if token not in seen:
self._df[token] = self._df.get(token, 0) + 1
seen.add(token)
self._tf.append(tf)
def query(self, text: str, top_k: int) -> list[int]:
"""Return indices of top_k documents sorted by BM25 score."""
query_tokens = _tokenize(text)
if not query_tokens or not self._n:
return []
scores: list[float] = [0.0] * self._n
for token in query_tokens:
if token not in self._df:
continue
idf = math.log(
(self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0
)
for i in range(self._n):
tf = self._tf[i].get(token, 0)
if tf == 0:
continue
dl = self._doc_lengths[i]
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl)
scores[i] += idf * numerator / denominator
ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True)
return [i for i in ranked[:top_k] if scores[i] > 0]
def _catalog_hash(tools: Sequence[Tool]) -> str:
"""SHA256 hash of sorted tool searchable text for staleness detection.
Each tool's searchable text is hashed individually before being joined,
so the output is collision-resistant even when tool descriptions
contain the separator character.
"""
per_tool = sorted(
hashlib.sha256(_extract_searchable_text(t).encode()).hexdigest() for t in tools
)
return hashlib.sha256("|".join(per_tool).encode()).hexdigest()
class BM25SearchTransform(BaseSearchTransform):
"""Search transform using BM25 Okapi relevance ranking.
Maintains an in-memory index that is lazily rebuilt when the tool
catalog changes detected via a hash of each tool's searchable text
(name, description, and parameter names/descriptions combined).
"""
def __init__(
self,
*,
max_results: int = 5,
always_visible: list[str] | None = None,
search_tool_name: str = "search_tools",
call_tool_name: str = "call_tool",
search_result_serializer: SearchResultSerializer | None = None,
) -> None:
super().__init__(
max_results=max_results,
always_visible=always_visible,
search_tool_name=search_tool_name,
call_tool_name=call_tool_name,
search_result_serializer=search_result_serializer,
)
self._index = _BM25Index()
self._indexed_tools: Sequence[Tool] = ()
self._last_hash: str = ""
def _make_search_tool(self) -> Tool:
transform = self
async def search_tools(
query: Annotated[str, "Natural language query to search for tools"],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str | list[dict[str, Any]]:
"""Search for tools using natural language.
Returns matching tool definitions ranked by relevance,
in the same format as list_tools.
"""
hidden = await transform._get_visible_tools(ctx)
results = await transform._search(hidden, query)
return await transform._render_results(results)
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
current_hash = _catalog_hash(tools)
if current_hash != self._last_hash:
documents = [_extract_searchable_text(t) for t in tools]
new_index = _BM25Index(self._index.k1, self._index.b)
new_index.build(documents)
self._index, self._indexed_tools, self._last_hash = (
new_index,
tools,
current_hash,
)
indices = self._index.query(query, self._max_results)
return [self._indexed_tools[i] for i in indices]

View file

@ -0,0 +1,89 @@
"""ToolSearch plugin: catalog-search-as-a-plugin.
Wraps a `BaseSearchTransform` implementation (BM25 or regex) and
contributes it via the plugin `transforms()` hook. The transform
classes live in `.base`, `.bm25`, `.regex` as implementation detail;
user code should configure behavior through the plugin.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict
from fastmcp.server.plugins.base import Plugin
from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform
from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform
from fastmcp.server.transforms import Transform
class ToolSearchConfig(BaseModel):
"""Config model for the `ToolSearch` plugin."""
model_config = ConfigDict(extra="forbid")
strategy: Literal["bm25", "regex"] = "bm25"
"""Which matcher to use. BM25 ranks by relevance; regex filters by
pattern match."""
max_results: int = 5
"""Maximum tools returned per search."""
always_visible: list[str] = []
"""Tool names that stay in `list_tools` alongside the synthetic
search/call pair."""
search_tool_name: str = "search_tools"
"""Name of the generated search tool."""
call_tool_name: str = "call_tool"
"""Name of the generated call-tool proxy."""
class ToolSearch(Plugin[ToolSearchConfig]):
"""Collapse the tool catalog behind a search interface.
With the plugin active, `list_tools()` returns only a pinned set
plus a generated `search_tools` / `call_tool` pair. Hidden tools
remain callable direct calls and the call-tool proxy both work.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
# Default config:
mcp = FastMCP("Server", plugins=[ToolSearch()])
# Typed config (IDE completion + static validation):
mcp = FastMCP(
"Server",
plugins=[ToolSearch(ToolSearchConfig(strategy="regex", always_visible=["help"]))],
)
# Dict config (useful for loading from JSON/YAML):
mcp = FastMCP("Server", plugins=[ToolSearch({"strategy": "regex"})])
```
"""
# `meta` is intentionally omitted: the auto-derived default
# (`name="tool-search"`, `version=None`) is appropriate for a
# bundled first-party plugin with no independent release cadence.
# Declare `meta` explicitly (or use `PluginMeta.from_package(...)`)
# if/when we publish this as its own PyPI package.
def transforms(self) -> list[Transform]:
cls = (
BM25SearchTransform
if self.config.strategy == "bm25"
else RegexSearchTransform
)
return [
cls(
max_results=self.config.max_results,
always_visible=list(self.config.always_visible),
search_tool_name=self.config.search_tool_name,
call_tool_name=self.config.call_tool_name,
)
]

View file

@ -0,0 +1,55 @@
"""Regex-based search transform."""
import re
from collections.abc import Sequence
from typing import Annotated, Any
from fastmcp.server.context import Context
from fastmcp.server.plugins.tool_search.base import (
BaseSearchTransform,
_extract_searchable_text,
)
from fastmcp.tools.base import Tool
class RegexSearchTransform(BaseSearchTransform):
"""Search transform using regex pattern matching.
Tools are matched against their name, description, and parameter
information using `re.search` with `re.IGNORECASE`.
"""
def _make_search_tool(self) -> Tool:
transform = self
async def search_tools(
pattern: Annotated[
str,
"Regex pattern to match against tool names, descriptions, and parameters",
],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str | list[dict[str, Any]]:
"""Search for tools matching a regex pattern.
Returns matching tool definitions in the same format as list_tools.
"""
hidden = await transform._get_visible_tools(ctx)
results = await transform._search(hidden, pattern)
return await transform._render_results(results)
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
try:
compiled = re.compile(query, re.IGNORECASE)
except re.error:
return []
matches: list[Tool] = []
for tool in tools:
text = _extract_searchable_text(tool)
if compiled.search(text):
matches.append(tool)
if len(matches) >= self._max_results:
break
return matches

View file

@ -1,26 +1,32 @@
"""OpenAPI provider for FastMCP.
"""Backwards-compatibility shim — OpenAPI moved to `fastmcp.server.plugins.openapi`.
This module provides OpenAPI integration for FastMCP through the Provider pattern.
The preferred entry point is now the `OpenAPI` plugin:
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
import httpx
from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig
client = httpx.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server", providers=[provider])
```
mcp = FastMCP("Server", plugins=[OpenAPI(OpenAPIConfig(spec=...))])
`OpenAPIProvider` and its helpers (`RouteMap`, `MCPType`, component
classes) remain importable from this package for direct composition.
Importing from this top-level path does **not** emit a deprecation
warning it stays silent so that unrelated code in fastmcp that
happens to touch `fastmcp.server.providers.openapi` doesn't spray
warnings. Users who import from the leaf submodules (`.provider`,
`.routing`, `.components`) directly will see a `FastMCPDeprecationWarning`
pointing at the new location.
"""
from fastmcp.server.providers.openapi.components import (
# Silent passthrough at the package level — re-export from the new
# location directly so neither this import nor the lazy provider import
# inside `fastmcp.server.providers.__init__` fires a deprecation warning.
from fastmcp.server.plugins.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
)
from fastmcp.server.providers.openapi.provider import OpenAPIProvider
from fastmcp.server.providers.openapi.routing import (
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
from fastmcp.server.plugins.openapi.routing import (
ComponentFn,
MCPType,
RouteMap,

View file

@ -1,53 +1,25 @@
"""OpenAPI component classes: Tool, Resource, and ResourceTemplate."""
"""Deprecation shim — OpenAPI component classes moved to
`fastmcp.server.plugins.openapi.components`.
"""
from __future__ import annotations
import json
import re
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
import httpx
from mcp.types import ToolAnnotations
from pydantic.networks import AnyUrl
import fastmcp
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.resources import (
Resource,
ResourceContent,
ResourceResult,
ResourceTemplate,
)
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HTTPRoute
from fastmcp.utilities.openapi.director import RequestDirector
if TYPE_CHECKING:
from fastmcp.server import Context
_SAFE_HEADERS = frozenset(
{
"accept",
"accept-encoding",
"accept-language",
"cache-control",
"connection",
"content-length",
"content-type",
"host",
"user-agent",
}
from fastmcp.server.plugins.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
_extract_mime_type_from_route,
)
def _redact_headers(headers: httpx.Headers) -> dict[str, str]:
return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()}
warnings.warn(
"fastmcp.server.providers.openapi.components has moved to "
"fastmcp.server.plugins.openapi.components. Prefer the OpenAPI "
"plugin: `from fastmcp.server.plugins.openapi import OpenAPI`. This "
"old leaf-submodule import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
__all__ = [
"OpenAPIResource",
@ -55,367 +27,3 @@ __all__ = [
"OpenAPITool",
"_extract_mime_type_from_route",
]
logger = get_logger(__name__)
# Default MIME type when no response content type can be inferred
_DEFAULT_MIME_TYPE = "application/json"
def _extract_mime_type_from_route(route: HTTPRoute) -> str:
"""Extract the primary MIME type from an HTTPRoute's response definitions.
Looks for the first successful response (2xx) and returns its content type.
Prefers JSON-compatible types when multiple are available.
Falls back to "application/json" when no response content type is declared.
"""
if not route.responses:
return _DEFAULT_MIME_TYPE
# Priority order for success status codes
success_codes = ["200", "201", "202", "204"]
response_info = None
for status_code in success_codes:
if status_code in route.responses:
response_info = route.responses[status_code]
break
# If no explicit success codes, try any 2xx response
if response_info is None:
for status_code, resp_info in route.responses.items():
if status_code.startswith("2"):
response_info = resp_info
break
if response_info is None or not response_info.content_schema:
return _DEFAULT_MIME_TYPE
# If there's only one content type, use it directly
content_types = list(response_info.content_schema.keys())
if len(content_types) == 1:
return content_types[0]
# When multiple types exist, prefer JSON-compatible types
json_compatible_types = [
"application/json",
"application/vnd.api+json",
"application/hal+json",
"application/ld+json",
"text/json",
]
for ct in json_compatible_types:
if ct in response_info.content_schema:
return ct
# Fall back to the first available content type
return content_types[0]
def _slugify(text: str) -> str:
"""Convert text to a URL-friendly slug format.
Only contains lowercase letters, uppercase letters, numbers, and underscores.
"""
if not text:
return ""
# Replace spaces and common separators with underscores
slug = re.sub(r"[\s\-\.]+", "_", text)
# Remove non-alphanumeric characters except underscores
slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
# Remove multiple consecutive underscores
slug = re.sub(r"_+", "_", slug)
# Remove leading/trailing underscores
slug = slug.strip("_")
return slug
class OpenAPITool(Tool):
"""Tool implementation for OpenAPI endpoints."""
task_config: TaskConfig = TaskConfig(mode="forbidden")
def __init__(
self,
client: httpx.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
name: str,
description: str,
parameters: dict[str, Any],
output_schema: dict[str, Any] | None = None,
tags: set[str] | None = None,
annotations: ToolAnnotations | None = None,
serializer: Callable[[Any], str] | None = None, # Deprecated
):
if serializer is not None and fastmcp.settings.deprecation_warnings:
warnings.warn(
"The `serializer` parameter is deprecated. "
"Return ToolResult from your tools for full control over serialization. "
"See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.",
FastMCPDeprecationWarning,
stacklevel=2,
)
super().__init__(
name=name,
description=description,
parameters=parameters,
output_schema=output_schema,
tags=tags or set(),
annotations=annotations,
serializer=serializer,
)
self._client = client
self._route = route
self._director = director
def __repr__(self) -> str:
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
async def run(self, arguments: dict[str, Any]) -> ToolResult:
"""Execute the HTTP request using RequestDirector."""
# Build the request — errors here are programming/schema issues,
# not HTTP failures, so we catch them separately.
try:
base_url = str(self._client.base_url) or "http://localhost"
request = self._director.build(self._route, arguments, base_url)
if self._client.headers:
for key, value in self._client.headers.items():
if key not in request.headers:
request.headers[key] = value
mcp_headers = get_http_headers()
if mcp_headers:
for key, value in mcp_headers.items():
if key not in request.headers:
request.headers[key] = value
except Exception as e:
raise ValueError(
f"Error building request for {self._route.method.upper()} "
f"{self._route.path}: {type(e).__name__}: {e}"
) from e
# Send the request and process the response.
try:
logger.debug(
f"run - sending request; headers: {_redact_headers(request.headers)}"
)
response = await self._client.send(request)
response.raise_for_status()
# Try to parse as JSON first
try:
result = response.json()
# Handle structured content based on output schema
if self.output_schema is not None:
if self.output_schema.get("x-fastmcp-wrap-result"):
structured_output = {"result": result}
else:
structured_output = result
elif not isinstance(result, dict):
structured_output = {"result": result}
else:
structured_output = result
# Structured content must be a dict for the MCP protocol.
# Wrap non-dict values that slipped through (e.g. a backend
# returning an array when the schema declared an object).
if not isinstance(structured_output, dict):
structured_output = {"result": structured_output}
return ToolResult(structured_content=structured_output)
except json.JSONDecodeError:
return ToolResult(content=response.text)
except httpx.HTTPStatusError as e:
error_message = (
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
)
try:
error_data = e.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if e.response.text:
error_message += f" - {e.response.text}"
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResource(Resource):
"""Resource implementation for OpenAPI endpoints."""
task_config: TaskConfig = TaskConfig(mode="forbidden")
def __init__(
self,
client: httpx.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri: str,
name: str,
description: str,
mime_type: str = "application/json",
tags: set[str] | None = None,
):
super().__init__(
uri=AnyUrl(uri),
name=name,
description=description,
mime_type=mime_type,
tags=tags or set(),
)
self._client = client
self._route = route
self._director = director
def __repr__(self) -> str:
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
async def read(self) -> ResourceResult:
"""Fetch the resource data by making an HTTP request."""
try:
path = self._route.path
resource_uri = str(self.uri)
# If this is a templated resource, extract path parameters from the URI
if "{" in path and "}" in path:
parts = resource_uri.split("/")
if len(parts) > 1:
path_params = {}
param_matches = re.findall(r"\{([^}]+)\}", path)
if param_matches:
param_matches.sort(reverse=True)
expected_param_count = len(parts) - 1
for i, param_name in enumerate(param_matches):
if i < expected_param_count:
param_value = parts[-1 - i]
path_params[param_name] = param_value
for param_name, param_value in path_params.items():
path = path.replace(f"{{{param_name}}}", str(param_value))
# Build headers with correct precedence
headers: dict[str, str] = {}
if self._client.headers:
headers.update(self._client.headers)
mcp_headers = get_http_headers()
if mcp_headers:
headers.update(mcp_headers)
response = await self._client.request(
method=self._route.method,
url=path,
headers=headers,
)
response.raise_for_status()
content_type = response.headers.get("content-type", "").lower()
if "application/json" in content_type:
result = response.json()
return ResourceResult(
contents=[
ResourceContent(
content=json.dumps(result), mime_type="application/json"
)
]
)
elif any(ct in content_type for ct in ["text/", "application/xml"]):
return ResourceResult(
contents=[
ResourceContent(content=response.text, mime_type=self.mime_type)
]
)
else:
return ResourceResult(
contents=[
ResourceContent(
content=response.content, mime_type=self.mime_type
)
]
)
except httpx.HTTPStatusError as e:
error_message = (
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
)
try:
error_data = e.response.json()
error_message += f" - {error_data}"
except (json.JSONDecodeError, ValueError):
if e.response.text:
error_message += f" - {e.response.text}"
raise ValueError(error_message) from e
except httpx.TimeoutException as e:
raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e
except httpx.RequestError as e:
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
class OpenAPIResourceTemplate(ResourceTemplate):
"""Resource template implementation for OpenAPI endpoints."""
task_config: TaskConfig = TaskConfig(mode="forbidden")
def __init__(
self,
client: httpx.AsyncClient,
route: HTTPRoute,
director: RequestDirector,
uri_template: str,
name: str,
description: str,
parameters: dict[str, Any],
tags: set[str] | None = None,
mime_type: str = _DEFAULT_MIME_TYPE,
):
super().__init__(
uri_template=uri_template,
name=name,
description=description,
parameters=parameters,
tags=tags or set(),
mime_type=mime_type,
)
self._client = client
self._route = route
self._director = director
def __repr__(self) -> str:
return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
async def create_resource(
self,
uri: str,
params: dict[str, Any],
context: Context | None = None,
) -> Resource:
"""Create a resource with the given parameters."""
uri_parts = [f"{key}={value}" for key, value in params.items()]
return OpenAPIResource(
client=self._client,
route=self._route,
director=self._director,
uri=uri,
name=f"{self.name}-{'-'.join(uri_parts)}",
description=self.description or f"Resource for {self._route.path}",
mime_type=self.mime_type,
tags=set(self._route.tags or []),
)

View file

@ -1,436 +1,23 @@
"""OpenAPIProvider for creating MCP components from OpenAPI specifications."""
"""Deprecation shim — `OpenAPIProvider` moved to
`fastmcp.server.plugins.openapi.provider`.
from __future__ import annotations
Prefer the `OpenAPI` plugin at `fastmcp.server.plugins.openapi` for new
code. `OpenAPIProvider` is still importable here for backcompat with
callers that composed it directly.
"""
from collections import Counter
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, Literal, cast
import warnings
import httpx
from jsonschema_path import SchemaPath
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
_extract_mime_type_from_route,
_slugify,
warnings.warn(
"fastmcp.server.providers.openapi.provider has moved to "
"fastmcp.server.plugins.openapi.provider. Prefer the OpenAPI plugin: "
"`from fastmcp.server.plugins.openapi import OpenAPI`. This old "
"leaf-submodule import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.server.providers.openapi.routing import (
DEFAULT_ROUTE_MAPPINGS,
ComponentFn,
MCPType,
RouteMap,
RouteMapFn,
_determine_route_type,
)
from fastmcp.tools.base import Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import (
HTTPRoute,
extract_output_schema_from_responses,
parse_openapi_to_http_routes,
)
from fastmcp.utilities.openapi.director import RequestDirector
from fastmcp.utilities.versions import VersionSpec, version_sort_key
__all__ = [
"OpenAPIProvider",
]
logger = get_logger(__name__)
DEFAULT_TIMEOUT: float = 30.0
class OpenAPIProvider(Provider):
"""Provider that creates MCP components from an OpenAPI specification.
Components are created eagerly during initialization by parsing the OpenAPI
spec. Each component makes HTTP calls to the described API endpoints.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
import httpx
client = httpx.AsyncClient(base_url="https://api.example.com")
provider = OpenAPIProvider(openapi_spec=spec, client=client)
mcp = FastMCP("API Server")
mcp.add_provider(provider)
```
"""
def __init__(
self,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient | None = None,
*,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
mcp_component_fn: ComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
tags: set[str] | None = None,
validate_output: bool = True,
):
"""Initialize provider by parsing OpenAPI spec and creating components.
Args:
openapi_spec: OpenAPI schema as a dictionary
client: Optional httpx AsyncClient for making HTTP requests.
If not provided, a default client is created using the first
server URL from the OpenAPI spec with a 30-second timeout.
To customize timeout or other settings, pass your own client.
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping
mcp_component_fn: Optional callable for component customization
mcp_names: Optional dictionary mapping operationId to component names
tags: Optional set of tags to add to all components
validate_output: If True (default), tools use the output schema
extracted from the OpenAPI spec for response validation. If
False, a permissive schema is used instead, allowing any
response structure while still returning structured JSON.
"""
super().__init__()
self._owns_client = client is None
if client is None:
client = self._create_default_client(openapi_spec)
self._client = client
self._mcp_component_fn = mcp_component_fn
self._validate_output = validate_output
# Keep track of names to detect collisions
self._used_names: dict[str, Counter[str]] = {
"tool": Counter(),
"resource": Counter(),
"resource_template": Counter(),
"prompt": Counter(),
}
# Pre-created component storage
self._tools: dict[str, OpenAPITool] = {}
self._resources: dict[str, OpenAPIResource] = {}
self._templates: dict[str, OpenAPIResourceTemplate] = {}
# Create openapi-core Spec and RequestDirector
try:
self._spec = SchemaPath.from_dict(cast(Any, openapi_spec))
self._director = RequestDirector(self._spec)
except Exception as e:
logger.exception("Failed to initialize RequestDirector")
raise ValueError(f"Invalid OpenAPI specification: {e}") from e
http_routes = parse_openapi_to_http_routes(openapi_spec)
# Process routes
route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
for route in http_routes:
route_map = _determine_route_type(route, route_maps)
route_type = route_map.mcp_type
if route_map_fn is not None:
try:
result = route_map_fn(route, route_type)
if result is not None:
route_type = result
logger.debug(
f"Route {route.method} {route.path} mapping customized: "
f"type={route_type.name}"
)
except Exception as e:
logger.warning(
f"Error in route_map_fn for {route.method} {route.path}: {e}. "
f"Using default values."
)
component_name = self._generate_default_name(route, mcp_names)
route_tags = set(route.tags) | route_map.mcp_tags | (tags or set())
if route_type == MCPType.TOOL:
self._create_openapi_tool(route, component_name, tags=route_tags)
elif route_type == MCPType.RESOURCE:
self._create_openapi_resource(route, component_name, tags=route_tags)
elif route_type == MCPType.RESOURCE_TEMPLATE:
self._create_openapi_template(route, component_name, tags=route_tags)
elif route_type == MCPType.EXCLUDE:
logger.debug(f"Excluding route: {route.method} {route.path}")
logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes")
@classmethod
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError(
"No server URL found in OpenAPI spec. Either add a 'servers' "
"entry to the spec or provide an httpx.AsyncClient explicitly."
)
base_url = servers[0]["url"]
variables = servers[0].get("variables", {})
for name, var in variables.items():
base_url = base_url.replace(f"{{{name}}}", var.get("default", ""))
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
"""Manage the lifecycle of the auto-created httpx client."""
if self._owns_client:
async with self._client:
yield
else:
yield
def _generate_default_name(
self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None
) -> str:
"""Generate a default name from the route."""
mcp_names_map = mcp_names_map or {}
if route.operation_id:
if route.operation_id in mcp_names_map:
name = mcp_names_map[route.operation_id]
else:
name = route.operation_id.split("__")[0]
else:
name = route.summary or f"{route.method}_{route.path}"
name = _slugify(name)
if len(name) > 56:
name = name[:56]
return name
def _get_unique_name(
self,
name: str,
component_type: Literal["tool", "resource", "resource_template", "prompt"],
) -> str:
"""Ensure the name is unique by appending numbers if needed."""
self._used_names[component_type][name] += 1
if self._used_names[component_type][name] == 1:
return name
new_name = f"{name}_{self._used_names[component_type][name]}"
logger.debug(
f"Name collision: '{name}' exists as {component_type}. Using '{new_name}'."
)
return new_name
def _create_openapi_tool(
self,
route: HTTPRoute,
name: str,
tags: set[str],
) -> None:
"""Create and register an OpenAPITool."""
combined_schema = route.flat_param_schema
output_schema = extract_output_schema_from_responses(
route.responses,
route.response_schemas,
route.openapi_version,
)
if not self._validate_output and output_schema is not None:
# Use a permissive schema that accepts any object, preserving
# the wrap-result flag so non-object responses still get wrapped
permissive: dict[str, Any] = {
"type": "object",
"additionalProperties": True,
}
if output_schema.get("x-fastmcp-wrap-result"):
permissive["x-fastmcp-wrap-result"] = True
output_schema = permissive
tool_name = self._get_unique_name(name, "tool")
base_description = (
route.description
or route.summary
or f"Executes {route.method} {route.path}"
)
tool = OpenAPITool(
client=self._client,
route=route,
director=self._director,
name=tool_name,
description=base_description,
parameters=combined_schema,
output_schema=output_schema,
tags=set(route.tags or []) | tags,
)
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, tool)
logger.debug(f"Tool {tool_name} customized by component_fn")
except Exception as e:
logger.warning(f"Error in component_fn for tool {tool_name}: {e}")
self._tools[tool.name] = tool
def _create_openapi_resource(
self,
route: HTTPRoute,
name: str,
tags: set[str],
) -> None:
"""Create and register an OpenAPIResource."""
resource_name = self._get_unique_name(name, "resource")
resource_uri = f"resource://{resource_name}"
base_description = (
route.description or route.summary or f"Represents {route.path}"
)
resource = OpenAPIResource(
client=self._client,
route=route,
director=self._director,
uri=resource_uri,
name=resource_name,
description=base_description,
mime_type=_extract_mime_type_from_route(route),
tags=set(route.tags or []) | tags,
)
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, resource)
logger.debug(f"Resource {resource_uri} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for resource {resource_uri}: {e}"
)
self._resources[str(resource.uri)] = resource
def _create_openapi_template(
self,
route: HTTPRoute,
name: str,
tags: set[str],
) -> None:
"""Create and register an OpenAPIResourceTemplate."""
template_name = self._get_unique_name(name, "resource_template")
path_params = sorted(p.name for p in route.parameters if p.location == "path")
uri_template_str = f"resource://{template_name}"
if path_params:
uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
base_description = (
route.description or route.summary or f"Template for {route.path}"
)
template_params_schema = {
"type": "object",
"properties": {
p.name: {
**(p.schema_.copy() if isinstance(p.schema_, dict) else {}),
**(
{"description": p.description}
if p.description
and not (
isinstance(p.schema_, dict) and "description" in p.schema_
)
else {}
),
}
for p in route.parameters
if p.location == "path"
},
"required": [
p.name for p in route.parameters if p.location == "path" and p.required
],
}
template = OpenAPIResourceTemplate(
client=self._client,
route=route,
director=self._director,
uri_template=uri_template_str,
name=template_name,
description=base_description,
parameters=template_params_schema,
tags=set(route.tags or []) | tags,
mime_type=_extract_mime_type_from_route(route),
)
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, template)
logger.debug(f"Template {uri_template_str} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for template {uri_template_str}: {e}"
)
self._templates[template.uri_template] = template
# -------------------------------------------------------------------------
# Provider interface
# -------------------------------------------------------------------------
async def _list_tools(self) -> Sequence[Tool]:
"""Return all tools created from the OpenAPI spec."""
return list(self._tools.values())
async def _get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name."""
tool = self._tools.get(name)
if tool is None:
return None
if version is not None and not version.matches(tool.version):
return None
return tool
async def _list_resources(self) -> Sequence[Resource]:
"""Return all resources created from the OpenAPI spec."""
return list(self._resources.values())
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI."""
resource = self._resources.get(uri)
if resource is None:
return None
if version is not None and not version.matches(resource.version):
return None
return resource
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates created from the OpenAPI spec."""
return list(self._templates.values())
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
matching = [t for t in self._templates.values() if t.matches(uri) is not None]
if not matching:
return None
if version is not None:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
async def _list_prompts(self) -> Sequence[Prompt]:
"""Return empty list - OpenAPI doesn't create prompts."""
return []
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return empty list - OpenAPI components don't support tasks."""
return []
__all__ = ["OpenAPIProvider"]

View file

@ -1,23 +1,25 @@
"""Route mapping logic for OpenAPI operations."""
"""Deprecation shim — OpenAPI route-mapping types moved to
`fastmcp.server.plugins.openapi.routing`.
"""
from __future__ import annotations
import warnings
import enum
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from re import Pattern
from typing import TYPE_CHECKING, Literal
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.openapi.routing import (
ComponentFn,
MCPType,
RouteMap,
RouteMapFn,
)
if TYPE_CHECKING:
from fastmcp.server.providers.openapi.components import (
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import HttpMethod, HTTPRoute
warnings.warn(
"fastmcp.server.providers.openapi.routing has moved to "
"fastmcp.server.plugins.openapi.routing. Prefer the OpenAPI plugin: "
"`from fastmcp.server.plugins.openapi import OpenAPI`. This old "
"leaf-submodule import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
__all__ = [
"ComponentFn",
@ -25,85 +27,3 @@ __all__ = [
"RouteMap",
"RouteMapFn",
]
logger = get_logger(__name__)
# Type definitions for the mapping functions
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
ComponentFn = Callable[
[
HTTPRoute,
"OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
],
None,
]
class MCPType(enum.Enum):
"""Type of FastMCP component to create from a route.
Enum values:
TOOL: Convert the route to a callable Tool
RESOURCE: Convert the route to a Resource (typically GET endpoints)
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
EXCLUDE: Exclude the route from being converted to any MCP component
"""
TOOL = "TOOL"
RESOURCE = "RESOURCE"
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
EXCLUDE = "EXCLUDE"
@dataclass(kw_only=True)
class RouteMap:
"""Mapping configuration for HTTP routes to FastMCP component types."""
methods: list[HttpMethod] | Literal["*"] = field(default="*")
pattern: Pattern[str] | str = field(default=r".*")
tags: set[str] = field(
default_factory=set,
metadata={"description": "A set of tags to match. All tags must match."},
)
mcp_type: MCPType = field(
metadata={"description": "The type of FastMCP component to create."},
)
mcp_tags: set[str] = field(
default_factory=set,
metadata={
"description": "A set of tags to apply to the generated FastMCP component."
},
)
# Default route mapping: all routes become tools.
DEFAULT_ROUTE_MAPPINGS = [
RouteMap(mcp_type=MCPType.TOOL),
]
def _determine_route_type(
route: HTTPRoute,
mappings: list[RouteMap],
) -> RouteMap:
"""Determine the FastMCP component type based on the route and mappings."""
for route_map in mappings:
if route_map.methods == "*" or route.method in route_map.methods:
if isinstance(route_map.pattern, Pattern):
pattern_matches = route_map.pattern.search(route.path)
else:
pattern_matches = re.search(route_map.pattern, route.path)
if pattern_matches:
if route_map.tags:
route_tags_set = set(route.tags or [])
if not route_map.tags.issubset(route_tags_set):
continue
logger.debug(
f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}"
)
return route_map
return RouteMap(mcp_type=MCPType.TOOL)

View file

@ -1,35 +1,23 @@
"""Skills providers for exposing agent skills as MCP resources.
"""Backwards-compatibility shim — skills providers moved to `fastmcp.server.plugins.skills`.
This module provides a two-layer architecture for skill discovery:
The preferred entry point is now the `Skills` plugin:
- **SkillProvider**: Handles a single skill folder, exposing its files as resources.
- **SkillsDirectoryProvider**: Scans a directory, creates a SkillProvider per folder.
- **Vendor providers**: Platform-specific providers for Claude, Cursor, VS Code, Codex,
Gemini, Goose, Copilot, and OpenCode.
Example:
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import ClaudeSkillsProvider, SkillProvider
from fastmcp.server.plugins.skills import Skills, SkillsConfig
mcp = FastMCP("Skills Server")
mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))])
# Load a single skill
mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing"))
# Or load all skills in a directory
mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/
```
The underlying `SkillProvider`, `SkillsDirectoryProvider`, and the
vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, etc.)
remain importable from this package for direct composition. The
top-level import path is silent; importing from the leaf submodules
emits a `FastMCPDeprecationWarning`.
"""
from __future__ import annotations
# Import providers
from fastmcp.server.providers.skills.claude_provider import ClaudeSkillsProvider
from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.server.providers.skills.skill_provider import SkillProvider
from fastmcp.server.providers.skills.vendor_providers import (
from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
from fastmcp.server.plugins.skills.vendor_providers import (
CodexSkillsProvider,
CopilotSkillsProvider,
CursorSkillsProvider,
@ -39,11 +27,9 @@ from fastmcp.server.providers.skills.vendor_providers import (
VSCodeSkillsProvider,
)
# Backwards compatibility alias
# Backwards-compatibility alias preserved from the original module.
SkillsProvider = SkillsDirectoryProvider
__all__ = [
"ClaudeSkillsProvider",
"CodexSkillsProvider",
@ -54,6 +40,6 @@ __all__ = [
"OpenCodeSkillsProvider",
"SkillProvider",
"SkillsDirectoryProvider",
"SkillsProvider", # Backwards compatibility alias
"SkillsProvider",
"VSCodeSkillsProvider",
]

View file

@ -1,44 +1,17 @@
"""Claude-specific skills provider for Claude Code skills."""
"""Deprecation shim — `ClaudeSkillsProvider` moved to `fastmcp.server.plugins.skills.claude_provider`."""
from __future__ import annotations
import warnings
from pathlib import Path
from typing import Literal
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider
from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider
warnings.warn(
"fastmcp.server.providers.skills.claude_provider has moved to "
"fastmcp.server.plugins.skills.claude_provider. Prefer the Skills "
'plugin: `Skills(SkillsConfig(vendor="claude"))`. This old '
"leaf-submodule import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
class ClaudeSkillsProvider(SkillsDirectoryProvider):
"""Provider for Claude Code skills from ~/.claude/skills/.
A convenience subclass that sets the default root to Claude's skills location.
Args:
reload: If True, re-scan on every request. Defaults to False.
supporting_files: How supporting files are exposed:
- "template": Accessed via ResourceTemplate, hidden from list_resources().
- "resources": Each file exposed as individual Resource in list_resources().
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.providers.skills import ClaudeSkillsProvider
mcp = FastMCP("Claude Skills")
mcp.add_provider(ClaudeSkillsProvider()) # Uses default location
```
"""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".claude" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
__all__ = ["ClaudeSkillsProvider"]

View file

@ -1,153 +1,18 @@
"""Directory scanning provider for discovering multiple skills."""
"""Deprecation shim — `SkillsDirectoryProvider` moved to `fastmcp.server.plugins.skills.directory_provider`."""
from __future__ import annotations
import warnings
from collections.abc import Sequence
from pathlib import Path
from typing import Literal
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.resources.base import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.server.providers.skills.skill_provider import SkillProvider
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.versions import VersionSpec
warnings.warn(
"fastmcp.server.providers.skills.directory_provider has moved to "
"fastmcp.server.plugins.skills.directory_provider. Prefer the "
"Skills plugin: `from fastmcp.server.plugins.skills import Skills`. "
"This old leaf-submodule import path will be removed in a future "
"release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
logger = get_logger(__name__)
class SkillsDirectoryProvider(AggregateProvider):
"""Provider that scans directories and creates a SkillProvider per skill folder.
This extends AggregateProvider to combine multiple SkillProviders into one.
Each subdirectory containing a main file (default: SKILL.md) becomes a skill.
Can scan multiple root directories - if a skill name appears in multiple roots,
the first one found wins.
Args:
roots: Root directory(ies) containing skill folders. Can be a single path
or a sequence of paths.
reload: If True, re-discover skills on each request. Defaults to False.
main_file_name: Name of the main skill file. Defaults to "SKILL.md".
supporting_files: How supporting files are exposed in child SkillProviders:
- "template": Accessed via ResourceTemplate, hidden from list_resources().
- "resources": Each file exposed as individual Resource in list_resources().
Example:
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills")
# Single directory
mcp.add_provider(SkillsDirectoryProvider(
roots=Path.home() / ".claude" / "skills",
reload=True, # Re-scan on each request
))
# Multiple directories
mcp.add_provider(SkillsDirectoryProvider(
roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"],
))
```
"""
def __init__(
self,
roots: str | Path | Sequence[str | Path],
reload: bool = False,
main_file_name: str = "SKILL.md",
supporting_files: Literal["template", "resources"] = "template",
) -> None:
super().__init__()
# Normalize to sequence: single path becomes list
if isinstance(roots, (str, Path)):
roots = [roots]
self._roots = [Path(r).resolve() for r in roots]
self._reload = reload
self._main_file_name = main_file_name
self._supporting_files = supporting_files
self._discovered = False
# Discover skills at init
self._discover_skills()
def _discover_skills(self) -> None:
"""Scan root directories and create SkillProvider per valid skill folder."""
# Clear existing providers if reloading
self.providers.clear()
seen_skill_names: set[str] = set()
for root in self._roots:
if not root.exists():
logger.debug(f"Skills root does not exist: {root}")
continue
for skill_dir in root.iterdir():
if not skill_dir.is_dir():
continue
main_file = skill_dir / self._main_file_name
if not main_file.exists():
continue
skill_name = skill_dir.name
# Skip if we've already seen this skill name (first wins)
if skill_name in seen_skill_names:
logger.debug(
f"Skipping duplicate skill '{skill_name}' from {root} "
f"(already found in earlier root)"
)
continue
try:
provider = SkillProvider(
skill_path=skill_dir,
main_file_name=self._main_file_name,
supporting_files=self._supporting_files,
)
self.providers.append(provider)
seen_skill_names.add(skill_name)
except (FileNotFoundError, PermissionError, OSError):
logger.exception(f"Failed to load skill: {skill_dir.name}")
self._discovered = True
logger.debug(
f"SkillsDirectoryProvider loaded {len(self.providers)} skills "
f"from {len(self._roots)} root(s)"
)
async def _ensure_discovered(self) -> None:
"""Ensure skills are discovered, rediscovering if reload is enabled."""
if self._reload or not self._discovered:
self._discover_skills()
# Override list methods to support reload
async def _list_resources(self) -> Sequence[Resource]:
await self._ensure_discovered()
return await super()._list_resources()
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
await self._ensure_discovered()
return await super()._list_resource_templates()
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
await self._ensure_discovered()
return await super()._get_resource(uri, version)
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
await self._ensure_discovered()
return await super()._get_resource_template(uri, version)
def __repr__(self) -> str:
roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots
return (
f"SkillsDirectoryProvider(roots={roots_repr!r}, "
f"reload={self._reload}, skills={len(self.providers)})"
)
__all__ = ["SkillsDirectoryProvider"]

View file

@ -1,449 +1,17 @@
"""Basic skill provider for handling a single skill folder."""
"""Deprecation shim — `SkillProvider` moved to `fastmcp.server.plugins.skills.skill_provider`."""
from __future__ import annotations
import warnings
import json
import mimetypes
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Literal, cast
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
from pydantic import AnyUrl
from fastmcp.resources.base import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.skills._common import (
SkillInfo,
parse_frontmatter,
scan_skill_files,
warnings.warn(
"fastmcp.server.providers.skills.skill_provider has moved to "
"fastmcp.server.plugins.skills.skill_provider. Prefer the Skills "
"plugin: `from fastmcp.server.plugins.skills import Skills`. This "
"old leaf-submodule import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.versions import VersionSpec
logger = get_logger(__name__)
# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this)
mimetypes.add_type("text/markdown", ".md")
# -----------------------------------------------------------------------------
# Skill-specific Resource and ResourceTemplate subclasses
# -----------------------------------------------------------------------------
class SkillResource(Resource):
"""A resource representing a skill's main file or manifest."""
skill_info: SkillInfo
is_manifest: bool = False
def get_meta(self) -> dict[str, Any]:
meta = super().get_meta()
fastmcp = cast(dict[str, Any], meta["fastmcp"])
fastmcp["skill"] = {
"name": self.skill_info.name,
"is_manifest": self.is_manifest,
}
return meta
async def read(self) -> str | bytes | ResourceResult:
"""Read the resource content."""
if self.is_manifest:
return self._generate_manifest()
else:
main_file_path = self.skill_info.path / self.skill_info.main_file
return main_file_path.read_text()
def _generate_manifest(self) -> str:
"""Generate JSON manifest for the skill."""
manifest = {
"skill": self.skill_info.name,
"files": [
{"path": f.path, "size": f.size, "hash": f.hash}
for f in self.skill_info.files
],
}
return json.dumps(manifest, indent=2)
class SkillFileTemplate(ResourceTemplate):
"""A template for accessing files within a skill."""
skill_info: SkillInfo
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
"""Read a file from the skill directory."""
file_path = arguments.get("path", "")
full_path = self.skill_info.path / file_path
# Security: ensure path doesn't escape skill directory
try:
full_path = full_path.resolve()
if not full_path.is_relative_to(self.skill_info.path):
raise ValueError(f"Path {file_path} escapes skill directory")
except ValueError as e:
raise ValueError(f"Invalid path: {e}") from e
if not full_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if not full_path.is_file():
raise ValueError(f"Not a file: {file_path}")
# Determine if binary or text based on mime type
mime_type, _ = mimetypes.guess_type(str(full_path))
if mime_type and mime_type.startswith("text/"):
return full_path.read_text()
else:
return full_path.read_bytes()
async def _read( # type: ignore[override]
self,
uri: str,
params: dict[str, Any],
task_meta: Any = None,
) -> ResourceResult: # ty:ignore[invalid-method-override]
"""Server entry point - read file directly without creating ephemeral resource.
Note: task_meta is ignored - this template doesn't support background tasks.
"""
# Call read() directly and convert to ResourceResult
result = await self.read(arguments=params)
return self.convert_result(result)
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
"""Create a resource for the given URI and parameters.
Note: This is not typically used since _read() handles file reading directly.
Provided for compatibility with the ResourceTemplate interface.
"""
file_path = params.get("path", "")
full_path = (self.skill_info.path / file_path).resolve()
# Security: ensure path doesn't escape skill directory
if not full_path.is_relative_to(self.skill_info.path):
raise ValueError(f"Path {file_path} escapes skill directory")
mime_type, _ = mimetypes.guess_type(str(full_path))
# Create a SkillFileResource that can read the file
return SkillFileResource(
uri=AnyUrl(uri),
name=f"{self.skill_info.name}/{file_path}",
description=f"File from {self.skill_info.name} skill",
mime_type=mime_type or "application/octet-stream",
skill_info=self.skill_info,
file_path=file_path,
)
class SkillFileResource(Resource):
"""A resource representing a specific file within a skill."""
skill_info: SkillInfo
file_path: str
def get_meta(self) -> dict[str, Any]:
meta = super().get_meta()
fastmcp = cast(dict[str, Any], meta["fastmcp"])
fastmcp["skill"] = {
"name": self.skill_info.name,
}
return meta
async def read(self) -> str | bytes | ResourceResult:
"""Read the file content."""
full_path = self.skill_info.path / self.file_path
# Security check
full_path = full_path.resolve()
if not full_path.is_relative_to(self.skill_info.path):
raise ValueError(f"Path {self.file_path} escapes skill directory")
if not full_path.exists():
raise FileNotFoundError(f"File not found: {self.file_path}")
mime_type, _ = mimetypes.guess_type(str(full_path))
if mime_type and mime_type.startswith("text/"):
return full_path.read_text()
else:
return full_path.read_bytes()
# -----------------------------------------------------------------------------
# SkillProvider - handles a SINGLE skill folder
# -----------------------------------------------------------------------------
class SkillProvider(Provider):
"""Provider that exposes a single skill folder as MCP resources.
Each skill folder must contain a main file (default: SKILL.md) and may
contain additional supporting files.
Exposes:
- A Resource for the main file (skill://{name}/SKILL.md)
- A Resource for the synthetic manifest (skill://{name}/_manifest)
- Supporting files via ResourceTemplate or Resources (configurable)
Args:
skill_path: Path to the skill directory.
main_file_name: Name of the main skill file. Defaults to "SKILL.md".
supporting_files: How supporting files (everything except main file and
manifest) are exposed to clients:
- "template": Accessed via ResourceTemplate, hidden from list_resources().
Clients discover files by reading the manifest first.
- "resources": Each file exposed as individual Resource in list_resources().
Full enumeration upfront.
Example:
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillProvider
mcp = FastMCP("My Skill")
mcp.add_provider(SkillProvider(
Path.home() / ".claude/skills/pdf-processing"
))
```
"""
def __init__(
self,
skill_path: str | Path,
main_file_name: str = "SKILL.md",
supporting_files: Literal["template", "resources"] = "template",
) -> None:
super().__init__()
self._skill_path = Path(skill_path).resolve()
self._main_file_name = main_file_name
self._supporting_files = supporting_files
self._skill_info: SkillInfo | None = None
# Load at init to catch errors early
self._load_skill()
def _load_skill(self) -> None:
"""Load and parse the skill directory."""
main_file = self._skill_path / self._main_file_name
if not self._skill_path.exists():
raise FileNotFoundError(f"Skill directory not found: {self._skill_path}")
if not main_file.exists():
raise FileNotFoundError(
f"Main skill file not found: {main_file}. "
f"Expected {self._main_file_name} in {self._skill_path}"
)
content = main_file.read_text()
frontmatter, body = parse_frontmatter(content)
# Get description from frontmatter or first non-empty line
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line[:200]
break
elif line.startswith("#"):
description = line.lstrip("#").strip()[:200]
break
# Scan all files in the skill directory
files = scan_skill_files(self._skill_path)
self._skill_info = SkillInfo(
name=self._skill_path.name,
description=description or f"Skill: {self._skill_path.name}",
path=self._skill_path,
main_file=self._main_file_name,
files=files,
frontmatter=frontmatter,
)
logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}")
@property
def skill_info(self) -> SkillInfo:
"""Get the loaded skill info."""
if self._skill_info is None:
raise RuntimeError("Skill not loaded")
return self._skill_info
# -------------------------------------------------------------------------
# Provider interface implementation
# -------------------------------------------------------------------------
async def _list_resources(self) -> Sequence[Resource]:
"""List skill resources."""
skill = self.skill_info
resources: list[Resource] = []
# Main skill file
resources.append(
SkillResource(
uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"),
name=f"{skill.name}/{self._main_file_name}",
description=skill.description,
mime_type="text/markdown",
skill_info=skill,
is_manifest=False,
)
)
# Synthetic manifest
resources.append(
SkillResource(
uri=AnyUrl(f"skill://{skill.name}/_manifest"),
name=f"{skill.name}/_manifest",
description=f"File listing for {skill.name}",
mime_type="application/json",
skill_info=skill,
is_manifest=True,
)
)
# If supporting_files="resources", add all supporting files as resources
if self._supporting_files == "resources":
for file_info in skill.files:
# Skip main file and manifest (already added)
if file_info.path == self._main_file_name:
continue
mime_type, _ = mimetypes.guess_type(file_info.path)
resources.append(
SkillFileResource(
uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"),
name=f"{skill.name}/{file_info.path}",
description=f"File from {skill.name} skill",
mime_type=mime_type or "application/octet-stream",
skill_info=skill,
file_path=file_info.path,
)
)
return resources
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI."""
skill = self.skill_info
# Parse URI: skill://{skill_name}/{file_path}
if not uri.startswith("skill://"):
return None
path_part = uri[len("skill://") :]
parts = path_part.split("/", 1)
if len(parts) != 2:
return None
skill_name, file_path = parts
if skill_name != skill.name:
return None
if file_path == "_manifest":
return SkillResource(
uri=AnyUrl(uri),
name=f"{skill_name}/_manifest",
description=f"File listing for {skill_name}",
mime_type="application/json",
skill_info=skill,
is_manifest=True,
)
elif file_path == self._main_file_name:
return SkillResource(
uri=AnyUrl(uri),
name=f"{skill_name}/{self._main_file_name}",
description=skill.description,
mime_type="text/markdown",
skill_info=skill,
is_manifest=False,
)
elif self._supporting_files == "resources":
# Check if it's a known supporting file
for file_info in skill.files:
if file_info.path == file_path:
mime_type, _ = mimetypes.guess_type(file_path)
return SkillFileResource(
uri=AnyUrl(uri),
name=f"{skill_name}/{file_path}",
description=f"File from {skill_name} skill",
mime_type=mime_type or "application/octet-stream",
skill_info=skill,
file_path=file_path,
)
return None
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List resource templates for accessing files within the skill."""
# Only expose template if supporting_files="template"
if self._supporting_files != "template":
return []
skill = self.skill_info
return [
SkillFileTemplate(
uri_template=f"skill://{skill.name}/{{path*}}",
name=f"{skill.name}_files",
description=f"Access files within {skill.name}",
mime_type="application/octet-stream",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
skill_info=skill,
)
]
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
# Only match if supporting_files="template"
if self._supporting_files != "template":
return None
skill = self.skill_info
if not uri.startswith("skill://"):
return None
path_part = uri[len("skill://") :]
parts = path_part.split("/", 1)
if len(parts) != 2:
return None
skill_name, file_path = parts
if skill_name != skill.name:
return None
# Don't match known resources (main file, manifest)
if file_path == "_manifest" or file_path == self._main_file_name:
return None
return SkillFileTemplate(
uri_template=f"skill://{skill.name}/{{path*}}",
name=f"{skill.name}_files",
description=f"Access files within {skill.name}",
mime_type="application/octet-stream",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
skill_info=skill,
)
def __repr__(self) -> str:
return (
f"SkillProvider(skill_path={self._skill_path!r}, "
f"supporting_files={self._supporting_files!r})"
)
__all__ = ["SkillProvider"]

View file

@ -1,142 +1,38 @@
"""Vendor-specific skills providers for various AI coding platforms."""
"""Deprecation shim — vendor skills providers moved to `fastmcp.server.plugins.skills.vendor_providers`.
from __future__ import annotations
Prefer `Skills(SkillsConfig(vendor="<name>"))` over the individual
vendor subclasses one plugin entry replaces the seven hardcoded
classes.
"""
from pathlib import Path
from typing import Literal
import warnings
from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.skills.vendor_providers import (
CodexSkillsProvider,
CopilotSkillsProvider,
CursorSkillsProvider,
GeminiSkillsProvider,
GooseSkillsProvider,
OpenCodeSkillsProvider,
VSCodeSkillsProvider,
)
warnings.warn(
"fastmcp.server.providers.skills.vendor_providers has moved to "
"fastmcp.server.plugins.skills.vendor_providers. Prefer the Skills "
'plugin: `Skills(SkillsConfig(vendor="<name>"))`. This old '
"leaf-submodule import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
class CursorSkillsProvider(SkillsDirectoryProvider):
"""Cursor skills from ~/.cursor/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".cursor" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class VSCodeSkillsProvider(SkillsDirectoryProvider):
"""VS Code skills from ~/.copilot/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".copilot" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class CodexSkillsProvider(SkillsDirectoryProvider):
"""Codex skills from /etc/codex/skills/ and ~/.codex/skills/.
Scans both system-level and user-level directories. System skills take
precedence if duplicates exist.
"""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
system_root = Path("/etc/codex/skills")
user_root = Path.home() / ".codex" / "skills"
# Include both paths (system first, then user)
roots = [system_root, user_root]
super().__init__(
roots=roots,
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class GeminiSkillsProvider(SkillsDirectoryProvider):
"""Gemini skills from ~/.gemini/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".gemini" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class GooseSkillsProvider(SkillsDirectoryProvider):
"""Goose skills from ~/.config/agents/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".config" / "agents" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class CopilotSkillsProvider(SkillsDirectoryProvider):
"""GitHub Copilot skills from ~/.copilot/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".copilot" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
class OpenCodeSkillsProvider(SkillsDirectoryProvider):
"""OpenCode skills from ~/.config/opencode/skills/."""
def __init__(
self,
reload: bool = False,
supporting_files: Literal["template", "resources"] = "template",
) -> None:
root = Path.home() / ".config" / "opencode" / "skills"
super().__init__(
roots=[root],
reload=reload,
main_file_name="SKILL.md",
supporting_files=supporting_files,
)
__all__ = [
"CodexSkillsProvider",
"CopilotSkillsProvider",
"CursorSkillsProvider",
"GeminiSkillsProvider",
"GooseSkillsProvider",
"OpenCodeSkillsProvider",
"VSCodeSkillsProvider",
]

View file

@ -15,6 +15,7 @@ from collections.abc import (
)
from contextlib import (
AbstractAsyncContextManager,
AsyncExitStack,
asynccontextmanager,
)
from dataclasses import replace
@ -68,6 +69,8 @@ from fastmcp.server.lifespan import Lifespan
from fastmcp.server.low_level import LowLevelServer
from fastmcp.server.middleware import Middleware, MiddlewareContext
from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin
from fastmcp.server.plugins import Plugin
from fastmcp.server.plugins.base import PluginError
from fastmcp.server.providers import LocalProvider, Provider
from fastmcp.server.providers.aggregate import AggregateProvider
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
@ -81,6 +84,7 @@ from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.collections import deep_merge
from fastmcp.utilities.components import FastMCPComponent, _coerce_version
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT
@ -94,9 +98,9 @@ if TYPE_CHECKING:
from fastmcp.client.client import FastMCP1Server
from fastmcp.client.sampling import SamplingHandler
from fastmcp.client.transports import ClientTransport, ClientTransportT
from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn
from fastmcp.server.providers.openapi import RouteMap
from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn
from fastmcp.server.plugins.openapi import RouteMap
from fastmcp.server.plugins.openapi.routing import ComponentFn as OpenAPIComponentFn
from fastmcp.server.plugins.openapi.routing import RouteMapFn as OpenAPIRouteMapFn
from fastmcp.server.providers.proxy import FastMCPProxy
logger = get_logger(__name__)
@ -185,16 +189,16 @@ def _get_auth_context() -> tuple[bool, Any]:
def _is_model_visible(tool: Tool) -> bool:
"""Check whether a tool should be visible to the model.
Tools registered via ``@app.tool()`` (without ``model=True``) have
``meta["ui"]["visibility"] == ["app"]`` they are callable by app UIs
Tools registered via `@app.tool()` (without `model=True`) have
`meta["ui"]["visibility"] == ["app"]` they are callable by app UIs
but should not appear in the model's tool list.
Returns True (visible) when:
- The tool has no ``meta.ui.visibility`` (normal tools).
- ``"model"`` is in the visibility list (e.g. ``["model"]`` or ``["app", "model"]``).
- The tool has no `meta.ui.visibility` (normal tools).
- `"model"` is in the visibility list (e.g. `["model"]` or `["app", "model"]`).
Returns False when the visibility list exists and does not contain ``"model"``
(e.g. ``["app"]``).
Returns False when the visibility list exists and does not contain `"model"`
(e.g. `["app"]`).
"""
meta = tool.meta
if not meta:
@ -212,8 +216,8 @@ def _is_app_visible(tool: Tool) -> bool:
"""Check whether a tool has explicitly opted into app-callable visibility.
Gates the dispatcher's hashed-name routing path: only tools whose
``meta.ui.visibility`` list contains ``"app"`` can be reached via
``<hash>_<local_name>`` calls. Tools without an explicit visibility
`meta.ui.visibility` list contains `"app"` can be reached via
`<hash>_<local_name>` calls. Tools without an explicit visibility
declaration are NOT app-callable they must be reached by their
display name through the normal transform-aware resolution path.
@ -295,6 +299,7 @@ class FastMCP(
middleware: Sequence[Middleware] | None = None,
providers: Sequence[Provider] | None = None,
transforms: Sequence[Transform] | None = None,
plugins: Sequence[Plugin] | None = None,
lifespan: LifespanCallable | Lifespan | None = None,
tools: Sequence[Tool | Callable[..., Any]] | None = None,
on_duplicate: DuplicateBehavior | None = None,
@ -386,6 +391,14 @@ class FastMCP(
)
self.auth: AuthProvider | None = auth
# Identifies where `self.auth` came from, for a clear error if a
# plugin later contributes a second auth provider. `None` while
# `self.auth` is `None`; set to `"user-declared auth="` when the
# caller passes `auth=...` and to `"plugin '<name>'"` when a
# plugin contributes one.
self._auth_source: str | None = (
"user-declared auth=" if auth is not None else None
)
if tools:
for tool in tools:
@ -414,6 +427,15 @@ class FastMCP(
self.middleware.append(DereferenceRefsMiddleware())
# Plugin registry: an ordered list, populated by `add_plugin()` and
# `plugins=[...]`. Plugins install their contributions synchronously
# at `add_plugin()` time; each plugin's `run()` async context manager
# wraps the server's lifespan (see `_enter_plugin_contexts`).
self.plugins: list[Plugin] = []
self._plugin_capabilities: dict[int, dict[str, Any]] = {}
for p in plugins or []:
self.add_plugin(p)
# Set up MCP protocol handlers
self._setup_handlers()
@ -478,6 +500,137 @@ class FastMCP(
def add_middleware(self, middleware: Middleware) -> None:
self.middleware.append(middleware)
def add_plugin(self, plugin: Plugin) -> None:
"""Register a plugin with this server.
Collects all of the plugin's contributions — providers, middleware,
transforms, routes, auth, capabilities synchronously, once, at
registration time. Plugin lifecycle (`run()` / `setup()` /
`teardown()`) is strictly for async runtime work.
The plugin's `on_install(server)` hook runs first, which enforces the
"one plugin instance per server" contract (plugins are single-
server by design construct a fresh instance for each server
rather than sharing). After `on_install()` returns, contribution
hooks are called and their results are applied. If any step
raises, registration fails loudly; the caller should discard the
partially configured server rather than expect best-effort
recovery.
Dynamic plugin loading (the "loader" pattern) is supported via
`Plugin.on_install(server)`, which may call `server.add_plugin()`
recursively. That path runs entirely at registration time, so
the resulting plugin tree is installed before runtime work starts.
Args:
plugin: A :class:`Plugin` instance.
Raises:
PluginError: If the plugin is already installed on a server,
if the plugin's `fastmcp_version` compatibility check
fails, or if another source has already contributed auth.
"""
plugin.check_fastmcp_compatibility()
if plugin._installed_on is not None:
raise PluginError(
f"Plugin {plugin.meta.name!r} is already installed on "
f"{plugin._installed_on.name!r}. Plugin instances are "
"single-server by design — construct a fresh instance "
"per server rather than sharing one across servers."
)
# Attach and append FIRST so any children registered recursively
# by `on_install` land after this plugin in `self.plugins`,
# preserving parent-before-child registration order in the plugin
# list. Child contribution side effects still occur when the
# child is added from on_install.
plugin._installed_on = self
self.plugins.append(plugin)
plugin.on_install(self)
# Gather everything up front: any hook that raises must not have
# mutated server state. Auth is the only hook that can conflict
# with prior server state (the singular-auth-slot rule), so we
# validate it before committing anything else.
contributed_auth = plugin.auth()
contributed_capabilities = plugin.capabilities()
contributed_mws = list(plugin.middleware())
contributed_transforms = list(plugin.transforms())
contributed_providers = list(plugin.providers())
contributed_routes = list(plugin.routes())
if contributed_auth is not None and self.auth is not None:
prior = self._auth_source or "user-declared auth="
raise PluginError(
f"Multiple auth sources declared: {prior}, "
f"plugin {plugin.meta.name!r}. FastMCP accepts a "
"single auth provider. Disable auth on all but one "
"source (typically via the plugin's config), or "
"construct a `MultiAuth` explicitly in Python and "
"pass it as the single `auth=` arg."
)
# Commit. From here we do not raise.
self._plugin_capabilities[id(plugin)] = contributed_capabilities
for mw in contributed_mws:
self.add_middleware(mw)
for transform in contributed_transforms:
self.add_transform(transform)
for provider in contributed_providers:
self.add_provider(provider)
for route in contributed_routes:
self._additional_http_routes.append(route)
if contributed_auth is not None:
self.auth = contributed_auth
self._auth_source = f"plugin {plugin.meta.name!r}"
async def _enter_plugin_contexts(self, stack: AsyncExitStack) -> None:
"""Enter each registered plugin's `run()` context on the given stack.
Called once per server lifespan. Contributions were collected when
each plugin was registered via `add_plugin()`. All this loop does
is wrap each plugin's async runtime lifetime around the server's
lifespan.
Order: registration order on entry, reverse order on exit (the
exit stack handles the reversal automatically). Exceptions inside
a plugin's `run()` body unwind already-entered contexts cleanly.
"""
for plugin in self.plugins:
try:
await stack.enter_async_context(plugin.run(self))
except Exception:
logger.exception(
"Plugin %r raised while entering run()", plugin.meta.name
)
raise
def _apply_plugin_capabilities(
self, capabilities: mcp.types.ServerCapabilities
) -> mcp.types.ServerCapabilities:
"""Deep-merge plugin capability contributions into the server's capabilities.
Called by `LowLevelServer.get_capabilities` after the base SDK
capabilities and FastMCP post-processing have been applied.
Each plugin's `capabilities()` dict is folded into the running
capabilities in registration order, with later plugins overriding
earlier ones at matching leaf keys same semantics as dict
update, applied recursively. Plugins that return an empty dict
contribute nothing.
"""
contributions = [
self._plugin_capabilities.get(id(plugin), {}) for plugin in self.plugins
]
if not any(contributions):
return capabilities
merged = capabilities.model_dump(exclude_none=True)
for contribution in contributions:
if contribution:
deep_merge(merged, contribution)
return type(capabilities).model_validate(merged)
def add_provider(self, provider: Provider, *, namespace: str = "") -> None:
"""Add a provider for dynamic tools, resources, and prompts.
@ -497,10 +650,10 @@ class FastMCP(
def _rewrite_prefab_uris(self, tools: list[Tool]) -> list[Tool]:
"""Replace placeholder Prefab URIs with per-tool hashed ones.
For each tool whose ``meta.ui.resourceUri`` is the placeholder,
reads the tool's stored hash from ``meta.fastmcp._tool_hash``
For each tool whose `meta.ui.resourceUri` is the placeholder,
reads the tool's stored hash from `meta.fastmcp._tool_hash`
and rewrites the URI to the per-tool form. Also strips CSP from
tool meta (it belongs on the resource). Produces ``model_copy``
tool meta (it belongs on the resource). Produces `model_copy`
views originals are untouched.
"""
from fastmcp.server.providers.prefab_synthesis import (
@ -573,7 +726,7 @@ class FastMCP(
"""Add a tool transformation.
.. deprecated::
Use ``add_transform(ToolTransform({...}))`` instead.
Use `add_transform(ToolTransform({...}))` instead.
"""
if fastmcp.settings.deprecation_warnings:
warnings.warn(
@ -1569,7 +1722,7 @@ class FastMCP(
"""Remove tool(s) from the server.
.. deprecated::
Use ``mcp.local_provider.remove_tool(name)`` instead.
Use `mcp.local_provider.remove_tool(name)` instead.
Args:
name: The name of the tool to remove.
@ -2137,7 +2290,7 @@ class FastMCP(
optionally with a given prefix.
.. deprecated::
Use :meth:`mount` instead. ``import_server`` will be removed in a
Use :meth:`mount` instead. `import_server` will be removed in a
future version.
Note that when a server is *imported*, its objects are immediately
@ -2261,21 +2414,28 @@ class FastMCP(
**settings: Additional settings passed to FastMCP
Returns:
A FastMCP server with an OpenAPIProvider attached.
A FastMCP server with the OpenAPI plugin attached.
"""
from .providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig
provider: Provider = OpenAPIProvider(
openapi_spec=openapi_spec,
# `from_openapi` returns an eagerly-populated server (callers
# frequently inspect `list_tools()` before running the server).
# Build the plugin to reuse its config-validation and provider-
# construction logic, then extract the provider eagerly rather
# than deferring to plugin-lifespan contribution.
plugin = OpenAPI(
OpenAPIConfig(
spec=openapi_spec,
mcp_names=mcp_names,
tags=sorted(tags) if tags else [],
validate_output=validate_output,
),
client=client,
route_maps=route_maps,
route_map_fn=route_map_fn,
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
tags=tags,
validate_output=validate_output,
)
return cls(name=name, providers=[provider], **settings)
return cls(name=name, providers=list(plugin.providers()), **settings)
@classmethod
def from_fastapi(
@ -2306,9 +2466,9 @@ class FastMCP(
**settings: Additional settings passed to FastMCP
Returns:
A FastMCP server with an OpenAPIProvider attached.
A FastMCP server with the OpenAPI plugin attached.
"""
from .providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig
if httpx_client_kwargs is None:
httpx_client_kwargs = {}
@ -2321,16 +2481,18 @@ class FastMCP(
server_name = name or app.title
provider: Provider = OpenAPIProvider(
openapi_spec=app.openapi(),
plugin = OpenAPI(
OpenAPIConfig(
spec=app.openapi(),
mcp_names=mcp_names,
tags=sorted(tags) if tags else [],
),
client=client,
route_maps=route_maps,
route_map_fn=route_map_fn,
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
tags=tags,
)
return cls(name=server_name, providers=[provider], **settings)
return cls(name=server_name, providers=list(plugin.providers()), **settings)
@classmethod
def as_proxy(

View file

@ -222,11 +222,31 @@ class Transform:
# Re-export built-in transforms (must be after Transform class to avoid circular imports)
from fastmcp.server.transforms.visibility import Visibility, is_enabled # noqa: E402
from fastmcp.server.transforms.namespace import Namespace # noqa: E402
from fastmcp.server.transforms.prompts_as_tools import PromptsAsTools # noqa: E402
from fastmcp.server.transforms.resources_as_tools import ResourcesAsTools # noqa: E402
from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402
from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402
# PromptsAsTools / ResourcesAsTools moved to `fastmcp.server.plugins.*`.
# Resolve lazily via `__getattr__` so importing anything else from this
# package doesn't load the plugin packages (which would cause a circular
# import through `fastmcp.server.plugins.base` → `fastmcp.server.providers`
# → back here).
def __getattr__(name: str):
if name == "PromptsAsTools":
from fastmcp.server.plugins.prompts_as_tools.transform import (
PromptsAsToolsTransform,
)
return PromptsAsToolsTransform
if name == "ResourcesAsTools":
from fastmcp.server.plugins.resources_as_tools.transform import (
ResourcesAsToolsTransform,
)
return ResourcesAsToolsTransform
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"Namespace",
"PromptsAsTools",

View file

@ -1,169 +1,43 @@
"""Transform that exposes prompts as tools.
"""Deprecation shim — prompts-as-tools moved to `fastmcp.server.plugins.prompts_as_tools`.
This transform generates tools for listing and getting prompts, enabling
clients that only support tools to access prompt functionality.
The preferred API is now the `PromptsAsTools` plugin:
The generated tools route through `ctx.fastmcp` at runtime, so all server
middleware (auth, visibility, rate limiting, etc.) applies to prompt
operations exactly as it would for direct `prompts/get` calls.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import PromptsAsTools
from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools
mcp = FastMCP("Server")
mcp.add_transform(PromptsAsTools(mcp))
# Now has list_prompts and get_prompt tools
```
mcp = FastMCP("Server", plugins=[PromptsAsTools()])
For backcompat, this module keeps `PromptsAsTools` bound to the
**transform** class (so existing `mcp.add_transform(PromptsAsTools(mcp))`
code keeps working). The transform is also exported under its new
canonical name, `PromptsAsToolsTransform`.
This path issues a `FastMCPDeprecationWarning` on import a
`DeprecationWarning` subclass that fastmcp enables by default (plain
`DeprecationWarning` is suppressed by CPython's default filter, so
users wouldn't see the notice).
"""
from __future__ import annotations
import warnings
import json
from collections.abc import Sequence
from typing import TYPE_CHECKING, Annotated, Any
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.prompts_as_tools.transform import PromptsAsToolsTransform
from mcp.types import TextContent
# `PromptsAsTools` at this old path stays bound to the transform class,
# so `mcp.add_transform(PromptsAsTools(mcp))` keeps working. The new
# plugin class is at `fastmcp.server.plugins.prompts_as_tools.PromptsAsTools`.
PromptsAsTools = PromptsAsToolsTransform
from fastmcp.server.dependencies import get_context
from fastmcp.server.transforms import GetToolNext, Transform
from fastmcp.tools.base import Tool
from fastmcp.utilities.versions import VersionSpec
warnings.warn(
"fastmcp.server.transforms.prompts_as_tools has moved to "
"fastmcp.server.plugins.prompts_as_tools. Prefer the PromptsAsTools "
"plugin: `from fastmcp.server.plugins.prompts_as_tools import "
"PromptsAsTools` and pass it via `plugins=[PromptsAsTools()]`. At "
"this old path, `PromptsAsTools` remains the transform class (also "
"exported as `PromptsAsToolsTransform`) for backcompat. The old "
"import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
if TYPE_CHECKING:
from fastmcp.server.providers.base import Provider
class PromptsAsTools(Transform):
"""Transform that adds tools for listing and getting prompts.
Generates two tools:
- `list_prompts`: Lists all prompts
- `get_prompt`: Gets a specific prompt with optional arguments
The generated tools route through the server at runtime, so auth,
middleware, and visibility apply automatically.
This transform should be applied to a FastMCP server instance, not
a raw Provider, because the generated tools need the server's
middleware chain for auth and visibility filtering.
Example:
```python
mcp = FastMCP("Server")
mcp.add_transform(PromptsAsTools(mcp))
# Now has list_prompts and get_prompt tools
```
"""
def __init__(self, provider: Provider) -> None:
from fastmcp.server.server import FastMCP
if not isinstance(provider, FastMCP):
raise TypeError(
"PromptsAsTools requires a FastMCP server instance, not a"
f" {type(provider).__name__}. The generated tools route through"
" the server's middleware chain at runtime for auth and"
" visibility. Pass your FastMCP server: PromptsAsTools(mcp)"
)
self._provider = provider
def __repr__(self) -> str:
return f"PromptsAsTools({self._provider!r})"
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
"""Add prompt tools to the tool list."""
return [
*tools,
self._make_list_prompts_tool(),
self._make_get_prompt_tool(),
]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name, including generated prompt tools."""
if name == "list_prompts":
return self._make_list_prompts_tool()
if name == "get_prompt":
return self._make_get_prompt_tool()
return await call_next(name, version=version)
def _make_list_prompts_tool(self) -> Tool:
"""Create the list_prompts tool."""
async def list_prompts() -> str:
"""List all available prompts.
Returns JSON with prompt metadata including name, description,
and optional arguments.
"""
ctx = get_context()
prompts = await ctx.fastmcp.list_prompts()
result: list[dict[str, Any]] = []
for p in prompts:
result.append( # noqa: PERF401
{
"name": p.name,
"description": p.description,
"arguments": [
{
"name": arg.name,
"description": arg.description,
"required": arg.required,
}
for arg in (p.arguments or [])
],
}
)
return json.dumps(result, indent=2)
return Tool.from_function(fn=list_prompts)
def _make_get_prompt_tool(self) -> Tool:
"""Create the get_prompt tool."""
async def get_prompt(
name: Annotated[str, "The name of the prompt to get"],
arguments: Annotated[
dict[str, Any] | None,
"Optional arguments for the prompt",
] = None,
) -> str:
"""Get a prompt by name with optional arguments.
Returns the rendered prompt as JSON with a messages array.
Arguments should be provided as a dict mapping argument names
to values.
"""
ctx = get_context()
result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {})
return _format_prompt_result(result)
return Tool.from_function(fn=get_prompt)
def _format_prompt_result(result: Any) -> str:
"""Format PromptResult for tool output.
Returns JSON with the messages array. Preserves embedded resources
as structured JSON objects.
"""
messages = []
for msg in result.messages:
if isinstance(msg.content, TextContent):
content = msg.content.text
else:
content = msg.content.model_dump(mode="json", exclude_none=True)
messages.append(
{
"role": msg.role,
"content": content,
}
)
return json.dumps({"messages": messages}, indent=2)
__all__ = ["PromptsAsTools", "PromptsAsToolsTransform"]

View file

@ -1,180 +1,45 @@
"""Transform that exposes resources as tools.
"""Deprecation shim — resources-as-tools moved to `fastmcp.server.plugins.resources_as_tools`.
This transform generates tools for listing and reading resources, enabling
clients that only support tools to access resource functionality.
The preferred API is now the `ResourcesAsTools` plugin:
The generated tools route through `ctx.fastmcp` at runtime, so all server
middleware (auth, visibility, rate limiting, etc.) applies to resource
operations exactly as it would for direct `resources/read` calls.
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.transforms import ResourcesAsTools
from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools
mcp = FastMCP("Server")
mcp.add_transform(ResourcesAsTools(mcp))
# Now has list_resources and read_resource tools
```
mcp = FastMCP("Server", plugins=[ResourcesAsTools()])
For backcompat, this module keeps `ResourcesAsTools` bound to the
**transform** class (so existing `mcp.add_transform(ResourcesAsTools(mcp))`
code keeps working). The transform is also exported under its new
canonical name, `ResourcesAsToolsTransform`.
This path issues a `FastMCPDeprecationWarning` on import a
`DeprecationWarning` subclass that fastmcp enables by default (plain
`DeprecationWarning` is suppressed by CPython's default filter, so
users wouldn't see the notice).
"""
from __future__ import annotations
import warnings
import base64
import json
from collections.abc import Sequence
from typing import TYPE_CHECKING, Annotated, Any
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.resources_as_tools.transform import (
ResourcesAsToolsTransform,
)
from mcp.types import ToolAnnotations
# `ResourcesAsTools` at this old path stays bound to the transform class,
# so `mcp.add_transform(ResourcesAsTools(mcp))` keeps working. The new
# plugin class is at `fastmcp.server.plugins.resources_as_tools.ResourcesAsTools`.
ResourcesAsTools = ResourcesAsToolsTransform
from fastmcp.server.dependencies import get_context
from fastmcp.server.transforms import GetToolNext, Transform
from fastmcp.tools.base import Tool
from fastmcp.utilities.versions import VersionSpec
warnings.warn(
"fastmcp.server.transforms.resources_as_tools has moved to "
"fastmcp.server.plugins.resources_as_tools. Prefer the "
"ResourcesAsTools plugin: `from fastmcp.server.plugins.resources_as_tools "
"import ResourcesAsTools` and pass it via `plugins=[ResourcesAsTools()]`. "
"At this old path, `ResourcesAsTools` remains the transform class "
"(also exported as `ResourcesAsToolsTransform`) for backcompat. The "
"old import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
_DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True)
if TYPE_CHECKING:
from fastmcp.server.providers.base import Provider
class ResourcesAsTools(Transform):
"""Transform that adds tools for listing and reading resources.
Generates two tools:
- `list_resources`: Lists all resources and templates
- `read_resource`: Reads a resource by URI
The generated tools route through the server at runtime, so auth,
middleware, and visibility apply automatically.
This transform should be applied to a FastMCP server instance, not
a raw Provider, because the generated tools need the server's
middleware chain for auth and visibility filtering.
Example:
```python
mcp = FastMCP("Server")
mcp.add_transform(ResourcesAsTools(mcp))
# Now has list_resources and read_resource tools
```
"""
def __init__(self, provider: Provider) -> None:
from fastmcp.server.server import FastMCP
if not isinstance(provider, FastMCP):
raise TypeError(
"ResourcesAsTools requires a FastMCP server instance, not a"
f" {type(provider).__name__}. The generated tools route through"
" the server's middleware chain at runtime for auth and"
" visibility. Pass your FastMCP server: ResourcesAsTools(mcp)"
)
self._provider = provider
def __repr__(self) -> str:
return f"ResourcesAsTools({self._provider!r})"
async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
"""Add resource tools to the tool list."""
return [
*tools,
self._make_list_resources_tool(),
self._make_read_resource_tool(),
]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name, including generated resource tools."""
if name == "list_resources":
return self._make_list_resources_tool()
if name == "read_resource":
return self._make_read_resource_tool()
return await call_next(name, version=version)
def _make_list_resources_tool(self) -> Tool:
"""Create the list_resources tool."""
async def list_resources() -> str:
"""List all available resources and resource templates.
Returns JSON with resource metadata. Static resources have a
'uri' field, while templates have a 'uri_template' field with
placeholders like {name}.
"""
ctx = get_context()
resources = await ctx.fastmcp.list_resources()
templates = await ctx.fastmcp.list_resource_templates()
result: list[dict[str, Any]] = []
for r in resources:
result.append( # noqa: PERF401
{
"uri": str(r.uri),
"name": r.name,
"description": r.description,
"mime_type": r.mime_type,
}
)
for t in templates:
result.append( # noqa: PERF401
{
"uri_template": t.uri_template,
"name": t.name,
"description": t.description,
}
)
return json.dumps(result, indent=2)
return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS)
def _make_read_resource_tool(self) -> Tool:
"""Create the read_resource tool."""
async def read_resource(
uri: Annotated[str, "The URI of the resource to read"],
) -> str:
"""Read a resource by its URI.
For static resources, provide the exact URI. For templated
resources, provide the URI with template parameters filled in.
Returns the resource content as a string. Binary content is
base64-encoded.
"""
ctx = get_context()
result = await ctx.fastmcp.read_resource(uri)
return _format_result(result)
return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS)
def _format_result(result: Any) -> str:
"""Format ResourceResult for tool output.
Single text content is returned as-is. Single binary content is
base64-encoded. Multiple contents are JSON-encoded.
"""
if len(result.contents) == 1:
content = result.contents[0].content
if isinstance(content, bytes):
return base64.b64encode(content).decode()
return content
return json.dumps(
[
{
"content": (
c.content
if isinstance(c.content, str)
else base64.b64encode(c.content).decode()
),
"mime_type": c.mime_type,
}
for c in result.contents
]
)
__all__ = ["ResourcesAsTools", "ResourcesAsToolsTransform"]

View file

@ -1,29 +1,44 @@
"""Search transforms for tool discovery.
"""Deprecation shim — search transforms moved to `fastmcp.server.plugins.tool_search`.
Search transforms collapse a large tool catalog into a search interface,
letting LLMs discover tools on demand instead of seeing the full list.
The preferred API is now the `ToolSearch` plugin:
Example:
```python
from fastmcp import FastMCP
from fastmcp.server.transforms.search import RegexSearchTransform
from fastmcp.server.plugins.tool_search import ToolSearch
mcp = FastMCP("Server")
mcp.add_transform(RegexSearchTransform())
# list_tools now returns only search_tools + call_tool
```
mcp = FastMCP("Server", plugins=[ToolSearch()])
Transform classes remain importable from their new location
(`fastmcp.server.plugins.tool_search.{bm25,regex,base}`) for advanced
composition. This old path issues a `FastMCPDeprecationWarning` on
import a `DeprecationWarning` subclass that fastmcp enables by
default (plain `DeprecationWarning` is suppressed by CPython's default
filter, so users wouldn't see the notice).
"""
from fastmcp.server.transforms.search.base import (
import warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
from fastmcp.server.plugins.tool_search.base import (
BaseSearchTransform,
SearchResultSerializer,
serialize_tools_for_output_json,
serialize_tools_for_output_markdown,
)
from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
from fastmcp.server.transforms.search.regex import RegexSearchTransform
from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform
from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform
warnings.warn(
"fastmcp.server.transforms.search has moved to "
"fastmcp.server.plugins.tool_search. Prefer the ToolSearch plugin: "
"`from fastmcp.server.plugins.tool_search import ToolSearch`. The old "
"import path will be removed in a future release.",
FastMCPDeprecationWarning,
stacklevel=2,
)
__all__ = [
"BM25SearchTransform",
"BaseSearchTransform",
"RegexSearchTransform",
"SearchResultSerializer",
"serialize_tools_for_output_json",

View file

@ -1,269 +1,10 @@
"""Base class for search transforms.
"""Deprecated shim. See ``fastmcp.server.plugins.tool_search.base``."""
Search transforms replace ``list_tools()`` output with a small set of
synthetic tools a search tool and a call-tool proxy so LLMs can
discover tools on demand instead of receiving the full catalog.
All concrete search transforms (``RegexSearchTransform``,
``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and
implement ``_make_search_tool()`` and ``_search()`` to provide their
specific search strategy.
Example::
from fastmcp import FastMCP
from fastmcp.server.transforms.search import RegexSearchTransform
mcp = FastMCP("Server")
@mcp.tool
def add(a: int, b: int) -> int: ...
@mcp.tool
def multiply(x: float, y: float) -> float: ...
# Clients now see only ``search_tools`` and ``call_tool``.
# The original tools are discoverable via search.
mcp.add_transform(RegexSearchTransform())
"""
from abc import abstractmethod
from collections.abc import Awaitable, Callable, Sequence
from typing import Annotated, Any
from fastmcp.server.context import Context
from fastmcp.server.transforms import GetToolNext
from fastmcp.server.transforms.catalog import CatalogTransform
from fastmcp.tools.base import Tool, ToolResult
from fastmcp.utilities.versions import VersionSpec
def _extract_searchable_text(tool: Tool) -> str:
"""Combine tool name, description, and parameter info into searchable text."""
parts = [tool.name]
if tool.description:
parts.append(tool.description)
schema = tool.parameters
if schema:
properties = schema.get("properties", {})
for param_name, param_info in properties.items():
parts.append(param_name)
if isinstance(param_info, dict):
desc = param_info.get("description", "")
if desc:
parts.append(desc)
return " ".join(parts)
def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]:
"""Serialize tools to the same dict format as ``list_tools`` output."""
return [
tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools
]
SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]]
async def _invoke_serializer(
serializer: SearchResultSerializer, tools: Sequence[Tool]
) -> Any:
"""Call a serializer and await the result if it returns a coroutine."""
result = serializer(tools)
if isinstance(result, Awaitable):
return await result
return result
def _union_type(branches: list[Any]) -> str:
branch_types = list(dict.fromkeys(_schema_type(b) for b in branches))
if "null" not in branch_types:
return " | ".join(branch_types) if branch_types else "any"
non_null = [b for b in branch_types if b != "null"]
if not non_null:
return "null"
return f"{' | '.join(non_null)}?"
def _schema_type(schema: Any) -> str:
# Intentionally heuristic: the goal is a concise readable label, not a
# complete type system. Malformed schemas (e.g. {"type": ""}) → "any".
if not isinstance(schema, dict):
return "any"
t = schema.get("type")
if isinstance(t, str) and t:
if t == "array":
return f"{_schema_type(schema.get('items'))}[]"
if t == "null":
return "null"
return t
if "$ref" in schema:
return "object"
if "anyOf" in schema:
return _union_type(schema["anyOf"])
if "oneOf" in schema:
return _union_type(schema["oneOf"])
if "allOf" in schema:
# allOf = intersection / Pydantic composed model → always an object
return "object"
return "object" if "properties" in schema else "any"
def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]:
lines = [f"**{title}**"]
if not isinstance(schema, dict):
lines.append("- `value` (any)")
return lines
props = schema.get("properties")
raw_required = schema.get("required")
req = set(raw_required) if isinstance(raw_required, list) else set()
if props is None:
# Not a properties-based schema — treat as a single unnamed value.
lines.append(f"- `value` ({_schema_type(schema)})")
return lines
if not props:
# Object schema with no properties — zero-argument tool.
lines.append("*(no parameters)*")
return lines
for name, field in props.items():
required = ", required" if name in req else ""
lines.append(f"- `{name}` ({_schema_type(field)}{required})")
return lines
def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str:
"""Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON."""
if not tools:
return "No tools matched the query."
blocks: list[str] = []
for tool in tools:
lines = [f"### {tool.name}"]
if tool.description:
lines.extend(["", tool.description.strip()])
lines.extend(["", *_schema_section(tool.parameters, "Parameters")])
if tool.output_schema is not None:
lines.extend(["", *_schema_section(tool.output_schema, "Returns")])
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
class BaseSearchTransform(CatalogTransform):
"""Replace the tool listing with a search interface.
When this transform is active, ``list_tools()`` returns only:
* Any tools listed in ``always_visible`` (pinned).
* A **search tool** that finds tools matching a query.
* A **call_tool** proxy that executes tools discovered via search.
Hidden tools remain callable ``get_tool()`` delegates unknown
names downstream, so direct calls and the call-tool proxy both work.
Search results respect the full auth pipeline: middleware, visibility
transforms, and component-level auth checks all apply.
Args:
max_results: Maximum number of tools returned per search.
always_visible: Tool names that stay in the ``list_tools``
output alongside the synthetic search/call tools.
search_tool_name: Name of the generated search tool.
call_tool_name: Name of the generated call-tool proxy.
"""
def __init__(
self,
*,
max_results: int = 5,
always_visible: list[str] | None = None,
search_tool_name: str = "search_tools",
call_tool_name: str = "call_tool",
search_result_serializer: SearchResultSerializer | None = None,
) -> None:
super().__init__()
self._max_results = max_results
self._always_visible = set(always_visible or [])
self._search_tool_name = search_tool_name
self._call_tool_name = call_tool_name
self._search_result_serializer: SearchResultSerializer = (
search_result_serializer or serialize_tools_for_output_json
)
# ------------------------------------------------------------------
# Transform interface
# ------------------------------------------------------------------
async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
"""Replace the catalog with pinned + synthetic search/call tools."""
pinned = [t for t in tools if t.name in self._always_visible]
return [*pinned, self._make_search_tool(), self._make_call_tool()]
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Intercept synthetic tool names; delegate everything else."""
if name == self._search_tool_name:
return self._make_search_tool()
if name == self._call_tool_name:
return self._make_call_tool()
return await call_next(name, version=version)
# ------------------------------------------------------------------
# Synthetic tools
# ------------------------------------------------------------------
@abstractmethod
def _make_search_tool(self) -> Tool:
"""Create the search tool. Subclasses define the parameter schema."""
...
def _make_call_tool(self) -> Tool:
"""Create the call_tool proxy that executes discovered tools."""
transform = self
async def call_tool(
name: Annotated[str, "The name of the tool to call"],
arguments: Annotated[
dict[str, Any] | None, "Arguments to pass to the tool"
] = None,
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> ToolResult:
"""Call a tool by name with the given arguments.
Use this to execute tools discovered via search_tools.
"""
if name in {transform._call_tool_name, transform._search_tool_name}:
raise ValueError(
f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy"
)
return await ctx.fastmcp.call_tool(name, arguments)
return Tool.from_function(fn=call_tool, name=self._call_tool_name)
# ------------------------------------------------------------------
# Serialization
# ------------------------------------------------------------------
async def _render_results(self, tools: Sequence[Tool]) -> Any:
return await _invoke_serializer(self._search_result_serializer, tools)
# ------------------------------------------------------------------
# Catalog access
# ------------------------------------------------------------------
async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]:
"""Get the auth-filtered tool catalog, excluding pinned tools."""
tools = await self.get_tool_catalog(ctx)
return [t for t in tools if t.name not in self._always_visible]
# ------------------------------------------------------------------
# Abstract search
# ------------------------------------------------------------------
@abstractmethod
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
"""Search the given tools and return matches."""
...
from fastmcp.server.plugins.tool_search.base import * # noqa: F403
from fastmcp.server.plugins.tool_search.base import ( # noqa: F401
BaseSearchTransform,
SearchResultSerializer,
_extract_searchable_text,
serialize_tools_for_output_json,
serialize_tools_for_output_markdown,
)

View file

@ -1,144 +1,3 @@
"""BM25-based search transform."""
"""Deprecated shim. See ``fastmcp.server.plugins.tool_search.bm25``."""
import hashlib
import math
import re
from collections.abc import Sequence
from typing import Annotated, Any
from fastmcp.server.context import Context
from fastmcp.server.transforms.search.base import (
BaseSearchTransform,
SearchResultSerializer,
_extract_searchable_text,
)
from fastmcp.tools.base import Tool
def _tokenize(text: str) -> list[str]:
"""Lowercase, split on non-alphanumeric, filter short tokens."""
return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1]
class _BM25Index:
"""Self-contained BM25 Okapi index."""
def __init__(self, k1: float = 1.5, b: float = 0.75) -> None:
self.k1 = k1
self.b = b
self._doc_tokens: list[list[str]] = []
self._doc_lengths: list[int] = []
self._avg_dl: float = 0.0
self._df: dict[str, int] = {}
self._tf: list[dict[str, int]] = []
self._n: int = 0
def build(self, documents: list[str]) -> None:
self._doc_tokens = [_tokenize(doc) for doc in documents]
self._doc_lengths = [len(tokens) for tokens in self._doc_tokens]
self._n = len(documents)
self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0
self._df = {}
self._tf = []
for tokens in self._doc_tokens:
tf: dict[str, int] = {}
seen: set[str] = set()
for token in tokens:
tf[token] = tf.get(token, 0) + 1
if token not in seen:
self._df[token] = self._df.get(token, 0) + 1
seen.add(token)
self._tf.append(tf)
def query(self, text: str, top_k: int) -> list[int]:
"""Return indices of top_k documents sorted by BM25 score."""
query_tokens = _tokenize(text)
if not query_tokens or not self._n:
return []
scores: list[float] = [0.0] * self._n
for token in query_tokens:
if token not in self._df:
continue
idf = math.log(
(self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0
)
for i in range(self._n):
tf = self._tf[i].get(token, 0)
if tf == 0:
continue
dl = self._doc_lengths[i]
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl)
scores[i] += idf * numerator / denominator
ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True)
return [i for i in ranked[:top_k] if scores[i] > 0]
def _catalog_hash(tools: Sequence[Tool]) -> str:
"""SHA256 hash of sorted tool searchable text for staleness detection."""
key = "|".join(sorted(_extract_searchable_text(t) for t in tools))
return hashlib.sha256(key.encode()).hexdigest()
class BM25SearchTransform(BaseSearchTransform):
"""Search transform using BM25 Okapi relevance ranking.
Maintains an in-memory index that is lazily rebuilt when the tool
catalog changes (detected via a hash of tool names).
"""
def __init__(
self,
*,
max_results: int = 5,
always_visible: list[str] | None = None,
search_tool_name: str = "search_tools",
call_tool_name: str = "call_tool",
search_result_serializer: SearchResultSerializer | None = None,
) -> None:
super().__init__(
max_results=max_results,
always_visible=always_visible,
search_tool_name=search_tool_name,
call_tool_name=call_tool_name,
search_result_serializer=search_result_serializer,
)
self._index = _BM25Index()
self._indexed_tools: Sequence[Tool] = ()
self._last_hash: str = ""
def _make_search_tool(self) -> Tool:
transform = self
async def search_tools(
query: Annotated[str, "Natural language query to search for tools"],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str | list[dict[str, Any]]:
"""Search for tools using natural language.
Returns matching tool definitions ranked by relevance,
in the same format as list_tools.
"""
hidden = await transform._get_visible_tools(ctx)
results = await transform._search(hidden, query)
return await transform._render_results(results)
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
current_hash = _catalog_hash(tools)
if current_hash != self._last_hash:
documents = [_extract_searchable_text(t) for t in tools]
new_index = _BM25Index(self._index.k1, self._index.b)
new_index.build(documents)
self._index, self._indexed_tools, self._last_hash = (
new_index,
tools,
current_hash,
)
indices = self._index.query(query, self._max_results)
return [self._indexed_tools[i] for i in indices]
from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform # noqa: F401

View file

@ -1,55 +1,3 @@
"""Regex-based search transform."""
"""Deprecated shim. See ``fastmcp.server.plugins.tool_search.regex``."""
import re
from collections.abc import Sequence
from typing import Annotated, Any
from fastmcp.server.context import Context
from fastmcp.server.transforms.search.base import (
BaseSearchTransform,
_extract_searchable_text,
)
from fastmcp.tools.base import Tool
class RegexSearchTransform(BaseSearchTransform):
"""Search transform using regex pattern matching.
Tools are matched against their name, description, and parameter
information using ``re.search`` with ``re.IGNORECASE``.
"""
def _make_search_tool(self) -> Tool:
transform = self
async def search_tools(
pattern: Annotated[
str,
"Regex pattern to match against tool names, descriptions, and parameters",
],
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
) -> str | list[dict[str, Any]]:
"""Search for tools matching a regex pattern.
Returns matching tool definitions in the same format as list_tools.
"""
hidden = await transform._get_visible_tools(ctx)
results = await transform._search(hidden, pattern)
return await transform._render_results(results)
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
try:
compiled = re.compile(query, re.IGNORECASE)
except re.error:
return []
matches: list[Tool] = []
for tool in tools:
text = _extract_searchable_text(tool)
if compiled.search(text):
matches.append(tool)
if len(matches) >= self._max_results:
break
return matches
from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform # noqa: F401

View file

@ -0,0 +1,26 @@
"""Generic helpers for collection types (dicts, lists, etc.)."""
from __future__ import annotations
from copy import deepcopy
from typing import Any
def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge `update` into `base` in place and return it.
Dict values are merged recursively; other values (including `None`
and primitives) overwrite. Lists are not concatenated `update`'s
list replaces `base`'s list.
Values copied from `update` are deep-copied at assignment time so
that subsequent merges into `base` never mutate data owned by the
caller (e.g. a plugin returning a class-level dict from a hook).
"""
for key, value in update.items():
existing = base.get(key)
if isinstance(existing, dict) and isinstance(value, dict):
deep_merge(existing, value)
else:
base[key] = deepcopy(value)
return base

View file

@ -0,0 +1,95 @@
"""Tests for the `fastmcp plugin` CLI."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
def _run_fastmcp(
*args: str, cwd: Path, env_extra: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
"""Invoke the `fastmcp` CLI as a subprocess."""
env = os.environ.copy()
env["PYTHONPATH"] = str(cwd)
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, "-m", "fastmcp.cli", *args],
cwd=cwd,
capture_output=True,
text=True,
env=env,
)
class TestManifestCLI:
def test_manifest_for_top_level_class(self, tmp_path: Path):
(tmp_path / "demo.py").write_text(
textwrap.dedent(
"""
from fastmcp.server.plugins import Plugin, PluginMeta
class Demo(Plugin):
meta = PluginMeta(name="demo", version="0.1.0")
"""
)
)
result = _run_fastmcp("plugin", "manifest", "demo:Demo", cwd=tmp_path)
assert result.returncode == 0, result.stderr
manifest = json.loads(result.stdout)
assert manifest["name"] == "demo"
assert manifest["entry_point"] == "demo:Demo"
def test_manifest_for_nested_class(self, tmp_path: Path):
"""`__qualname__` produces dotted paths for nested classes; the CLI
must traverse the dots to resolve the inner class."""
(tmp_path / "demo.py").write_text(
textwrap.dedent(
"""
from fastmcp.server.plugins import Plugin, PluginMeta
class Outer:
class Inner(Plugin):
meta = PluginMeta(name="inner", version="0.1.0")
"""
)
)
result = _run_fastmcp("plugin", "manifest", "demo:Outer.Inner", cwd=tmp_path)
assert result.returncode == 0, result.stderr
manifest = json.loads(result.stdout)
assert manifest["name"] == "inner"
assert manifest["entry_point"] == "demo:Outer.Inner"
def test_manifest_emits_clean_error_for_invalid_meta(self, tmp_path: Path):
"""A plugin with invalid meta must produce a clean error, not a traceback."""
(tmp_path / "bad.py").write_text(
textwrap.dedent(
"""
from fastmcp.server.plugins import Plugin, PluginMeta
class Bad(Plugin):
meta = PluginMeta(
name="bad",
version="0.1.0",
dependencies=["not a valid pep508 spec!!"],
)
"""
)
)
result = _run_fastmcp("plugin", "manifest", "bad:Bad", cwd=tmp_path)
assert result.returncode == 1
# Error goes through logger.error, not as a Python traceback.
assert "Traceback" not in result.stderr
assert "PEP 508" in result.stderr
def test_manifest_emits_clean_error_for_missing_module(self, tmp_path: Path):
result = _run_fastmcp(
"plugin", "manifest", "nonexistent_module:Thing", cwd=tmp_path
)
assert result.returncode == 1
assert "Traceback" not in result.stderr

View file

@ -27,7 +27,7 @@ class TestDeprecatedServerOpenAPIImports:
x for x in w if issubclass(x.category, DeprecationWarning)
]
assert len(deprecation_warnings) >= 1
assert "providers.openapi" in str(deprecation_warnings[0].message)
assert "plugins.openapi" in str(deprecation_warnings[0].message)
def test_import_routing_emits_warning(self):
"""Importing from fastmcp.server.openapi.routing should emit deprecation warning."""
@ -43,7 +43,7 @@ class TestDeprecatedServerOpenAPIImports:
x for x in w if issubclass(x.category, DeprecationWarning)
]
assert len(deprecation_warnings) >= 1
assert "providers.openapi" in str(deprecation_warnings[0].message)
assert "plugins.openapi" in str(deprecation_warnings[0].message)
def test_fastmcp_openapi_class_emits_warning(self):
"""Using FastMCPOpenAPI should emit deprecation warning."""
@ -117,7 +117,7 @@ class TestDeprecatedExperimentalOpenAPIImports:
x for x in w if issubclass(x.category, DeprecationWarning)
]
assert len(deprecation_warnings) >= 1
assert "providers.openapi" in str(deprecation_warnings[0].message)
assert "plugins.openapi" in str(deprecation_warnings[0].message)
def test_experimental_imports_still_work(self):
"""All expected symbols should be importable from experimental."""
@ -152,7 +152,7 @@ class TestDeprecatedComponentsImports:
x for x in w if issubclass(x.category, DeprecationWarning)
]
assert len(deprecation_warnings) >= 1
assert "providers.openapi" in str(deprecation_warnings[0].message)
assert "plugins.openapi" in str(deprecation_warnings[0].message)
def test_components_imports_still_work(self):
"""Component classes should be importable from deprecated location."""

View file

View file

@ -9,7 +9,7 @@ from httpx import Response
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
def create_openapi_server(
@ -929,7 +929,7 @@ class TestOpenAPIPostEdgeCases:
async def test_unexpected_error_in_request_building_gives_useful_message(self):
"""Unexpected exceptions during request building should produce useful errors."""
from fastmcp.server.providers.openapi.components import OpenAPITool
from fastmcp.server.plugins.openapi.components import OpenAPITool
from fastmcp.utilities.openapi.director import RequestDirector
from fastmcp.utilities.openapi.models import HTTPRoute

View file

@ -5,7 +5,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
def create_openapi_server(

View file

@ -5,7 +5,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
def create_openapi_server(

View file

@ -8,12 +8,12 @@ from httpx import Response
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.providers.openapi.components import (
from fastmcp.server.plugins.openapi.components import (
_extract_mime_type_from_route,
_redact_headers,
)
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
from fastmcp.server.plugins.openapi.routing import MCPType, RouteMap
from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo

View file

@ -5,7 +5,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
def create_openapi_server(

View file

@ -7,7 +7,7 @@ import httpx
import pytest
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
def create_openapi_server(

View file

@ -5,8 +5,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.providers.openapi.provider import DEFAULT_TIMEOUT
from fastmcp.server.plugins.openapi.provider import DEFAULT_TIMEOUT, OpenAPIProvider
class TestOpenAPIProviderServerVariables:

View file

@ -0,0 +1,248 @@
"""Tests for the `Plugin.auth()` contribution hook (FMCP-24).
Semantic rule: FastMCP's auth slot is singular. `auth=` + every plugin's
`auth()` return are collected; at most one `AuthProvider` may be active.
Multiple sources raise `PluginError` no automatic `MultiAuth` wrapping.
Users who want multi-source auth build `MultiAuth` explicitly.
"""
from __future__ import annotations
import pytest
from fastmcp import FastMCP
from fastmcp.server.auth.auth import AuthProvider, TokenVerifier
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
from fastmcp.server.plugins.base import Plugin, PluginError, PluginMeta
def _verifier(token: str = "t") -> TokenVerifier:
return StaticTokenVerifier(tokens={token: {"client_id": "c", "scopes": []}})
class _FakeServerAuth(AuthProvider):
"""Minimal non-TokenVerifier AuthProvider — stands in for an OAuth
server in tests without needing a real issuer URL."""
def __init__(self, base_url: str = "https://example.com") -> None:
super().__init__(base_url=base_url)
async def verify_token(self, token): # type: ignore[override]
return None
class TestDefaultHook:
def test_plugin_auth_defaults_to_none(self):
class P(Plugin):
meta = PluginMeta(name="p")
assert P().auth() is None
class TestSingleSource:
def test_lone_plugin_contribution_becomes_self_auth(self):
"""One plugin contributing one AuthProvider, no user `auth=` → that
provider is installed directly as `self.auth`. No wrapping, no
lifespan round-trip: `self.auth` is set synchronously at
`add_plugin` time so HTTP/SSE transports see it when they build
the Starlette app."""
v = _verifier()
class P(Plugin):
meta = PluginMeta(name="p")
def auth(self) -> AuthProvider | None:
return v
mcp = FastMCP("t", plugins=[P()])
assert mcp.auth is v
def test_user_declared_alone_untouched(self):
"""No plugin contributing auth → `self.auth` is exactly the user
value, no processing."""
user_v = _verifier()
mcp = FastMCP("t", auth=user_v)
assert mcp.auth is user_v
def test_no_sources_leaves_auth_none(self):
mcp = FastMCP("t")
assert mcp.auth is None
def test_add_plugin_installs_auth(self):
"""Plugin added after construction installs its auth synchronously."""
v = _verifier()
mcp = FastMCP("t")
assert mcp.auth is None
class P(Plugin):
meta = PluginMeta(name="p")
def auth(self) -> AuthProvider | None:
return v
mcp.add_plugin(P())
assert mcp.auth is v
class TestMultipleSourcesRejected:
"""FastMCP's auth slot is singular. Multiple contributors raise."""
def test_two_plugin_verifiers_raises(self):
v1, v2 = _verifier("one"), _verifier("two")
class P1(Plugin):
meta = PluginMeta(name="p1")
def auth(self) -> AuthProvider | None:
return v1
class P2(Plugin):
meta = PluginMeta(name="p2")
def auth(self) -> AuthProvider | None:
return v2
with pytest.raises(PluginError, match="Multiple auth sources"):
FastMCP("t", plugins=[P1(), P2()])
def test_user_plus_plugin_raises(self):
"""User-declared `auth=` + any plugin contribution is ambiguous —
framework doesn't silently pick a winner."""
user_v, plugin_v = _verifier("u"), _verifier("p")
class P(Plugin):
meta = PluginMeta(name="p")
def auth(self) -> AuthProvider | None:
return plugin_v
with pytest.raises(PluginError, match="Multiple auth sources"):
FastMCP("t", auth=user_v, plugins=[P()])
def test_two_server_contributions_raises(self):
"""Also covers the server-server case (historical multiauth reason)."""
s1 = _FakeServerAuth("https://a.example")
s2 = _FakeServerAuth("https://b.example")
class P1(Plugin):
meta = PluginMeta(name="p1")
def auth(self) -> AuthProvider | None:
return s1
class P2(Plugin):
meta = PluginMeta(name="p2")
def auth(self) -> AuthProvider | None:
return s2
with pytest.raises(PluginError, match="Multiple auth sources"):
FastMCP("t", plugins=[P1(), P2()])
def test_error_names_conflicting_sources(self):
"""Operator needs to know which sources conflict so they can
disable auth on all but one."""
v1, v2 = _verifier("a"), _verifier("b")
class Alpha(Plugin):
meta = PluginMeta(name="alpha")
def auth(self) -> AuthProvider | None:
return v1
class Beta(Plugin):
meta = PluginMeta(name="beta")
def auth(self) -> AuthProvider | None:
return v2
with pytest.raises(PluginError) as exc_info:
FastMCP("t", plugins=[Alpha(), Beta()])
msg = str(exc_info.value)
# The "prior" source (alpha) and the rejected plugin (beta) must
# both appear so operators can act on the conflict without
# re-running with extra logging.
assert "'alpha'" in msg
assert "'beta'" in msg
class TestAddPluginFailures:
def test_rejected_auth_conflict_raises_loudly(self):
"""A plugin whose auth contribution conflicts raises immediately.
Plugin installation is not transactional: after a failed install,
callers should discard the partially configured server rather than
expect FastMCP to recover arbitrary plugin mutations.
"""
v1 = _verifier("one")
class P1(Plugin):
meta = PluginMeta(name="p1")
def auth(self) -> AuthProvider | None:
return v1
class P2(Plugin):
meta = PluginMeta(name="p2")
def __init__(self) -> None:
super().__init__()
self._v = _verifier("two")
def auth(self) -> AuthProvider | None:
return self._v
p1 = P1()
mcp = FastMCP("t", plugins=[p1])
assert mcp.plugins == [p1]
assert mcp.auth is v1
p2 = P2()
with pytest.raises(PluginError, match="Multiple auth sources"):
mcp.add_plugin(p2)
assert mcp.plugins == [p1, p2]
assert mcp.auth is v1
assert p2._installed_on is mcp
class TestSingleServerPerInstance:
def test_same_instance_registered_twice_raises(self):
"""A plugin instance belongs to one server — registering the same
instance twice (same server or different) raises PluginError."""
v = _verifier()
class P(Plugin):
meta = PluginMeta(name="p")
def auth(self) -> AuthProvider | None:
return v
p = P()
mcp = FastMCP("t", plugins=[p])
assert mcp.auth is v
with pytest.raises(PluginError, match="already installed"):
mcp.add_plugin(p)
def test_plugins_kwarg_duplicate_instance_raises(self):
"""Duplicate instance in the `plugins=` kwarg is caught the same way."""
class P(Plugin):
meta = PluginMeta(name="p")
p = P()
with pytest.raises(PluginError, match="already installed"):
FastMCP("t", plugins=[p, p])
def test_instance_on_second_server_raises(self):
"""Sharing a plugin instance across two servers is not supported."""
class P(Plugin):
meta = PluginMeta(name="p")
p = P()
FastMCP("a", plugins=[p])
with pytest.raises(PluginError, match="already installed"):
FastMCP("b", plugins=[p])

View file

@ -7,15 +7,15 @@ from mcp.types import ImageContent, TextContent
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.experimental.transforms.code_mode import (
CodeMode,
from fastmcp.server.context import Context
from fastmcp.server.plugins.code_mode import (
GetSchemas,
GetToolCatalog,
MontySandboxProvider,
Search,
_ensure_async,
)
from fastmcp.server.context import Context
from fastmcp.server.plugins.code_mode.sandbox import _ensure_async
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
from fastmcp.tools.base import Tool, ToolResult
@ -105,7 +105,7 @@ async def test_code_mode_default_tools() -> None:
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
listed_tools = await mcp.list_tools(run_middleware=False)
assert {tool.name for tool in listed_tools} == {"search", "get_schema", "execute"}
@ -125,7 +125,7 @@ async def test_code_mode_search_returns_lightweight_results() -> None:
"""Say hello to someone."""
return f"Hello, {name}!"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "square number"})
text = _unwrap_string_result(result)
@ -144,7 +144,7 @@ async def test_code_mode_get_schema_brief() -> None:
"""Compute the square of a number."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp, "get_schema", {"tools": ["square"], "detail": "brief"}
@ -165,7 +165,7 @@ async def test_code_mode_get_schema_detailed() -> None:
"""Compute the square of a number."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp, "get_schema", {"tools": ["square"], "detail": "detailed"}
@ -186,7 +186,7 @@ async def test_code_mode_get_schema_full() -> None:
"""Compute the square of a number."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "get_schema", {"tools": ["square"], "detail": "full"})
text = _unwrap_string_result(result)
@ -205,7 +205,7 @@ async def test_code_mode_get_schema_default_is_detailed() -> None:
"""Compute the square of a number."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "get_schema", {"tools": ["square"]})
text = _unwrap_string_result(result)
@ -221,7 +221,7 @@ async def test_code_mode_get_schema_not_found() -> None:
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "get_schema", {"tools": ["nonexistent"]})
text = _unwrap_string_result(result)
@ -238,7 +238,7 @@ async def test_code_mode_get_schema_partial_match() -> None:
"""Compute the square."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "get_schema", {"tools": ["square", "nonexistent"]})
text = _unwrap_string_result(result)
@ -254,7 +254,7 @@ async def test_code_mode_execute_works() -> None:
def add(x: int, y: int) -> int:
return x + y
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp, "execute", {"code": "return await call_tool('add', {'x': 2, 'y': 3})"}
@ -275,7 +275,7 @@ async def test_code_mode_custom_execute_name() -> None:
return "pong"
mcp.add_transform(
CodeMode(
CodeModeTransform(
sandbox_provider=_UnsafeTestSandboxProvider(),
execute_tool_name="run_code",
)
@ -295,7 +295,7 @@ async def test_code_mode_custom_execute_description() -> None:
return "pong"
mcp.add_transform(
CodeMode(
CodeModeTransform(
sandbox_provider=_UnsafeTestSandboxProvider(),
execute_description="Custom execute description",
)
@ -313,7 +313,7 @@ async def test_code_mode_default_execute_description() -> None:
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
listed = await mcp.list_tools(run_middleware=False)
by_name = {t.name: t for t in listed}
@ -341,7 +341,7 @@ async def test_code_mode_no_discovery_tools() -> None:
return "pong"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -371,7 +371,7 @@ async def test_code_mode_custom_discovery_tool_function() -> None:
return Tool.from_function(fn=list_tools, name="list_all")
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[list_all],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -394,7 +394,7 @@ async def test_code_mode_search_detailed() -> None:
"""Compute the square."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "square", "detail": "detailed"})
text = _unwrap_string_result(result)
@ -414,7 +414,7 @@ async def test_code_mode_search_tool_full_detail() -> None:
return x * x
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[Search(default_detail="full")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -437,7 +437,7 @@ async def test_code_mode_custom_search_tool_name() -> None:
return "pong"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[
Search(name="find"),
GetSchemas(name="describe"),
@ -452,7 +452,7 @@ async def test_code_mode_custom_search_tool_name() -> None:
def test_code_mode_rejects_discovery_execute_name_collision() -> None:
"""CodeMode raises ValueError when a discovery tool collides with execute."""
cm = CodeMode(
cm = CodeModeTransform(
discovery_tools=[Search(name="execute")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -462,7 +462,7 @@ def test_code_mode_rejects_discovery_execute_name_collision() -> None:
def test_code_mode_rejects_duplicate_discovery_names() -> None:
"""CodeMode raises ValueError when discovery tools have duplicate names."""
cm = CodeMode(
cm = CodeModeTransform(
discovery_tools=[Search(name="search"), Search(name="search")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -483,7 +483,7 @@ async def test_code_mode_execute_respects_disabled_tool_visibility() -> None:
return "nope"
mcp.disable(names={"secret"}, components={"tool"})
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
with pytest.raises(ToolError, match=r"Unknown tool"):
await _run_tool(
@ -500,7 +500,7 @@ async def test_code_mode_search_respects_disabled_tool_visibility() -> None:
return "nope"
mcp.disable(names={"secret"}, components={"tool"})
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "secret"})
text = _unwrap_string_result(result)
@ -521,7 +521,7 @@ async def test_code_mode_execute_sees_mid_run_visibility_changes() -> None:
return "secret-ok"
mcp.disable(names={"secret"}, components={"tool"})
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
async with Client(mcp) as client:
result = await client.call_tool(
@ -540,7 +540,7 @@ async def test_code_mode_execute_respects_tool_auth() -> None:
def protected() -> str:
return "nope"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
with pytest.raises(ToolError, match=r"Unknown tool"):
await _run_tool(
@ -556,7 +556,7 @@ async def test_code_mode_search_respects_tool_auth() -> None:
"""A protected tool."""
return "nope"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "protected"})
text = _unwrap_string_result(result)
@ -575,7 +575,7 @@ async def test_code_mode_shadows_colliding_tool_names() -> None:
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
tools = await mcp.list_tools(run_middleware=False)
tool_names = {t.name for t in tools}
@ -600,7 +600,7 @@ async def test_code_mode_get_tool_returns_meta_tools_and_passes_through() -> Non
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
search_tool = await mcp.get_tool("search")
assert search_tool is not None
@ -631,7 +631,7 @@ async def test_code_mode_execute_non_text_content_stringified() -> None:
def image_tool() -> ImageContent:
return ImageContent(type="image", data="base64data", mimeType="image/png")
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp, "execute", {"code": "return await call_tool('image_tool', {})"}
@ -653,7 +653,7 @@ async def test_code_mode_execute_multi_tool_chaining() -> None:
def add_one(x: int) -> int:
return x + 1
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp,
@ -676,7 +676,7 @@ async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None:
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
with pytest.raises(ToolError):
await _run_tool(mcp, "execute", {"code": "raise ValueError('boom')"})

View file

@ -4,13 +4,13 @@ from typing import Any
from mcp.types import TextContent
from fastmcp import FastMCP
from fastmcp.experimental.transforms.code_mode import (
CodeMode,
from fastmcp.server.plugins.code_mode import (
GetTags,
ListTools,
Search,
_ensure_async,
)
from fastmcp.server.plugins.code_mode.sandbox import _ensure_async
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
from fastmcp.tools.base import ToolResult
@ -104,7 +104,7 @@ async def test_categories_brief_shows_tag_counts() -> None:
return f"Hello, {name}!"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[GetTags()],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -130,7 +130,7 @@ async def test_categories_full_lists_tools_per_tag() -> None:
return f"Hello, {name}!"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[GetTags(default_detail="full")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -156,7 +156,7 @@ async def test_categories_includes_untagged() -> None:
return "pong"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[GetTags()],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -176,7 +176,7 @@ async def test_categories_tool_in_multiple_tags() -> None:
return x + y
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[GetTags(default_detail="full")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -200,7 +200,7 @@ async def test_categories_detail_override_per_call() -> None:
return x + y
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[GetTags()], # default_detail="brief"
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -219,7 +219,7 @@ async def test_get_tags_empty_catalog() -> None:
mcp.disable(names={"ping"}, components={"tool"})
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[GetTags()],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -248,7 +248,7 @@ async def test_search_with_tags_filter() -> None:
"""Say hello."""
return f"Hello, {name}!"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "add hello", "tags": ["math"]})
text = _unwrap_string_result(result)
@ -264,7 +264,7 @@ async def test_search_with_tags_filter_no_matches() -> None:
"""Add two numbers."""
return x + y
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "add", "tags": ["nonexistent"]})
text = _unwrap_string_result(result)
@ -285,7 +285,7 @@ async def test_search_without_tags_returns_all() -> None:
"""Say hello."""
return f"Hello, {name}!"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "add hello"})
text = _unwrap_string_result(result)
@ -307,7 +307,7 @@ async def test_search_with_untagged_filter() -> None:
"""Ping."""
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "ping add", "tags": ["untagged"]})
text = _unwrap_string_result(result)
@ -325,7 +325,7 @@ async def test_search_default_detail_detailed_skips_get_schema() -> None:
return x * x
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[Search(default_detail="detailed")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -346,7 +346,7 @@ async def test_search_full_detail_empty_results_returns_json() -> None:
def add(x: int, y: int) -> int:
return x + y
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp,
@ -366,7 +366,7 @@ async def test_get_schema_empty_tools_list() -> None:
def ping() -> str:
return "pong"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "get_schema", {"tools": []})
text = _unwrap_string_result(result)
@ -382,7 +382,7 @@ async def test_get_schema_full_partial_match_returns_valid_json() -> None:
"""Compute the square."""
return x * x
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(
mcp, "get_schema", {"tools": ["square", "nonexistent"], "detail": "full"}
@ -418,7 +418,7 @@ async def test_search_shows_catalog_size_when_results_are_subset() -> None:
"""Say hello."""
return f"Hello, {name}!"
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "add numbers"})
text = _unwrap_string_result(result)
@ -435,7 +435,7 @@ async def test_search_omits_annotation_when_all_tools_returned() -> None:
"""Add two numbers."""
return x + y
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "add numbers"})
text = _unwrap_string_result(result)
@ -466,7 +466,7 @@ async def test_search_limit_caps_results() -> None:
"""Multiply numbers."""
return x * y
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
result = await _run_tool(mcp, "search", {"query": "numbers", "limit": 1})
text = _unwrap_string_result(result)
@ -496,7 +496,7 @@ async def test_search_default_limit_from_constructor() -> None:
return "c"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[Search(default_limit=2)],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -527,7 +527,7 @@ async def test_list_tools_brief() -> None:
return x * y
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[ListTools()],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -552,7 +552,7 @@ async def test_list_tools_detailed() -> None:
return x * x
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[ListTools(default_detail="detailed")],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -575,7 +575,7 @@ async def test_list_tools_full_returns_json() -> None:
return "pong"
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[ListTools()],
sandbox_provider=_UnsafeTestSandboxProvider(),
)
@ -593,7 +593,7 @@ async def test_list_tools_empty_catalog() -> None:
mcp = FastMCP("ListTools Empty")
mcp.add_transform(
CodeMode(
CodeModeTransform(
discovery_tools=[ListTools()],
sandbox_provider=_UnsafeTestSandboxProvider(),
)

View file

@ -0,0 +1,105 @@
"""Tests for the CodeMode plugin wrapper.
Transform behavior (what `CodeModeTransform` does to the catalog, how
discovery tools render, sandbox execution, etc.) is covered by
`test_code_mode.py` and `test_code_mode_discovery.py`. This file only
covers the plugin layer itself config validation, meta derivation,
dict-config coercion, and the deprecation shim at the old import path.
"""
from __future__ import annotations
import warnings
from typing import Any
import pytest
from pydantic import ValidationError
from fastmcp import FastMCP
from fastmcp.server.plugins.code_mode import CodeMode, CodeModeConfig
class _NoopSandbox:
async def run(
self,
code: str,
*,
inputs: dict[str, Any] | None = None,
external_functions: dict[str, Any] | None = None,
) -> Any:
return None
class TestCodeModeConfig:
def test_config_generic_binding(self):
"""`Plugin[CodeModeConfig]` binds CodeModeConfig as the validated config type."""
assert CodeMode._config_cls is CodeModeConfig
def test_dict_config_accepted(self):
"""Dict config works for loading from JSON/YAML."""
plugin = CodeMode({"execute_tool_name": "go"})
assert plugin.config.execute_tool_name == "go"
def test_unknown_sandbox_rejected(self):
with pytest.raises((ValidationError, Exception), match="sandbox"):
CodeModeConfig(sandbox="docker") # ty: ignore[invalid-argument-type]
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
CodeModeConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_default_meta(self):
"""CodeMode uses Plugin's auto-derived meta: kebab-cased class
name, no independent version (bundled first-party plugin)."""
assert CodeMode.meta.name == "code-mode"
assert CodeMode.meta.version is None
class TestDeprecationShim:
"""The old `fastmcp.experimental.transforms.code_mode` path still works but warns."""
def test_old_package_import_emits_deprecation_warning(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.experimental.transforms.code_mode", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.experimental.transforms.code_mode")
fastmcp_deprecations = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any(
"plugins.code_mode" in str(w.message) for w in fastmcp_deprecations
), (
f"expected FastMCPDeprecationWarning pointing at plugins.code_mode, "
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
)
async def test_legacy_add_transform_pattern_still_works(self):
"""End-to-end: old `add_transform(CodeMode(...))` code keeps
working. The point of the shim is that this doesn't break — the
identity-check test alone wouldn't catch a regression where
`CodeMode` at the old path drifted to the plugin class."""
from fastmcp.exceptions import FastMCPDeprecationWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastMCPDeprecationWarning)
from fastmcp.experimental.transforms.code_mode import (
CodeMode as OldCodeMode,
)
mcp = FastMCP("legacy")
@mcp.tool
def ping() -> str:
return "pong"
mcp.add_transform(OldCodeMode(sandbox_provider=_NoopSandbox()))
tools = await mcp.list_tools(run_middleware=False)
assert {t.name for t in tools} == {"search", "get_schema", "execute"}

View file

@ -3,7 +3,7 @@ from typing import Any
import pytest
from fastmcp import FastMCP
from fastmcp.server.transforms.search.base import (
from fastmcp.server.plugins.tool_search.base import (
_schema_section,
_schema_type,
serialize_tools_for_output_markdown,

View file

@ -0,0 +1,286 @@
"""Tests for the OpenAPI plugin wrapper.
Transform/provider behavior is covered by the existing OpenAPIProvider
tests in `tests/server/providers/openapi/`. This file only covers
plugin-layer concerns config validation, dictRouteMap conversion,
spec_path loading, and the escape-hatch wiring.
"""
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.server.plugins.openapi import MCPType, OpenAPI, OpenAPIConfig, RouteMap
from fastmcp.server.plugins.openapi.plugin import RouteMapDict
from fastmcp.server.plugins.openapi.provider import OpenAPIProvider
PETSTORE_SPEC: dict = {
"openapi": "3.0.0",
"info": {"title": "Petstore", "version": "1.0"},
"servers": [{"url": "https://petstore.example.com"}],
"paths": {
"/pets": {
"get": {
"operationId": "list_pets",
"responses": {"200": {"description": "ok"}},
},
"post": {
"operationId": "create_pet",
"responses": {"201": {"description": "created"}},
},
},
"/pets/{id}": {
"get": {
"operationId": "get_pet",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"200": {"description": "ok"}},
}
},
},
}
class TestOpenAPIConfig:
def test_config_generic_binding(self):
assert OpenAPI._config_cls is OpenAPIConfig
def test_default_config_instantiable(self):
"""Defaults must pass the plugin framework's instantiate-with-no-args
contract. The spec/spec_path check fires at providers() time, not
at Config construction."""
assert OpenAPIConfig() # must not raise
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
OpenAPIConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_meta_name_is_single_word(self):
"""'openapi' is one technical term — explicit meta override
prevents the kebab auto-deriver from producing 'open-api'."""
assert OpenAPI.meta.name == "openapi"
assert OpenAPI.meta.version is None
class TestSpecLoading:
async def test_inline_spec_builds_provider(self):
plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC))
mcp = FastMCP("petstore", plugins=[plugin])
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
assert {"list_pets", "create_pet", "get_pet"}.issubset(names)
async def test_spec_path_loads_from_disk(self, tmp_path: Path):
spec_file = tmp_path / "petstore.json"
spec_file.write_text(json.dumps(PETSTORE_SPEC))
plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file)))
mcp = FastMCP("petstore", plugins=[plugin])
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
assert {"list_pets", "create_pet", "get_pet"}.issubset(names)
async def test_spec_path_loads_utf8_regardless_of_locale(self, tmp_path: Path):
"""Spec files must load as UTF-8, not via the process locale.
Otherwise a spec with non-ASCII descriptions (German umlauts,
Japanese, fancy quotes, etc.) fails on non-UTF-8 systems like
Windows cp1252 see PR #4015 review thread."""
spec_with_unicode = {
**PETSTORE_SPEC,
"info": {"title": "Pëtstöre — 宠物商店", "version": "1.0"},
}
spec_file = tmp_path / "petstore-unicode.json"
spec_file.write_text(
json.dumps(spec_with_unicode, ensure_ascii=False),
encoding="utf-8",
)
plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file)))
providers = plugin.providers()
assert isinstance(providers[0], OpenAPIProvider)
def test_missing_spec_fails_at_build_time(self):
plugin = OpenAPI(OpenAPIConfig())
with pytest.raises(ValueError, match="spec.*spec_path"):
plugin.providers()
def test_both_spec_and_spec_path_rejected(self, tmp_path: Path):
spec_file = tmp_path / "spec.json"
spec_file.write_text(json.dumps(PETSTORE_SPEC))
plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC, spec_path=str(spec_file)))
with pytest.raises(ValueError, match="exactly one"):
plugin.providers()
class TestRouteMapping:
def test_route_maps_dict_form_converts_to_typed(self):
plugin = OpenAPI(
OpenAPIConfig(
spec=PETSTORE_SPEC,
route_maps=[
RouteMapDict(
mcp_type="RESOURCE", methods=["GET"], pattern=r"^/pets$"
),
],
)
)
providers = plugin.providers()
assert isinstance(providers[0], OpenAPIProvider)
# The GET /pets route should have become a resource, not a tool.
async def test_list_pets_maps_to_resource_via_config(self):
plugin = OpenAPI(
OpenAPIConfig(
spec=PETSTORE_SPEC,
route_maps=[
RouteMapDict(
mcp_type="RESOURCE", methods=["GET"], pattern=r"^/pets$"
),
],
)
)
mcp = FastMCP("petstore", plugins=[plugin])
async with Client(mcp) as c:
tools = {t.name for t in await c.list_tools()}
resources = {str(r.uri) for r in await c.list_resources()}
assert "list_pets" not in tools
assert any("list_pets" in uri or "/pets" in uri for uri in resources)
def test_typed_route_maps_override_dict_config(self):
"""When users pass typed `route_maps=` to `__init__`, that beats
the dict form in Config advanced users shouldn't be shadowed
by an empty default."""
plugin = OpenAPI(
OpenAPIConfig(spec=PETSTORE_SPEC),
route_maps=[RouteMap(mcp_type=MCPType.EXCLUDE, pattern=r".*")],
)
providers = plugin.providers()
provider = providers[0]
# Every route was excluded → provider has no tools/resources.
assert isinstance(provider, OpenAPIProvider)
class TestDefaultClient:
async def test_plugin_built_client_is_closed_on_provider_lifespan_exit(self):
"""When the plugin builds its own httpx client (user didn't pass
`client=`), the provider's lifespan must still close it on
shutdown. A leaked client was bug noted on PR #4015."""
plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC))
provider = plugin.providers()[0]
assert isinstance(provider, OpenAPIProvider)
client = provider._client
assert not client.is_closed
async with provider.lifespan():
pass
assert client.is_closed
async def test_server_variable_defaults_are_substituted(self):
"""Spec servers with `{variable}` placeholders must be resolved
using `servers[0].variables[name].default` before going to the
httpx client otherwise the literal template leaks into every
request URL."""
templated_spec = {
**PETSTORE_SPEC,
"servers": [
{
"url": "https://{region}.api.example.com",
"variables": {"region": {"default": "us-east"}},
}
],
}
plugin = OpenAPI(OpenAPIConfig(spec=templated_spec))
provider = plugin.providers()[0]
assert isinstance(provider, OpenAPIProvider)
assert str(provider._client.base_url) == "https://us-east.api.example.com"
class TestEscapeHatches:
async def test_custom_client_is_used(self):
"""Passing `client=` bypasses the auto-derived httpx client."""
client = httpx.AsyncClient(base_url="https://override.example.com")
plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC), client=client)
providers = plugin.providers()
assert isinstance(providers[0], OpenAPIProvider)
# Access the provider's client through the known private attr.
# This is an implementation check — acceptable in a test.
assert providers[0]._client is client
await client.aclose()
class TestDeprecationShim:
"""The old `fastmcp.server.providers.openapi` location now shims
back to the new plugin package. Top-level import is silent (so
unrelated code touching `fastmcp.server.providers` doesn't spray
warnings), but leaf submodules emit a `FastMCPDeprecationWarning`."""
async def test_top_level_old_path_is_silent_and_functional(self):
"""Still-common `from fastmcp.server.providers.openapi import
OpenAPIProvider` keeps working without emitting a warning."""
import warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
from fastmcp.server.providers.openapi import (
OpenAPIProvider as LegacyProvider,
)
fastmcp_warns = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert not fastmcp_warns
client = httpx.AsyncClient(base_url="https://petstore.example.com")
provider = LegacyProvider(openapi_spec=PETSTORE_SPEC, client=client)
mcp = FastMCP("petstore", providers=[provider])
async with Client(mcp) as c:
tools = {t.name for t in await c.list_tools()}
assert {"list_pets", "create_pet", "get_pet"}.issubset(tools)
assert LegacyProvider is OpenAPIProvider
await client.aclose()
def test_leaf_submodule_import_emits_deprecation_warning(self):
import importlib
import sys
import warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.providers.openapi.provider", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.providers.openapi.provider")
fastmcp_warns = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any("plugins.openapi" in str(w.message) for w in fastmcp_warns), (
f"expected FastMCPDeprecationWarning pointing at plugins.openapi, "
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
)

View file

@ -0,0 +1,125 @@
"""Tests for the PromptsAsTools plugin wrapper.
Transform behavior is covered by `test_prompts_as_tools.py`. This file
only covers plugin-layer concerns config validation, meta derivation,
and the deprecation shim at the old import path.
"""
from __future__ import annotations
import warnings
import pytest
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.server.plugins.prompts_as_tools import (
PromptsAsTools,
PromptsAsToolsConfig,
)
class TestPromptsAsToolsConfig:
def test_config_generic_binding(self):
assert PromptsAsTools._config_cls is PromptsAsToolsConfig
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
PromptsAsToolsConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_default_meta(self):
assert PromptsAsTools.meta.name == "prompts-as-tools"
assert PromptsAsTools.meta.version is None
class TestPromptsAsToolsPluginRegistration:
async def test_plugin_registers_synthetic_tools(self):
mcp = FastMCP("t", plugins=[PromptsAsTools()])
@mcp.prompt
def greet(name: str) -> str:
"""Say hello."""
return f"Hello {name}"
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
assert {"list_prompts", "get_prompt"}.issubset(names)
class TestDeprecationShim:
def test_old_path_emits_deprecation_warning(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.transforms.prompts_as_tools", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.transforms.prompts_as_tools")
fastmcp_deprecations = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any(
"plugins.prompts_as_tools" in str(w.message) for w in fastmcp_deprecations
), (
f"expected FastMCPDeprecationWarning pointing at plugins.prompts_as_tools, "
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
)
async def test_legacy_add_transform_pattern_still_works(self):
"""End-to-end: old `add_transform(PromptsAsTools(mcp))` code keeps
working. `PromptsAsTools` at the old path must remain the transform
class, not the plugin."""
from fastmcp.exceptions import FastMCPDeprecationWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastMCPDeprecationWarning)
from fastmcp.server.transforms.prompts_as_tools import (
PromptsAsTools as OldPromptsAsTools,
)
mcp = FastMCP("legacy")
@mcp.prompt
def greet(name: str) -> str:
return f"Hello {name}"
mcp.add_transform(OldPromptsAsTools(mcp))
tools = await mcp.list_tools(run_middleware=False)
assert {"list_prompts", "get_prompt"}.issubset({t.name for t in tools})
def test_top_level_import_does_not_emit_deprecation(self):
"""`from fastmcp.server.transforms import Transform` should not
trigger a PromptsAsTools deprecation warning. The warning only
fires when the leaf module is imported directly."""
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
# Flush anything that might carry a cached import.
sys.modules.pop("fastmcp.server.transforms", None)
sys.modules.pop("fastmcp.server.transforms.prompts_as_tools", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.transforms")
# Access the attr via __getattr__, which should NOT load the
# shim leaf module.
mod = sys.modules["fastmcp.server.transforms"]
_ = mod.Transform
assert not any(
issubclass(w.category, FastMCPDeprecationWarning)
and "prompts_as_tools" in str(w.message)
for w in caught
), (
f"unexpected prompts_as_tools deprecation warning from top-level "
f"import: {[(w.category.__name__, str(w.message)) for w in caught]}"
)

View file

@ -0,0 +1,91 @@
"""Tests for the ResourcesAsTools plugin wrapper.
Transform behavior is covered by `test_resources_as_tools.py`. This file
only covers plugin-layer concerns config validation, meta derivation,
and the deprecation shim at the old import path.
"""
from __future__ import annotations
import warnings
import pytest
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.server.plugins.resources_as_tools import (
ResourcesAsTools,
ResourcesAsToolsConfig,
)
class TestResourcesAsToolsConfig:
def test_config_generic_binding(self):
assert ResourcesAsTools._config_cls is ResourcesAsToolsConfig
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
ResourcesAsToolsConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_default_meta(self):
assert ResourcesAsTools.meta.name == "resources-as-tools"
assert ResourcesAsTools.meta.version is None
class TestResourcesAsToolsPluginRegistration:
async def test_plugin_registers_synthetic_tools(self):
mcp = FastMCP("t", plugins=[ResourcesAsTools()])
@mcp.resource("test://hello")
def hello() -> str:
return "world"
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
assert {"list_resources", "read_resource"}.issubset(names)
class TestDeprecationShim:
def test_old_path_emits_deprecation_warning(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.transforms.resources_as_tools", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.transforms.resources_as_tools")
fastmcp_deprecations = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any(
"plugins.resources_as_tools" in str(w.message) for w in fastmcp_deprecations
), (
f"expected FastMCPDeprecationWarning pointing at plugins.resources_as_tools, "
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
)
async def test_legacy_add_transform_pattern_still_works(self):
from fastmcp.exceptions import FastMCPDeprecationWarning
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastMCPDeprecationWarning)
from fastmcp.server.transforms.resources_as_tools import (
ResourcesAsTools as OldResourcesAsTools,
)
mcp = FastMCP("legacy")
@mcp.resource("test://hello")
def hello() -> str:
return "world"
mcp.add_transform(OldResourcesAsTools(mcp))
tools = await mcp.list_tools(run_middleware=False)
assert {"list_resources", "read_resource"}.issubset({t.name for t in tools})

View file

@ -0,0 +1,133 @@
"""Tests for the Skills plugin wrapper.
Provider behavior (skill discovery, file exposure, etc.) is covered by
`test_skills_provider.py` and `test_skills_vendor_providers.py`. This
file only covers plugin-layer concerns config validation, meta,
vendorpath resolution, and the deprecation shim at the old import path.
"""
from __future__ import annotations
import warnings
from pathlib import Path
from typing import cast
import pytest
from pydantic import ValidationError
from fastmcp.server.plugins.skills import Skills, SkillsConfig
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.server.plugins.skills.plugin import VENDOR_PATHS, Vendor
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
class TestSkillsConfig:
def test_config_generic_binding(self):
assert Skills._config_cls is SkillsConfig
def test_default_config_instantiable(self):
"""Defaults must pass the plugin framework's instantiate-with-no-args
contract; the source check fires at providers() time."""
assert SkillsConfig() # must not raise
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
SkillsConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_default_meta(self):
assert Skills.meta.name == "skills"
assert Skills.meta.version is None
class TestSourceResolution:
def test_path_source_builds_skill_provider(self, tmp_path: Path):
skill = tmp_path / "my-skill"
skill.mkdir()
(skill / "SKILL.md").write_text("# My Skill")
plugin = Skills(SkillsConfig(path=str(skill)))
providers = plugin.providers()
assert isinstance(providers[0], SkillProvider)
def test_directory_source_builds_directory_provider(self, tmp_path: Path):
plugin = Skills(SkillsConfig(directory=str(tmp_path)))
providers = plugin.providers()
assert isinstance(providers[0], SkillsDirectoryProvider)
def test_directory_source_accepts_list(self, tmp_path: Path):
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
plugin = Skills(SkillsConfig(directory=[str(a), str(b)]))
providers = plugin.providers()
assert isinstance(providers[0], SkillsDirectoryProvider)
@pytest.mark.parametrize("vendor", list(VENDOR_PATHS))
def test_vendor_presets_resolve_to_known_paths(self, vendor: str):
"""Every vendor string must produce a directory provider rooted
at the paths the old vendor subclass used to hardcode."""
plugin = Skills(SkillsConfig(vendor=cast(Vendor, vendor)))
providers = plugin.providers()
assert isinstance(providers[0], SkillsDirectoryProvider)
def test_no_source_fails_at_build_time(self):
plugin = Skills(SkillsConfig())
with pytest.raises(ValueError, match="path.*directory.*vendor"):
plugin.providers()
def test_multiple_sources_rejected(self, tmp_path: Path):
plugin = Skills(SkillsConfig(directory=str(tmp_path), vendor="claude"))
with pytest.raises(ValueError, match="exactly one"):
plugin.providers()
class TestDeprecationShim:
"""The old `fastmcp.server.providers.skills` package shims back to the
new plugin package. Top-level stays silent; leaf submodule imports
emit `FastMCPDeprecationWarning`."""
def test_top_level_is_silent(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.providers.skills", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.providers.skills")
fastmcp_warns = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert not fastmcp_warns
def test_leaf_submodule_import_emits_deprecation_warning(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.providers.skills.vendor_providers", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.providers.skills.vendor_providers")
fastmcp_warns = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any("plugins.skills" in str(w.message) for w in fastmcp_warns)
def test_old_import_path_symbols_still_resolve(self):
"""`ClaudeSkillsProvider` and friends keep resolving through the
silent package-level shim."""
from fastmcp.server.plugins.skills.claude_provider import (
ClaudeSkillsProvider as NewClass,
)
from fastmcp.server.providers.skills import (
ClaudeSkillsProvider as OldClass,
)
assert OldClass is NewClass

View file

@ -8,13 +8,14 @@ from mcp.types import TextResourceContents
from pydantic import AnyUrl
from fastmcp import Client, FastMCP
from fastmcp.server.providers.skills import (
ClaudeSkillsProvider,
SkillProvider,
SkillsDirectoryProvider,
SkillsProvider,
)
from fastmcp.server.providers.skills._common import parse_frontmatter
from fastmcp.server.plugins.skills._common import parse_frontmatter
from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
# `SkillsProvider` was a backcompat alias for `SkillsDirectoryProvider`
# in the old providers/ package — preserve that shape for these tests.
SkillsProvider = SkillsDirectoryProvider
class TestParseFrontmatter:

View file

@ -4,8 +4,8 @@ from __future__ import annotations
from pathlib import Path
from fastmcp.server.providers.skills import (
ClaudeSkillsProvider,
from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider
from fastmcp.server.plugins.skills.vendor_providers import (
CodexSkillsProvider,
CopilotSkillsProvider,
CursorSkillsProvider,

View file

@ -0,0 +1,185 @@
"""Tests for the ToolSearch plugin.
These exercise the plugin-facing API (`ToolSearch`, its `Config`,
registration on a server) rather than the underlying transform
internals, which live in `tests/server/transforms/test_search.py`.
"""
from __future__ import annotations
import warnings
import pytest
from pydantic import ValidationError
from fastmcp import Client, FastMCP
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform
from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform
def _make_server_with_tools(plugins: list) -> FastMCP:
mcp = FastMCP("t", plugins=plugins)
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@mcp.tool
def multiply(x: float, y: float) -> float:
"""Multiply two numbers."""
return x * y
@mcp.tool
def search_files(pattern: str) -> list[str]:
"""Search the filesystem for files matching a pattern."""
return []
return mcp
class TestSearchPluginRegistration:
async def test_default_plugin_uses_bm25_and_hides_tools(self):
"""With no config, ToolSearch uses BM25 and replaces list_tools output."""
mcp = _make_server_with_tools([ToolSearch()])
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
# Only the synthetic pair should be visible.
assert names == {"search_tools", "call_tool"}
async def test_regex_strategy_dispatches_regex_transform(self):
plugin = ToolSearch(ToolSearchConfig(strategy="regex"))
transforms = plugin.transforms()
assert len(transforms) == 1
assert isinstance(transforms[0], RegexSearchTransform)
async def test_bm25_strategy_dispatches_bm25_transform(self):
plugin = ToolSearch(ToolSearchConfig(strategy="bm25"))
transforms = plugin.transforms()
assert len(transforms) == 1
assert isinstance(transforms[0], BM25SearchTransform)
async def test_always_visible_pins_tools_alongside_search_call(self):
mcp = _make_server_with_tools(
[ToolSearch(ToolSearchConfig(always_visible=["add"]))]
)
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
assert names == {"add", "search_tools", "call_tool"}
async def test_custom_tool_names_apply(self):
mcp = _make_server_with_tools(
[
ToolSearch(
ToolSearchConfig(search_tool_name="find", call_tool_name="invoke")
)
]
)
async with Client(mcp) as c:
tools = await c.list_tools()
names = {t.name for t in tools}
by_name = {t.name: t for t in tools}
assert names == {"find", "invoke"}
# The call-tool proxy's description must reference the actual
# configured search-tool name, not the hardcoded "search_tools"
# default — otherwise LLMs see misleading guidance pointing at
# a tool that doesn't exist under the user's rename.
assert by_name["invoke"].description is not None
assert "find" in by_name["invoke"].description
assert "search_tools" not in by_name["invoke"].description
async def test_search_binds_searchconfig_via_generic_parameter(self):
"""`Plugin[ToolSearchConfig]` makes ToolSearchConfig the validated config type."""
assert ToolSearch._config_cls is ToolSearchConfig
async def test_dict_config_still_accepted(self):
"""Dict config path (inherited from Plugin base) constructs cleanly —
used for loading plugin configs from JSON/YAML."""
plugin = ToolSearch({"strategy": "regex"})
assert isinstance(plugin.transforms()[0], RegexSearchTransform)
async def test_hidden_tool_is_still_callable(self):
"""ToolSearch hides tools from list_tools but leaves them callable by name."""
mcp = _make_server_with_tools([ToolSearch()])
async with Client(mcp) as c:
result = await c.call_tool("add", {"a": 2, "b": 3})
assert result.data == 5
class TestSearchPluginConfigValidation:
def test_unknown_strategy_rejected(self):
with pytest.raises((ValidationError, Exception), match="strategy"):
ToolSearchConfig(strategy="fuzzy") # ty: ignore[invalid-argument-type]
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
ToolSearchConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_default_meta_name_and_version(self):
"""ToolSearch relies on Plugin's auto-derived meta: kebab-cased
class name, no independent version (bundled first-party plugin)."""
assert ToolSearch.meta.name == "tool-search"
assert ToolSearch.meta.version is None
class TestDeprecationShim:
"""The old `fastmcp.server.transforms.search` path still works but warns."""
def test_old_package_import_emits_deprecation_warning(self):
# Force a fresh import so the module-level warning fires in this process.
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.transforms.search", None)
sys.modules.pop("fastmcp.server.transforms.search.base", None)
sys.modules.pop("fastmcp.server.transforms.search.bm25", None)
sys.modules.pop("fastmcp.server.transforms.search.regex", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.transforms.search")
# Must be FastMCPDeprecationWarning specifically — fastmcp installs a
# filter that surfaces that subclass even when the base
# DeprecationWarning is suppressed by CPython's default filter.
fastmcp_deprecations = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any(
"plugins.tool_search" in str(w.message) for w in fastmcp_deprecations
), (
f"expected FastMCPDeprecationWarning pointing at plugins.tool_search, "
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
)
def test_old_submodule_imports_still_resolve(self):
"""Existing code that imports from the old submodule path keeps working."""
from fastmcp.exceptions import FastMCPDeprecationWarning
# Suppress the parent-package deprecation warning that fires on first
# import — otherwise running this test in isolation leaks the warning
# to pytest output.
with warnings.catch_warnings():
warnings.simplefilter("ignore", FastMCPDeprecationWarning)
from fastmcp.server.transforms.search.bm25 import (
BM25SearchTransform as OldBM25,
)
from fastmcp.server.transforms.search.regex import (
RegexSearchTransform as OldRegex,
)
# They're the same classes as the new path, not shims.
assert OldBM25 is BM25SearchTransform
assert OldRegex is RegexSearchTransform

1756
tests/server/test_plugins.py Normal file

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more