MCP tools¶
The mcp extra (pip install warpweft[mcp]) exposes a component's invocables as Model Context
Protocol tools. It is a projection, not a new
component type: a tool is an @invocable method that you additionally mark
with @tool. Every tool call is routed through container.invoke, so it runs
the component's full policy chain (retry, cache, breaker) and telemetry for
free. @tool lives in this package, so the core never learns about MCP.
Marking a tool¶
from warpweft import AComponent, invocable
from warpweft.mcp import tool
class Weather(AComponent[WeatherSettings, str, Forecast]):
@tool(description="Get the forecast for a city.", read_only=True)
@invocable
async def forecast(self, city: str) -> Forecast: ...
@invocable # no @tool -> never exposed to an LLM
async def _refresh(self) -> None: ...
Exposure is opt-in: only @tool-marked invocables become tools, so health
checks and internal helpers stay private. @tool accepts a name/title
override, a description (else the method docstring is used), the MCP
annotation hints read_only, destructive, idempotent, open_world,
free-form tags used to filter which tools a server exposes, and
background=True for long-running tools. A
@tool on a method that is not @invocable is rejected when the server is
built.
An action is marked automatically - its execute is a tool by
default (opt out with entrypoint = False), from class-level metadata - so you
rarely write @tool yourself.
Serving¶
run_stdio starts the app, serves its tools over stdio, and stops the app when
the stream closes. For programmatic use (or a future HTTP transport),
build_server(app) returns a transport-agnostic MCP server.
Filtering¶
One app can back several servers with different tool sets - a read-only server
for an assistant, the full set for an operator console. collect_tools,
build_server and run_stdio take three narrowing arguments, applied in
order:
build_server(app, tags={"public"}) # any-match on @tool tags
build_server(app, include={"search__*"}) # only these names (fnmatch globs)
build_server(app, exclude={"billing__refund"}) # drop these names; wins over include
A tool with no tags never passes a tags filter, so tagging works as a
whitelist: a forgotten tag keeps a tool private rather than exposing it. The
filter is validated strictly - a tag no tool declares, a pattern that matches
no tool, or a filter that leaves nothing to expose raises a FrameworkError
instead of quietly serving the wrong set.
The tool contract¶
Per tool, derived from the invocable's descriptor:
- name -
component__method(MCP names disallow.) or thenameoverride. - description - the
@tooldescription, else the method docstring. - inputSchema - the method's input JSON Schema, with read-only/computed
fields dropped (a model does not fill those in). Parameters annotated with a
field format (
warpweft.formats, e.g.host: Ipv4) carry theformatkeyword, and the arguments an LLM supplies are validated against the input model before the call - an invalid value comes back as a tool error. - outputSchema - the return type's JSON Schema. MCP requires an object
schema, so a non-object return (
str,list[Doc], ...) is advertised wrapped:{"result": <schema>}. Every tool has an output schema. - annotations - the hint flags above.
Results¶
A tool call returns the invocable's Outcome, serialized against the output
schema in JSON mode - so SecretStr fields are masked and enums/dates become
primitives. Every call returns structuredContent conforming to the
advertised schema - a non-object value arrives as {"result": ...} - plus a
text content block that stays the raw serialization (a str result reads
as plain text, not JSON). The Outcome's source and degraded are
reported in the result meta.
Errors¶
Any failure - framework or user code - becomes a tool error (isError=true),
never a transport-level failure. The error tells the model what to do next: a
one-sentence hint is appended to the message, and the result meta carries
machine-readable guidance:
| meta key | Meaning |
|---|---|
warpweft.error |
a stable code: invalid_arguments, circuit_open, timeout, retry_exhausted, unavailable, transient, permanent, error - plus declined / confirmation_unsupported from the destructive-tool gate, and unknown_task / not_ready / cancelled / failed from the background task tools (not_ready is the only retryable one) |
warpweft.retryable |
whether calling again can help |
warpweft.retry_after_s |
for circuit_open: seconds until the breaker admits a probe |
warpweft.attempts |
for retry_exhausted: attempts already spent |
The codes mirror the error taxonomy: transient failures
(timeouts, an open breaker, a degraded component) are retryable, permanent
ones are not, and an unclassified exception is reported as non-retryable
error - the same stance the retry link takes.
Progress and cancellation¶
A long-running invocable reports progress without knowing what transport drives it:
from warpweft import report_progress
class Indexer(AComponent[IndexerSettings, str, Report]):
@tool(description="Rebuild the search index.")
@invocable
async def rebuild(self) -> Report:
for step, shard in enumerate(self._shards, start=1):
await self._index(shard)
await report_progress(step, total=len(self._shards), message=shard)
...
report_progress is a no-op unless the transport installed a sink
(warpweft.core.context.use_progress_sink). The MCP server installs one per
call, so reports become notifications/progress exactly when the client sent
a progressToken. Reports are fire-and-forget - a failed notification never
fails the call.
Cancellation needs no code at all: when the client cancels an MCP request,
the SDK cancels the handler's anyio scope, the cancellation unwinds the
policy chain, and the component's cleanup (finally blocks, context
managers) runs as usual.
Background (long-running) tools¶
A tool that runs for minutes should not hold its tools/call open that long -
many clients and proxies time out. Mark it background=True and it becomes a
non-blocking submit: the call returns a task_id at once and the work runs
in the background. The caller then polls with the shared built-in tools.
class Reports(AComponent[ReportSettings, str, Report]):
@tool(background=True, description="Generate a monthly report.")
@invocable
async def report(self, month: str) -> Report:
await report_progress(0.5, message="crunching")
return Report(...)
When at least one background tool is served, three shared tools appear:
task_status(task_id)-working/completed/failed/cancelled, plus the latestreport_progressmessage.task_result(task_id)- the finished result, or anot_readytool error while it is still running. The payload is the origin op's return value, fully typed and secret-masked, wrapped as{"result": ...}. ItsoutputSchemais theoneOfunion of every background op's result schema, so a model still knows the shapes to expect.task_cancel(task_id)- requests cancellation; the job's policy chain unwinds and the task settles tocancelled.
This is all plain tools/call returning CallToolResult, so it needs no
special transport - it works over stdio today. Everything else about a tool
still applies: arguments are validated before submit, report_progress becomes
task status (not client notifications, since the request already returned), and
confirm_destructive gates the submit so a task never starts without consent.
run_stdio opens an in-memory task runner by default (background=False serves
inline-only). For programmatic use, pass your own store and runner:
from warpweft.mcp import build_server, task_runner
async with task_runner(store=my_store) as runner: # your store survives restarts
server = build_server(app, runner=runner)
...
The names task_status / task_result / task_cancel are reserved; a
component tool claiming one, or a background=True tool served without a
runner, raises a FrameworkError at build time. The default in-memory store
keeps task state and results for the process lifetime only - a deployment that
must survive a restart supplies its own TaskStore (Redis, Postgres, ...).
Per-request axes (tenancy)¶
A scoped component slices its state along axes
(a tenant, a region, ...). Over MCP those axis values have to come from the
call. build_server/run_stdio take axis_binders - a map from an
AxisHandle (returned by app.axis(...)) to a function that derives its value
from each call:
tenant = app.axis("tenant") # or app.axis("tenant", default="public")
server = build_server(
app,
axis_binders={tenant: lambda ctx, params: ctx.headers.get("x-tenant")},
)
The value is bound (via the axis's contextvar) around the call, so a scoped
component resolves the right slice; returning None leaves the axis to its
default (or, for a required axis, fails the call). The binder receives the
request context ctx (session, headers, _meta) and the call params (tool
name and arguments), so tenancy can come from a header, the auth context, or a
tool argument. A background submit binds the axis across runner.start, so the
background job inherits the submit-time value.
A binder that raises becomes a tool error, never a transport failure.
Without axis_binders, an axis value must instead be bound by the host around
the call - e.g. ASGI middleware that reads the tenant and enters
tenant.use(...) before dispatching to the MCP handler. axis_binders just
moves that wiring into the server so stdio and non-ASGI hosts get it too.
Confirming destructive tools¶
puts a human in the loop for every tool marked destructive=True: before
executing, the server sends an MCP elicitation that the client presents to
the user. Only an explicit accept runs the tool - decline or cancel returns a
tool error (warpweft.error: "declined") without executing anything. The
gate fails closed: a client that does not support elicitation gets
confirmation_unsupported instead of an unconfirmed execution. Confirmation
happens after argument validation and never applies to non-destructive
tools.
Off by default - the destructive annotation alone only hints to the host
UI; this flag turns it into an enforced contract.