Skip to content

The @platools.tool() decorator

The @platools.tool() decorator registers a function as an MCP-compliant tool. All arguments are keyword-only, and the decorator always requires parentheses.

Signature (from platools/core/decorator.py):

def tool(
*,
name: str | None = None,
description: str | None = None,
auth: AuthLevel = "none",
roles: list[str] | tuple[str, ...] | None = None,
rate_limit: str | None = None,
timeout: int | None = None,
annotations: dict[str, Any] | None = None,
) -> Callable[[F], F]: ...

All arguments are keyword-only. The decorator is called with parentheses every time, even when you don’t pass any arguments:

@platools.tool() # correct
def foo(x: int) -> int: ...
@platools.tool # WRONG - this tries to decorate the factory itself
def foo(x: int) -> int: ...

Parameters

name: str | None

The tool’s external name. Defaults to func.__name__. Must be unique within a Platools instance - duplicates raise ValueError at registration time.

@platools.tool(name="refund.create")
def process_refund(order_id: str, reason: str) -> RefundResult: ...

description: str | None

The description surfaced to the agent. Defaults to the function’s docstring (short + long descriptions joined, via docstring_parser). Explicit descriptions override the docstring.

auth: AuthLevel

One of "none", "user", "admin". Enforced by the platform before your function is invoked - the SDK only records the declaration. See PRD §5.1.

LevelMeaning
"none"Any caller. Reserved for idempotent, read-only, low-risk tools.
"user"Requires an authenticated end user. The user’s JWT is passed through.
"admin"Requires a platform-admin role. Combine with roles=["..."] for finer gating.

Invalid values raise ValueError("auth must be one of ['admin', 'none', 'user'], got ...") immediately at import time - broken auth is never silently shipped.

roles: list[str] | tuple[str, ...] | None

Allowlist of role names the caller must have. Stored on the ToolDef and enforced server-side. Doctor warns when an auth="admin" tool has an empty roles list.

@platools.tool(auth="admin", roles=["billing", "finance"])
def freeze_account(account_id: str, reason: str) -> None: ...

rate_limit: str | None

Human-readable rate limit hint, e.g. "10/min", "100/hour". Parsed and enforced by the platform; the SDK treats it as an opaque string and passes it through on registration.

timeout: int | None

Max execution time in seconds. Must be positive or None. The platform enforces this across the WebSocket - a tool that runs past timeout gets an error back to the caller.

annotations: dict[str, Any] | None

Free-form metadata attached to the tool schema. Use this for doctor-readable hints like:

@platools.tool(
auth="admin",
roles=["support"],
annotations={"destructive": True, "idempotent": False},
)
def delete_customer(customer_id: str) -> None: ...

Doctor’s check_destructive_annotations rule looks for destructive: true tools without a confirming role or a “confirmation required” field - it will warn if the annotation is missing on a tool whose verb looks destructive (delete_*, freeze_*, cancel_*, etc.).

Typing requirements

Every parameter (except self / cls) must have a type hint. build_input_schema raises SchemaError at decoration time if any parameter is untyped:

@platools.tool()
def broken(order_id, reason: str) -> None: # SchemaError on load
...

*args and **kwargs are explicitly unsupported - tool signatures must be fully named so the JSON Schema can declare properties and required.

Supported annotation shapes

Everything Pydantic handles, you get for free:

  • Literal["a", "b"], int | None, Union[...], Optional[...]
  • list[T], dict[K, V], tuple[T, ...]
  • Nested BaseModel classes
  • Annotated[T, Field(description=..., ge=..., le=...)] for inline constraints
  • pydantic.StrictInt, StrictStr, etc.

Docstring Args: descriptions are merged into the generated schema’s properties[*].description automatically - you don’t have to duplicate them in Field(description=...).

Sync or async

Both are supported. The transport layer runs sync tools via asyncio.to_thread() so they don’t block the event loop:

@platools.tool()
def sync_tool(x: int) -> int:
return x * 2
@platools.tool()
async def async_tool(x: int) -> int:
await asyncio.sleep(0.01)
return x * 2

The ToolDef.is_async flag is set automatically via inspect.iscoroutinefunction(func).

Next steps

  • Schemas - how the decorator converts type hints to JSON Schema.
  • Client - connecting decorated tools to the platform.
  • CLI - validating your tools with platools doctor.