Skip to content

[Draft] Introduce a module for rewriting Tools #599

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"rich>=13.9.4",
"typer>=0.15.2",
"websockets>=14.0",
"jsonschema>=4.23.0",
]
requires-python = ">=3.10"
readme = "README.md"
Expand Down
98 changes: 98 additions & 0 deletions src/fastmcp/contrib/tool_transformer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Tool Transformer

This module provides the [`proxy_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:209) function and related utilities to modify existing FastMCP tools and add them to a server. It allows for intercepting tool calls, altering their behavior by changing parameters, adding new parameters for hooks, and executing custom logic before or after the original tool execution. The [`transform_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:133) function is also available for transforming a tool without adding it to a server.

This is a community-contributed module and is located in the `contrib` directory. Please refer to the main [FastMCP Contrib Modules README](../../contrib/README.md) for general information about contrib modules and their guarantees.

## Purpose

The Tool Transformer is useful for scenarios where you need to:
- Modify arguments of an existing tool (e.g., setting constant values, providing new defaults, changing descriptions).
- Add new parameters that are not passed to the original tool but are available to custom hook functions.
- Execute custom logic before a tool call (e.g., logging, input validation, conditional logic via `pre_call_hook`).
- Process the response from a tool before returning it (e.g., filtering, transforming, logging via `post_call_hook`).
- Load tool transformation configurations from YAML files.

## Installation

Since this is a contrib module, it is included with the FastMCP library. No separate installation is required.

## Core Concepts

### `proxy_tool` Function
The primary way to use this module to transform a tool and add it to a server is through the [`proxy_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:209) function. This function takes an existing `FastMCPTool` instance and an `FastMCP` server instance to which the new, transformed tool will be added. It allows you to specify a new name, description, parameter overrides, hook parameters, and pre/post call hooks.

### `transform_tool` Function
The [`transform_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:133) function is used to transform a tool without adding it to a server. It takes an existing `FastMCPTool` instance and allows you to specify a new name, description, parameter overrides, hook parameters, and pre/post call hooks.

### Parameter Overrides
You can modify existing parameters of a tool using the [`ToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:16) class and its subclasses (e.g., [`StringToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:150), [`IntToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:134), [`BooleanToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:158)). This allows you to:
- Change a parameter's description.
- Set a `constant` value for a parameter, which will always be used.
- Provide a new `default` value.
- Make an optional parameter `required` (note: you cannot make an already required parameter optional).

