Skip to main content
Tools are the functions your MCP server exposes to AI assistants. When an LLM decides it needs to perform an action—like searching documents, creating issues, or fetching data—it calls one of your tools. You can use FastMCP (recommended) or the low-level Server API. Both are fully supported.

Basic Tool

Here’s a simple tool that searches documents. Notice the type annotations and docstring—these are essential for LLMs to understand and use your tool correctly.
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field

mcp = FastMCP("my-server")

class SearchResult(BaseModel):
    title: str
    url: str
    snippet: str

@mcp.tool()
async def search(query: str = Field(description="Search query")) -> list[SearchResult]:
    """Search for documents matching the query."""
    # Implementation
    return results

Requirements

Every tool needs a few things to work properly with Gumstack and LLMs. Missing any of these will cause issues.

Declare in config.yaml

Every tool must be declared in config.yaml:
tools:
  - name: "search"
    description: "Search for documents"
The name must match your function name exactly. Without this, Gumstack won’t detect the tool.

Return type annotation

Every tool must have a return type. This generates the output schema that Gumloop uses for node connections.
# Good - typed return
@mcp.tool()
def get_user(id: str) -> User:
    ...

# Bad - no schema generated, breaks Gumloop
@mcp.tool()
def get_user(id: str):
    return {"name": "John"}

Docstrings

LLMs use docstrings to decide when to call your tool. Be specific:
# Good
@mcp.tool()
def create_issue(title: str, body: str) -> Issue:
    """Create a new issue in the project tracker.

    Use this when the user wants to report a bug or request a feature.
    """

# Bad - too vague
@mcp.tool()
def create_issue(title: str, body: str) -> Issue:
    """Create an issue."""

Parameter descriptions

Use Annotated with a string or Field(description=...):
from typing import Annotated
from pydantic import Field

@mcp.tool()
def search(
    query: Annotated[str, "Keywords to search for"],
    limit: Annotated[int, Field(description="Max results", ge=1, le=100)] = 10
) -> list[SearchResult]:
    """Search documents."""

Supported types

FastMCP supports all Pydantic types:
TypeExample
Basicint, float, str, bool
Collectionslist[str], dict[str, int]
Optionalstr | None, Optional[str]
ConstrainedLiteral["A", "B"], Enum
Pydantic modelsUser, SearchResult
Date/timedatetime, date
PathsPath

Common Mistakes

These patterns cause problems in production. Avoid them to keep your tools reliable and debuggable.
# Bad - no output schema
@mcp.tool()
def get_data() -> dict:
    return {"key": "value"}

# Good - typed dict or BaseModel

@mcp.tool()
def get_data() -> dict[str, str]:
return {"key": "value"}

# Bad - blocks event loop
@mcp.tool()
async def fetch(url: str) -> str:
    return requests.get(url).text

# Good - use async HTTP client
@mcp.tool()
async def fetch(url: str) -> str:
    async with httpx.AsyncClient() as client:
        resp = await client.get(url)
        return resp.text
# Bad
API_KEY = "sk-..."

# Good

creds = await get_credentials()
api_key = creds["api_key"]

# Bad - hides errors from observability
try:
    result = do_thing()
except Exception:
    return "Error occurred"

# Good - let errors propagate
result = do_thing()  # Gumstack logs the error

One Tool, One Job

Resist the urge to create “swiss army knife” tools that do multiple things. Split complex operations into focused tools for better:
  • Observability — See exactly which operation failed
  • Access control — Admins can restrict specific actions
  • LLM accuracy — Simpler tools are easier to use correctly
# Instead of one mega-tool
@mcp.tool()
def manage_issues(action: str, issue_id: str = None, title: str = None):
    ...

# Split into focused tools
@mcp.tool()
def list_issues() -> list[Issue]: ...

@mcp.tool()
def create_issue(title: str, body: str) -> Issue: ...

@mcp.tool()
def close_issue(issue_id: str) -> Issue: ...

Low-level Server API

FastMCP handles most use cases, but if you need fine-grained control over tool registration and responses, you can use the low-level Server class directly:
from mcp.server.lowlevel.server import Server
import mcp.types as types

server = Server("my-server")

@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="greet",
            description="Say hello",
            inputSchema={"type": "object", "properties": {"name": {"type": "string"}}}
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "greet":
        return [types.TextContent(type="text", text=f"Hello, {arguments['name']}!")]
See the MCP Python SDK and FastMCP docs for full API reference.