### Extra Hook Parameters
New parameters can be defined using the [`ToolParameterTypes`](src/fastmcp/contrib/tool_transformer/models.py:186) (e.g., [`StringToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:150), [`IntToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:134), or [`BooleanToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:158)). These parameters are exposed by the transformed tool but are *not* passed to the underlying original tool. Instead, their values are passed to the `pre_call_hook` and `post_call_hook` functions, allowing for more flexible hook logic.

### Pre-call and Post-call Hooks
- **`pre_call_hook`**: A callable (async function) that is executed *before* the original tool is called. It receives the arguments intended for the original tool (which can be modified) and the values of any `hook_parameters`. See [`PreToolCallHookProtocol`](src/fastmcp/contrib/tool_transformer/models.py:197).
- **`post_call_hook`**: A callable (async function) that is executed *after* the original tool returns a response. It receives the response from the original tool, the arguments that were passed to it, and the values of any `hook_parameters`. See [`PostToolCallHookProtocol`](src/fastmcp/contrib/tool_transformer/models.py:206).

### Loading Overrides from YAML
The module also supports defining tool transformations in YAML or JSON files. The [`loader.py`](src/fastmcp/contrib/tool_transformer/loader.py) provides functions to load [`ToolOverride`](src/fastmcp/contrib/tool_transformer/models.py:219) objects from these files, such as [`overrides_from_yaml_file`](src/fastmcp/contrib/tool_transformer/loader.py:22) and [`overrides_from_json_file`](src/fastmcp/contrib/tool_transformer/loader.py:31).

## Usage

### Programmatic Transformation
To transform a single tool programmatically:
1. Obtain an instance of the tool you want to transform (e.g., from a `FastMCP` server).
2. Call the [`proxy_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:209) function, providing the original tool, the target server, and any desired transformation options (name, description, parameter overrides, hook parameters, hooks, etc.).

Refer to [`example-overrides.py`](src/fastmcp/contrib/tool_transformer/example-overrides.py) for transforming parameters and [`example-hooks.py`](src/fastmcp/contrib/tool_transformer/example-hooks.py) for using pre-call hooks.

### YAML/JSON-based Transformation
To transform tools based on a YAML or JSON configuration:
1. Create a YAML or JSON file defining your tool overrides (see [`example-overrides-yaml.py`](src/fastmcp/contrib/tool_transformer/example-overrides-yaml.py) for structure).
2. Load this configuration using a function from [`loader.py`](src/fastmcp/contrib/tool_transformer/loader.py), such as [`overrides_from_yaml_file`](src/fastmcp/contrib/tool_transformer/loader.py:22). This will give you a dictionary mapping tool names to [`ToolOverride`](src/fastmcp/contrib/tool_transformer/models.py:219) objects.
3. Iterate through the tools you want to transform and call [`proxy_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:209) for each, providing the original tool, the target server, and the corresponding [`ToolOverride`](src/fastmcp/contrib/tool_transformer/models.py:219) object.

Refer to [`example-overrides-yaml.py`](src/fastmcp/contrib/tool_transformer/example-overrides-yaml.py) for a practical demonstration.

## Key Components and Parameters

### `proxy_tool` and `transform_tool` Parameters:
Both [`proxy_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:209) and [`transform_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:133) accept the following parameters:
- `tool`: The `FastMCPTool` instance to be transformed. (Required)
- `override`: A [`ToolOverride`](src/fastmcp/contrib/tool_transformer/models.py:219) object containing the overrides to apply. If provided, the individual override parameters below are ignored. (Optional)
- `name`: The new name for the transformed tool. If `None`, the original tool's name is used. (Optional)
- `description`: The new description for the transformed tool. If `None`, the original tool's description is used. (Optional)
- `parameter_overrides`: A list of [`ToolParameterTypes`](src/fastmcp/contrib/tool_transformer/models.py:186) objects defining overrides for existing parameters. (Optional)
- `hook_parameters`: A list of [`ToolParameterTypes`](src/fastmcp/contrib/tool_transformer/models.py:186) objects defining additional parameters for hooks. (Optional)
- `pre_call_hook`: An async callable matching [`PreToolCallHookProtocol`](src/fastmcp/contrib/tool_transformer/models.py:197). Executed before the original tool call. (Optional)
- Receives: `tool_args: dict[str, Any]` (modifiable), `hook_args: dict[str, Any]`
- `post_call_hook`: An async callable matching [`PostToolCallHookProtocol`](src/fastmcp/contrib/tool_transformer/models.py:206). Executed after the original tool call. (Optional)
- Receives: `response: list[TextContent | ImageContent | EmbeddedResource]`, `tool_args: dict[str, Any]`, `hook_args: dict[str, Any]`

Additionally, [`proxy_tool`](src/fastmcp/contrib/tool_transformer/tool_transformer.py:209) requires:
- `server`: The `FastMCP` server instance where the transformed tool will be added. (Required)

### Models
- [`ToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:16): A base class for defining tool parameters and their overrides. Subclasses like [`StringToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:150), [`IntToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:134), and [`BooleanToolParameter`](src/fastmcp/contrib/tool_transformer/models.py:158) are used for specific types. Key fields include:
- `name`: Name of the parameter. (Required)
- `description`: New description for the parameter. (Optional)
- `required`: Boolean indicating if the parameter should be made required. Cannot make an already required parameter optional. (Optional)
- `constant`: A constant value for the parameter. If set, this value always overrides any input. (Optional)
- `default`: New default value for the parameter. (Optional)
- [`ToolOverride`](src/fastmcp/contrib/tool_transformer/models.py:219): A Pydantic model used to group transformation options for a single tool, particularly useful when loading configurations from YAML/JSON. Key fields include:
- `name`: New name for the tool. (Optional)
- `description`: New description for the tool. (Optional)
- `parameter_overrides`: List of [`ToolParameterTypes`](src/fastmcp/contrib/tool_transformer/models.py:186) objects. (Optional)
- `hook_parameters`: List of [`ToolParameterTypes`](src/fastmcp/contrib/tool_transformer/models.py:186) objects. (Optional)
- `pre_call_hook`: An async callable matching [`PreToolCallHookProtocol`](src/fastmcp/contrib/tool_transformer/models.py:197). (Optional)
- `post_call_hook`: An async callable matching [`PostToolCallHookProtocol`](src/fastmcp/contrib/tool_transformer/models.py:206). (Optional)

## Examples
- **Programmatic Transformation with Parameter Overrides:** See [`example-overrides.py`](src/fastmcp/contrib/tool_transformer/example-overrides.py)
- **Programmatic Transformation with Hooks:** See [`example-hooks.py`](src/fastmcp/contrib/tool_transformer/example-hooks.py)
- **YAML-based Transformation:** See [`example-overrides-yaml.py`](src/fastmcp/contrib/tool_transformer/example-overrides-yaml.py)
26 changes: 26 additions & 0 deletions src/fastmcp/contrib/tool_transformer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from fastmcp.contrib.tool_transformer.models import (
BooleanToolParameter,
FloatToolParameter,
IntToolParameter,
PostToolCallHookProtocol,
PreToolCallHookProtocol,
StringToolParameter,
ToolOverride,
ToolParameter,
ToolParameterTypes,
)
from fastmcp.contrib.tool_transformer.tool_transformer import proxy_tool, transform_tool

__all__ = [
"BooleanToolParameter",
"FloatToolParameter",
"IntToolParameter",
"PostToolCallHookProtocol",
"PreToolCallHookProtocol",
"StringToolParameter",
"ToolOverride",
"ToolParameter",
"ToolParameterTypes",
"proxy_tool",
"transform_tool",
]
26 changes: 26 additions & 0 deletions src/fastmcp/contrib/tool_transformer/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class TransformedToolError(Exception):
"""An error that occurs when a tool is transformed."""

def __init__(self, message: str):
super().__init__(message)


class ToolParameterOverrideError(TransformedToolError):
"""An error that occurs when a parameter override is invalid."""

def __init__(self, parameter_name: str):
super().__init__(f"Parameter {parameter_name} not found in tool.")


class ToolParameterNotFoundError(TransformedToolError):
"""An error that occurs when a parameter is not found in the schema."""

def __init__(self, parameter_name: str):
super().__init__(f"Parameter {parameter_name} not found in tool.")


class ToolParameterAlreadyExistsError(TransformedToolError):
"""An error that occurs when a parameter already exists in the schema."""

def __init__(self, parameter_name: str):
super().__init__(f"Parameter {parameter_name} already exists in schema.")
53 changes: 53 additions & 0 deletions src/fastmcp/contrib/tool_transformer/example-hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Sample code for FastMCP using InterceptingProxyTool."""

import asyncio
from typing import Any

from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.contrib.tool_transformer.tool_transformer import proxy_tool
from fastmcp.exceptions import ToolError

third_party_mcp_config = {
"time": {
"command": "uvx",
"args": [
"git+https://github.com/modelcontextprotocol/[email protected]#subdirectory=src/time",
"--local-timezone=America/New_York",
],
}
}


async def async_main():
async with Client(third_party_mcp_config) as remote_mcp_client:
proxied_mcp_server = FastMCP.as_proxy(remote_mcp_client)

proxied_tools = await proxied_mcp_server.get_tools()

frontend_server = FastMCP("Frontend Server")

async def pre_call_hook(
tool_args: dict[str, Any],
hook_args: dict[str, Any],
) -> None:
if tool_args.get("source_timezone") == "America/New_York":
raise ToolError("New Yorkers are not allowed to use this tool.")

proxy_tool(
proxied_tools["convert_time"],
frontend_server,
name="transformed_convert_time",
description="Converts a time from New York to another timezone.",
pre_call_hook=pre_call_hook,
)

await frontend_server.run_async(transport="sse")


def run_mcp():
asyncio.run(async_main())


if __name__ == "__main__":
run_mcp()
63 changes: 63 additions & 0 deletions src/fastmcp/contrib/tool_transformer/example-overrides-yaml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Sample code for FastMCP using InterceptingProxyTool."""

import asyncio
from pathlib import Path

import yaml

from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.contrib.tool_transformer import proxy_tool
from fastmcp.contrib.tool_transformer.loader import overrides_from_yaml_file

third_party_mcp_config = {
"time": {
"command": "uvx",
"args": [
"git+https://github.com/modelcontextprotocol/[email protected]#subdirectory=src/time",
"--local-timezone=America/New_York",
],
}
}

override_config_yaml = yaml.safe_load("""
tools:
- convert_time:
description: >-
An updated multi-line description
for the time tool.
parameter_overrides:
source_timezone:
description: This field now has a description and a constant value
constant: America/New_York
time:
description: This field now has a description and a default value
default: "3:00"
""")


async def async_main():
async with Client(third_party_mcp_config) as remote_mcp_client:
backend_server = FastMCP.as_proxy(remote_mcp_client)

backend_tools = await backend_server.get_tools()

frontend_server = FastMCP("Frontend Server")

tool_overrides = overrides_from_yaml_file(Path("override_config.yaml"))

proxy_tool(
tool=backend_tools["convert_time"],
server=frontend_server,
override=tool_overrides["convert_time"],
)

await frontend_server.run_async(transport="sse")


def run_mcp():
asyncio.run(async_main())


if __name__ == "__main__":
run_mcp()
59 changes: 59 additions & 0 deletions src/fastmcp/contrib/tool_transformer/example-overrides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Sample code for FastMCP using InterceptingProxyTool."""

import asyncio

from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.contrib.tool_transformer.models import ToolParameter
from fastmcp.contrib.tool_transformer.tool_transformer import (
proxy_tool,
)

third_party_mcp_config = {
"time": {
"command": "uvx",
"args": [
"git+https://github.com/modelcontextprotocol/[email protected]#subdirectory=src/time",
"--local-timezone=America/New_York",
],
}
}


async def async_main():
async with Client(third_party_mcp_config, timeout=30) as remote_mcp_client:
proxied_mcp_server = FastMCP.as_proxy(remote_mcp_client)

proxied_tools = await proxied_mcp_server.get_tools()

frontend_server = FastMCP("Frontend Server")

proxy_tool(
proxied_tools["convert_time"],
server=frontend_server,
name="transformed_convert_time",
description="Converts a time from New York to another timezone.",
parameter_overrides=[
ToolParameter[str](
name="source_timezone",
description="The timezone of the time to convert.",
constant="America/New_York", # Source Timezone is now required to be America/New_York
),
ToolParameter[str](
name="time",
description="The time to convert. Must be in the format HH:MM. Default is 3:00.",
default="3:00", # Time now defaults to 3:00
),
# No override of the override the target_timezone parameter
],
)

await frontend_server.run_async(transport="sse")


def run_mcp():
asyncio.run(async_main())


if __name__ == "__main__":
run_mcp()
32 changes: 32 additions & 0 deletions src/fastmcp/contrib/tool_transformer/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import json
from pathlib import Path
from typing import Any

import yaml

from fastmcp.contrib.tool_transformer.models import ToolOverride


def overrides_from_dict(obj: dict[str, Any]) -> dict[str, ToolOverride]:
return {
tool_name: ToolOverride.model_validate(tool_override)
for tool_name, tool_override in obj.items()
}


def overrides_from_yaml(yaml_str: str) -> dict[str, ToolOverride]:
return overrides_from_dict(yaml.safe_load(yaml_str))


def overrides_from_yaml_file(yaml_file: Path) -> dict[str, ToolOverride]:
with Path(yaml_file).open(encoding="utf-8") as f:
return overrides_from_yaml(yaml_str=f.read())


def overrides_from_json(json_str: str) -> dict[str, ToolOverride]:
return overrides_from_dict(json.loads(json_str))


def overrides_from_json_file(json_file: Path) -> dict[str, ToolOverride]:
with Path(json_file).open(encoding="utf-8") as f:
return overrides_from_json(f.read())
Loading