MCP#
MCP mounts streamable HTTP on a Wreath application or runs over stdio. Tools, resources and prompts share the framework's binding, identity, authorization, recording and progress boundaries.
wreath.mcp#
A first-party Model Context Protocol server, over the routes you already have.
An MCP server is a small protocol wrapped around several hard problems: a schema for every callable a model may invoke, a session that survives across calls, cancellation that actually stops work, an audit trail of what was called with what, and an authorization boundary in front of all of it. Wreath owns every one of those already -- the binding layer computes the schemas, the Flight Recorder keeps the record, Cedar decides -- so serving MCP here is wiring rather than a new stack.
Declare a server, decorate the callables a model should be able to reach, and the endpoint is an ordinary route:
from dataclasses import dataclass
from typing import Annotated
from wreath import Wreath
from wreath.binding import Body
from wreath.mcp import MCP, ToolError
app = Wreath()
mcp = MCP(app, name="camera-trap", version="1.0.0", path="/mcp")
@dataclass
class SightingQuery:
species: str
since: str | None = None
@mcp.tool(description="Find recent sightings of a species.")
async def find_sightings(request, query: Annotated[SightingQuery, Body()]) -> dict:
if query.species not in KNOWN_SPECIES:
raise ToolError(f"no camera is trained on {query.species!r}")
return {"sightings": [...]}The annotations that would bind an HTTP request body are the tool's inputSchema, rendered by the same code that renders the OpenAPI document. There is no second declaration syntax, no second validator, and a test asserts that the two renderings agree so they cannot drift apart later.
Point it at real work and it needs a boundary. Pass auth=MCPAuth(...) and the endpoint becomes an OAuth 2.1 resource server: it publishes /.well-known/oauth-protected-resource, answers a request with no token with a 401 naming that document, and refuses a token minted for a different resource -- the confused-deputy failure the specification exists to prevent. Gate individual tools with @mcp.tool(action=...), which is the same Cedar decision @authorize makes on a route, and bound them with rate_limit=ToolRateLimit(...). Every call leaves one Flight Recorder marker carrying the tool, the caller, the duration and the outcome, with the arguments beside it under the recording policy that already hides a password.
More than tools. @mcp.resource(uri, ...) declares something a model reads rather than does, and a client may resources/subscribe to it: open the GET stream and mcp.notify_resource_updated(uri) tells every subscriber it changed. @mcp.prompt(...) declares text a person chooses -- a slash command, a menu entry -- whose arguments are a flat map of strings, refused at registration if they are annotated as anything else. A long-running tool reports through request.state.mcp.progress, which is an ordinary wreath.progress.ProgressReporter: the server relays what it writes as notifications/progress, so there is no MCP-specific progress mechanism to learn, and a ProgressRegistry given the message bus carries a durable job's progress across workers to whichever one holds the stream.
Routes you already have. expose_routes(mcp, app, tags=("public",)) turns selected routes into tools. It takes an explicit selector and has no all=True: converting an application's whole HTTP surface into model-callable actions in one line includes the destructive half, and nobody reviews a line that short. A route with no docstring is refused, because the description is the entire basis on which a model decides to call it, and an exposed route keeps whatever @authorize, @roles, @permissions or @second_factor it was behind -- exposing a route is never a way around what was put in front of it. The last of those is the interesting one: a route behind @second_factor(max_age=300) is a tool the model may call only while the person has re-proved a factor recently, which is how "the model may read, and the human must step up before the model may delete" is written down. A tool that was never a route says the same thing with @mcp.tool(second_factor=300), so writing it does not require inventing a route to hang it on.
Asking the client something. Three MCP methods travel the other way, and a tool reaches all three through request.state.mcp. sample(...) asks the client's model to generate -- declared per tool with @mcp.tool(sampling=...), because whether a tool may spend the caller's model is a policy question, and gated, throttled and recorded exactly as the call itself is. elicit(message, Form) asks the person to fill a dataclass in: its fields are the requested schema, through the same derivation a tool's inputSchema comes from, and the answer is validated by the same binding layer and recorded under the same redaction. It is declared and gated the same way, with @mcp.tool(elicitation=...), because a form renders inside a client UI the person already trusts -- a tool asking for an API key is a phishing surface wearing that chrome, and "they can decline" is the control social engineering is built to walk through. read_file(path) reads beneath MCP(file_root=...) through wreath._fsguard, and beneath whatever roots the client declared -- a declared root is a boundary here, not a decoration. Each is a request the client must answer; a client that never does gets a bounded wait and then a ClientRequestError, never a session parked forever.
Locally, over stdio. wreath mcp stdio app:app puts the same ASGI application behind a stdin/stdout pipe for an editor that speaks only that transport. It is a byte relay over the in-process transport, not a second server: the routing, the authentication, the limits and the record are the ones the HTTP endpoint has.
What is not here. logging/setLevel, stream resumption by Last-Event-ID, templated resources and listChanged notifications. Each answers method not found and says which stage it is waiting for, rather than failing in a way a client author has to reverse-engineer.
Reference: MCP. Guide: Serving MCP tools.
MCP#
A Model Context Protocol server mounted on a Wreath application.
Declare it once, decorate the callables you want a model to be able to invoke, and the endpoint is a route:
app = Wreath() mcp = MCP(app, name="camera-trap", version="1.0.0")
@mcp.tool(description="Find recent sightings of a species.") async def find_sightings(request, query: Annotated[SightingQuery, Body()]) -> dict: ...
There is no second declaration syntax and no second validator. A tool's inputSchema is derived from the same annotations that would bind an HTTP request body, by the same code that renders the OpenAPI document, so the schema a model reads and the validation a call meets cannot disagree.
Pass auth=MCPAuth(...) to run it as an OAuth 2.1 resource server: the endpoint then publishes protected-resource metadata, refuses a request with no bearer token by pointing at that metadata, and refuses a token minted for a different resource -- which is the failure the specification exists to prevent. Without it the endpoint is exactly as protected as the route is, which for a bare Wreath() means not at all.
Args:
app— The application (orRouter) to mount on. Pass None to mount later- with
mount(). name— The server name reported ininitialize.version— The server version reported ininitialize.path— The single endpoint path. POST carries every client message;- DELETE ends a session.
instructions— Optional guidance returned frominitialize, describing- how a model should use this server as a whole.
auth— The OAuth 2.1 resource-server configuration, or None to install no- authentication of this server's own.
limits— The bounds this server runs inside. Payload size is deliberatelynot among them— it isRequestLimits.max_body_byteson the app,- where every other request's size is already decided.
authorizer— The policy authorizer for Cedar-gated tools. Defaults to the- one
app.configure_auth(...)installed, which is what you want - unless this server is mounted on a bare
Router. progress— The registry a running tool reports progress to. AProgressRegistry(bus)makes it fleet-wide.progress_interval— How often the relay polls that registry, in seconds.file_root— The one directoryrequest.state.mcp.read_file(...)may read- beneath, opened once as a trusted descriptor and walked component by
- component through
wreath._fsguard-- the same confinement static - files and the template loader use, refusing every symlink. Without
- it a tool cannot read a file at all, which is the right default for
- a surface a model drives. A client's declared
rootsnarrow it - further; they never widen it.
MCP(app: Any = None, *, name: str, version: str, path: str = '/mcp', instructions: str | None = None, auth: MCPAuth | None = None, limits: MCPLimits = MCPLimits(max_tools=256, max_sessions=1024, max_concurrent_calls=8, session_idle_seconds=900.0, max_resources=256, max_prompts=128, max_subscriptions=256, max_pending_notifications=64, stream_keepalive_seconds=15.0, max_pending_requests=4, client_request_seconds=30.0, max_file_bytes=1048576, max_result_bytes=1048576), authorizer: Any = None, progress: ProgressRegistry | None = None, progress_interval: float = 0.25, file_root: str | os.PathLike[str] | None = None) -> Noneauth (property)#
auth: MCPAuth | Nonecounters#
counters() -> AnyThis MCP server's counters, for wreath.metrics.collect.
declared_actions#
declared_actions() -> dict[str, tuple[str, ...]]Resource type -> the Cedar actions this server is gated on.
The same shape wreath.authorization.declared_actions returns for an application's routes, and derived the same way -- from what is enforced, so there is no second list to drift. Read it to see, in one place, every action a model can reach through this server: tools, resources, prompts, and whatever expose_routes carried in from a route's own @authorize.
executor#
executor(*names: str, max_tools: int = 32) -> Anyexpired_sessions (property)#
expired_sessions: intSessions collected for going idle since this server started.
limits (property)#
limits: MCPLimitsmetadata_path (property)#
metadata_path: strWhere this server's protected-resource metadata is served.
metadata_url (property)#
metadata_url: strThe absolute metadata URL a 401 challenge names, or "".
mount#
mount(app: Any) -> NoneRegister this server's routes on app, which may be a Router.
name (property)#
name: strnotify_resource_updated#
notify_resource_updated(uri: str) -> intTell every subscriber that uri changed. Returns how many were told.
Synchronous and non-blocking on purpose: the caller is the code that just wrote the row, and a fan-out that could block would put a client's reading speed on the write path. A session with no open stream keeps the notification queued up to MCPLimits.max_pending_notifications; past that it is dropped and counted in notifications_dropped.
path (property)#
path: strprogress (property)#
progress: ProgressRegistryThe registry a running tool reports progress to.
prompt#
prompt(handler: Callable[..., Any] | None = None, *, name: str | None = None, title: str | None = None, description: str | None = None, action: str | None = None) -> AnyDeclare a callable as a prompt. Usable bare or with arguments.
A prompt is chosen by a person -- a slash command, a menu entry -- rather than by the model, which is why it has arguments instead of a schema and why every one of them is a string. Return the text of one message, or a sequence of {"role": ..., "content": ...} mappings.
@mcp.prompt(description="Draft a report on a species' sightings.") async def sighting_report(request, species: str) -> str: return f"Summarise this month's {species} sightings."
Args:
name— The prompt name. Defaults to the function's name.title— A human-facing display name.description— What this prompt is for. Defaults to the docstring.action— The Cedar action rendering it is gated on, with the prompt's- own name as the resource.
Raises:
TypeError— The handler is not an async function.ValueError— There is no description, the name is taken, or the- server is at its
max_promptsceiling. ToolSignatureError— A parameter is annotated as something other than- a string, or binds from a source
prompts/getcannot fill.
prompts (property)#
prompts: tuple[Prompt, ...]Every declared prompt, in the order prompts/list renders them.
resource#
resource(uri: str, *, name: str | None = None, title: str | None = None, description: str | None = None, mime_type: str | None = None, action: str | None = None) -> AnyDeclare a reader as a resource, addressed by uri.
A resource is what a model reads rather than what it does, so the reader takes the request and nothing else -- a resources/read carries a URI and no arguments. Return text, bytes, or a value to render as JSON; bytes travel as a base64 blob and everything else as text.
@mcp.resource( "camera://ridge/latest", description="The most recent frame from the ridge camera.", mime_type="image/jpeg", ) async def ridge_latest(request) -> bytes: ...
Args:
uri— The identifier a client reads and subscribes to.name— A short programmatic name. Defaults to the function's.title— A human-facing display name.description— What this holds. Defaults to the docstring, and one of- the two must be present.
mime_type— The media type reads are served as. Inferred from the- reader's return value when omitted.
action— The Cedar action a read is gated on. The **URI is the Cedarresource**— a resource has a stable identity, unlike a tool, so- there is no second identifier to pass and nothing for the two to
- disagree about.
Raises:
TypeError— The reader is not an async function, or takes arguments.ValueError— The URI is empty or taken, there is no description, or- the server is at its
max_resourcesceiling.
resources (property)#
resources: tuple[Resource, ...]Every declared resource, in the order resources/list renders them.
sessions (property)#
sessions: intLive sessions right now, expired ones already collected.
stats#
stats() -> dict[str, int]Every counter this server keeps, by name.
The same shape messaging.MessageBus.stats() returns, and for the same reason: read one attribute at a time, an exporter has to know each name and gains nothing when one is added. counters() layers this mapping onto the canonical metrics protocol; mounting on a Wreath application registers it for Prometheus, OpenMetrics, StatsD and CloudWatch.
tool#
tool(handler: Callable[..., Any] | None = None, *, name: str | None = None, title: str | None = None, description: str | None = None, action: str | None = None, resource: object | Callable[[Any], object] = None, rate_limit: ToolRateLimit | None = None, second_factor: float | None = None, sampling: str | bool | None = None, elicitation: str | bool | None = None) -> AnyDeclare a callable as a tool. Usable bare or with arguments.
The handler's first parameter is the Request, exactly as a route handler's is; every later parameter is bound from the call's arguments object using its annotation. Reach for Annotated[Model, Body()] when a tool takes a structured argument, and plain annotated parameters for scalars.
Args:
name— The tool name. Defaults to the function's name.title— A human-facing display name.description— What the tool does. Defaults to the docstring, and one- of the two must be present.
action— The Cedar action this tool is gated on, conventionally- `
Type— :verb`. Every call is then a policy decision made by the - same authorizer, against the same entity shapes, as a route
- carrying
@authorize(action=...)-- and a caller the policy - refuses counts in
unauthorized_calls, nevertool_errors. resource— The Cedar resource, or a callable over theRequestthat- produces one. Only meaningful with
action=. rate_limit— AToolRateLimitbounding how often one caller may- invoke this tool. Keyed on the verified subject when the
- endpoint carries
MCPAuth, and on the session otherwise. second_factor— Seconds within which the caller must have proved a- second factor for this tool to run -- step-up, declared the way
@second_factor(max_age=...)declares it on a route, and- enforced by the same check against the same
second_factor_atstampwreath.userswrites. Implies- authentication. This is the natural way to say "this tool
- deletes things, so ask for the code again"; before it existed,
- step-up reached a tool only by exposing a route that already
- carried it. An identity with no stamp -- a bearer token, an
- OIDC login -- never satisfies it, so the default is closed.
sampling— Whether this tool may callrequest.state.mcp.sample(...), which asks the client's model- to generate. Pass the Cedar action a caller must be allowed for
- it -- which is where this belongs, because "may this tool spend
- the caller's model on text of its own choosing" is a policy
- question and not a code one -- or
Trueto declare it with no - policy. A tool that declares neither may not sample at all, and
- a sampling request also spends a token from this tool's own
rate_limit=bucket.elicitation— Whether this tool may callrequest.state.mcp.elicit(...), which puts a form in front of- the person at the other end. Declared, gated, throttled and
- recorded exactly as
sampling=is, and off by default for a sharper reason— an elicitation renders **inside a client UI the- user already trusts**, so a tool that asks for
- `
{"api_key"— str}` is a phishing surface wearing the client's - chrome. "The user is asked and can decline" is not the control
- it looks like -- a consent dialog is the defence social
- engineering is built to defeat -- so which tools may prompt a
- human is a deployment's decision. Pass the Cedar action, or
Trueto declare it with no policy.
Raises:
TypeError— The handler is not an async function.ValueError— The tool has no description, the name is taken, the- server is at its
max_toolsceiling,resource=was given - without
action=, orsecond_factor=is not positive. ToolSignatureError— The signature binds from a source that an MCP- call cannot supply -- a header, a cookie, a form field, an
- upload, a path placeholder, or a dependency.
tools (property)#
tools: tuple[Tool, ...]Every declared tool, in the order tools/list renders them.
version (property)#
version: strClientRequestError#
A server-to-client request could not be sent, or was not answered.
Raised inside the tool that asked, so it is caught with an ordinary try/except and answered with a ToolError the model can read, or left to the boundary -- where it counts in tool_errors and the model is told only the type, like any other unplanned failure.
The message always names which of the four endings happened: the client never advertised the capability, the session already has as many questions outstanding as it may, the client answered with an error, or nobody answered at all.
JsonRpcError#
A transport- or protocol-level failure, rendered as a JSON-RPC error.
This is deliberately not how a tool reports that it could not do its job: a model needs to see that failure as a result it can reason about, which is what wreath.mcp.ToolError produces. Reserve this for envelopes the server could not honour at all.
JsonRpcError(code: int, message: str, data: Any = None) -> NoneMCPAuth#
What an MCP endpoint publishes about itself, and what it will accept.
Attributes: resource: This endpoint's canonical identifier -- the absolute URL a client asks the authorization server for a token for, and the value it must find in the token's aud. It should be the public URL of the MCP endpoint itself: https://api.example.com/mcp. authorization_servers: Issuer identifiers this endpoint trusts. Purely advisory: they tell a client where to go, and change nothing about what is accepted here, which is decided by verifier and audience. verifier: The token verifier -- a JwtVerifier, an OidcProvider's bearer_verifier(), or any callable taking the compact token and returning an Identity or None. Without one, every request is refused: an endpoint that publishes metadata and then accepts anything is worse than one with no metadata at all. audience: The value a token's aud must contain. Defaults to resource, which is what RFC 8707 asks an authorization server to put there; set it only when the deployment's tokens name the resource by some other identifier. scopes_supported: Scopes a client may usefully request. Advertised only. resource_name: A human-readable name for the resource, for a consent screen that is deciding what to say about it. resource_documentation: A URL a developer can read.
Raises:
ValueError—resourceis empty, or no authorization server is named.
MCPAuth(resource: str, authorization_servers: Sequence[str] = (), verifier: Verifier | None = None, audience: str | None = None, scopes_supported: Sequence[str] = (), resource_name: str | None = None, resource_documentation: str | None = None) -> Noneauthenticate#
async authenticate(request: Request) -> IdentityVerify the request's bearer token and bind it to this resource.
Raises:
Unauthenticated— No token, an unverifiable one, or one minted for a- different resource.
challenge#
challenge(metadata_url: str, *, error: str | None, description: str | None) -> bytesThe WWW-Authenticate value for a 401 from this endpoint.
Always carries resource_metadata=<metadata_url> (RFC 9728 §5.3), which is how a client that has never seen this server discovers which authorization server to get a token from — a 401 without it says a token is needed and nothing about where to obtain one.
Args:
metadata_url— The absolute URL of this endpoint's- protected-resource metadata document.
error— The RFC 6750 error code, orNone.Noneis meaningfulrather than lazy— a request that carried no credentials at all- gets a bare challenge, because there is no token for an error
- code to describe.
description— Human-readable detail, orNoneto omit it.
document#
document() -> dict[str, Any]The RFC 9728 protected-resource metadata for this endpoint.
expected_audience (property)#
expected_audience: strThe value a token's aud must contain to be accepted here.
audience when one was set, and resource otherwise — which is what RFC 8707 asks an authorization server to put there. This is the check that makes a token minted for another resource useless at this one, so it is never empty: resource is required at construction.
MCPLimits#
Bounds for one MCP server.
Attributes: max_tools: Tools one server may declare. Registration past it raises, at import time rather than at call time, because a tools/list a model cannot hold in its context is a design problem and not a runtime one. max_sessions: Concurrently live sessions. initialize is the one method that can be reached without a session, so before authentication is configured every POST that lands can mint one; a dictionary with no ceiling is then a memory leak with a network interface. max_concurrent_calls: Calls one session may have in flight at once. The bound is per session rather than per server so that one busy client cannot starve another, and it is small because a model that fans out is usually looping rather than working. session_idle_seconds: How long a session may go unused before it is collected. A client that never sends DELETE is the normal case, not the exception -- a process crashes, a laptop sleeps, a model stops mid-conversation -- so without this every abandoned session is held until restart and max_sessions is reached by attrition. Set it to None to keep sessions until they are ended explicitly. max_resources: Resources one server may declare, for the same reason max_tools exists: resources/list is text a model reads. max_prompts: Prompts one server may declare. max_subscriptions: URIs one session may hold a resources/subscribe on. A subscription costs a set entry and a fan-out visit per change, and a client is free to subscribe to every resource it can list, so the ceiling is what keeps that from being unbounded per session. max_pending_notifications: Notifications one session may hold while its server-to-client stream is closed or slow. The queue is bounded because a client that opens no stream must not be able to make the server buffer for it forever; past the ceiling the newest notification is dropped and counted in MCP.stats(), because a dropped notification that nobody counts is the silent degradation this codebase keeps finding. stream_keepalive_seconds: How long the notification stream may stay silent before it emits an SSE comment. Nothing in SSEResponse is emitted on a timer, deliberately, so an idle stream is invisible to an intermediary that reaps idle connections unless the application says something -- this is the application saying it. max_pending_requests: Server-to-client requests one session may have outstanding at once -- a sampling/createMessage, an elicitation/create, a roots/list. Each one holds a future and an entry in a table until the client answers it, so an unbounded table is a memory leak a client can drive simply by asking for work and never replying. Small on purpose: a tool that has three questions outstanding for one user is not waiting on a person any more. client_request_seconds: How long to wait for the client's answer to one of those requests. A client that never answers must not be able to pin a session's call slot for the session's whole idle life, which is the deadlock this number exists to make impossible. max_file_bytes: The largest file ToolContext.read_file will return. A resource read is a JSON-RPC result held whole in memory and then base64-encoded, so the ceiling is on what one answer may cost rather than on what the filesystem happens to hold. max_result_bytes: Largest serialized tool or resource result.
Raises:
ValueError— A bound is not positive.
MCPLimits(max_tools: int = 256, max_sessions: int = 1024, max_concurrent_calls: int = 8, session_idle_seconds: float | None = 900.0, max_resources: int = 256, max_prompts: int = 128, max_subscriptions: int = 256, max_pending_notifications: int = 64, stream_keepalive_seconds: float = 15.0, max_pending_requests: int = 4, client_request_seconds: float = 30.0, max_file_bytes: int = 1048576, max_result_bytes: int = 1048576) -> NonePrompt#
One declared prompt: what prompts/list renders and prompts/get fills.
Attributes: name: The name a client offers to the person choosing it. description: What this prompt is for. Never empty. handler: The async callable, invoked as handler(request, **arguments). arguments: The prompts/list argument descriptors, derived once. title: An optional human-facing display name. binding_spec: The compiled signature, or None for a prompt that takes only the request. requirement: What a caller must satisfy to render it. completions: Argument name -> the values completion/complete offers for it, read off the rendered schema of an argument annotated as a Literal or an Enum. Derived rather than declared: a second place to write the candidate values down is a second place for them to go stale.
Prompt(name: str, description: str, handler: Callable[..., Any], arguments: tuple[dict[str, Any], ...] = (), title: str | None = None, binding_spec: BindingSpec | None = None, requirement: AuthRequirement = AuthRequirement(authenticated=False, identify=False, role_checks=(), permission_checks=(), policies=(), second_factor=None, oauth_step_up=None, public=False), completions: Mapping[str, tuple[str, ...]] = mappingproxy({})) -> Nonebind#
bind(arguments: Mapping[str, Any]) -> dict[str, Any]Validate arguments and return the handler's kwargs.
Raises:
ValidationError— One or more arguments are missing, unknown, or of- the wrong shape.
manifest#
manifest() -> dict[str, Any]The prompts/list entry for this prompt.
Resource#
One declared resource: what resources/list renders and read serves.
Attributes: uri: The identifier a client reads, subscribes to, and sees again on every notification about it. Unique within one MCP. name: A short programmatic name. description: What this resource holds, and when a model should read it. Never empty, for the same reason a tool's is not. handler: The async callable, invoked as handler(request). title: An optional human-facing display name. mime_type: The media type reads are served as, or None to infer it from what the reader returned. requirement: What a caller must satisfy to read it -- the same AuthRequirement a tool or a route carries, decided by the same authorizer.
Resource(uri: str, name: str, description: str, handler: Callable[..., Any], title: str | None = None, mime_type: str | None = None, requirement: AuthRequirement = AuthRequirement(authenticated=False, identify=False, role_checks=(), permission_checks=(), policies=(), second_factor=None, oauth_step_up=None, public=False)) -> Nonemanifest#
manifest() -> dict[str, Any]The resources/list entry for this resource.
render#
render(value: Any, *, _encoded: bytes | None = None) -> dict[str, Any]One contents entry for whatever the reader returned.
bytes travel as a base64 blob and everything else as text, because those are the two shapes the specification defines and a client chooses between them by which key is present, not by the media type.
Tool#
One declared tool: what tools/list renders and tools/call invokes.
A tool is not a route, but its handler obeys the route contract -- the first parameter is the Request, every later parameter is bound by name -- so the same signature works in both places and the schema derivation is shared.
Attributes: name: The name a client calls. Unique within one MCP. description: What the tool does, and when a model should reach for it. Never empty: a tool without a description is a tool that gets misused, and registration refuses one. handler: The async callable, invoked as handler(request, **arguments). input_schema: JSON Schema for the arguments object. title: An optional human-facing display name. binding_spec: The compiled signature, or None for a handler that takes only the request. requirement: What a caller must satisfy to invoke it -- exactly the AuthRequirement a route carries, so the authorizer cannot tell an MCP call from an HTTP one and expose_routes can hand a route's own requirement straight through rather than translating it into something weaker. NO_REQUIREMENT for a tool anyone who reached the endpoint may call. rate_limit: The declared per-caller ceiling, or None. limiter: The token bucket enforcing rate_limit. One per tool, because two tools sharing a bucket would spend each other's allowance. route: The path of the route this tool was derived from, or None for a tool declared with @mcp.tool. Carried so a refusal can name the route rather than only the tool it would have become. sampling_requirement: What a caller must satisfy for this tool to ask the client's model to generate, or None when the tool did not declare sampling= at all -- which is the default, and means it may not sample. Whether a given tool may spend the caller's model on text of its own choosing is exactly a policy question, so it is one more AuthRequirement decided by the same authorizer as everything else here; sampling=True declares the capability with no policy attached, which is the shape a development server wants. elicitation_requirement: The same, for putting a form in front of the person at the other end, and None by default for the same reason. An elicitation renders inside a client UI the user already trusts, so a tool that asks for {"api_key": str} is a phishing surface wearing the client's chrome; "the user can decline" is precisely the control social engineering defeats. Which tools may prompt a human is therefore a deployment's decision, decided by the same authorizer.
Tool(name: str, description: str, handler: Callable[..., Any], input_schema: dict[str, Any], title: str | None = None, binding_spec: BindingSpec | None = None, requirement: AuthRequirement = AuthRequirement(authenticated=False, identify=False, role_checks=(), permission_checks=(), policies=(), second_factor=None, oauth_step_up=None, public=False), rate_limit: ToolRateLimit | None = None, limiter: MemoryRateLimitStore | None = None, route: str | None = None, sampling_requirement: AuthRequirement | None = None, elicitation_requirement: AuthRequirement | None = None) -> Nonebind#
bind(arguments: Mapping[str, Any]) -> dict[str, Any]Validate arguments against the signature and return call kwargs.
Raises:
ValidationError— One or more arguments are missing, unknown, or of- the wrong shape.
manifest#
manifest() -> dict[str, Any]The tools/list entry for this tool.
ToolContext#
What a running tool can learn about the call it is serving.
Reachable from the handler as request.state.mcp, which keeps the ownership visible: it lives exactly as long as the request that carries it, and no global or context variable has to be consulted to find it.
Cancellation arrives as asyncio.CancelledError inside the handler, because the call runs in its own task and notifications/cancelled cancels that task. A tool that holds a resource should therefore release it in a finally:, exactly as it would anywhere else; there is no cancellation flag to poll.
It is also the way out of this call and back to the client: sample, elicit, roots and read_file are the three MCP methods that travel server-to-client, and they are methods here rather than free functions because each needs the session the call arrived on and the request it is being served under. The same task that makes cancellation work is what makes them possible: the client's answer arrives on a different POST, which the endpoint can only serve because this one is parked in a task of its own.
Attributes: session_id: The Mcp-Session-Id this call arrived on. request_id: The JSON-RPC id, and what a cancellation would name. tool: The tool's registered name. progress_token: The client's _meta.progressToken, when it sent one. Present so a tool can tell whether anyone is listening; reporting progress does not depend on it. progress: A wreath.progress.ProgressReporter bound to this call. Always present, whether or not the client asked for progress: reporter.update(42, "processing invoices") writes to the server's ProgressRegistry exactly as it would from a durable job, and when the client did send a progressToken the server relays each report as a notifications/progress on the session's stream. There is no MCP-specific progress mechanism, deliberately -- the registry already models a task reporting a percentage and a message, already spans workers when it is given the message bus, and already has a status endpoint and an SSE stream of its own. identity: The verified caller, whenever the server had to resolve one -- through MCPAuth on the endpoint, or through the application's own app.configure_auth(...) backend for a tool that is gated or rate limited. None for a tool that is neither, on an endpoint with no MCPAuth: nothing about that call depended on who was asking, so the backend was never run for it. Also published as request.identity, which is where the authorizer and every other Wreath component reads it; it is repeated here so a tool does not have to know which of the two is the real one. arguments: The call's raw arguments object, before binding. Present so a Cedar resource= resolver can name which row is being asked for: a route resolves its resource from the path, and a tool has no path to resolve it from. The bound, validated values arrive as the handler's own parameters; this is for the policy decision that has to happen first.
ToolContext(session_id: str, request_id: Any, tool: str, progress_token: Any = None, identity: Any = None, arguments: Mapping[str, Any] = mappingproxy({}), progress: ProgressReporter | None = None, _server: Any = None, _session: Any = None, _request: Any = None) -> Noneelicit#
async elicit(message: str, form: type[Any]) -> Any | NoneAsk the person at the other end to fill form in.
form is a dataclass, and its fields are the requested schema: they go through derive_input_schema, the same path that renders a tool's inputSchema, and the answer comes back through the same binding validation. There is no second schema language and no hand-written check, which is why a field the client fills in wrongly is refused with the same error shape a bad tools/call argument gets.
The tool doing the asking must have declared that it may: @mcp.tool(elicitation="Form::ask") gates it on Cedar exactly as action= gates the call itself, and elicitation=True declares it with no policy. A tool that declared neither cannot elicit at all, because a form renders inside a client UI the person already trusts and "they can decline" is the control social engineering exists to defeat. The request also spends a token from the tool's own rate_limit= bucket and leaves its own Flight Recorder marker beside the call's.
Returns:
- An instance of
form, or None when the person declined or cancelled.
Raises:
ClientRequestError— The client never advertised theelicitation- capability, the tool did not declare
elicitation=, the caller - was refused or throttled, nobody answered, or the answer did not
- match the schema that was asked for.
TypeError—formis not a dataclass, or has a field MCP cannot- carry -- the specification allows primitives only.
read_file#
async read_file(path: str) -> bytesRead a file beneath the server's file_root, and beneath the client's roots.
Two confinements, both real. The bytes are opened through wreath._fsguard -- component by component beneath a trusted directory descriptor, refusing every symlink, the same walk static files and the template loader use -- so nothing outside MCP(file_root=...) is reachable however the path is spelled. And when the client declared roots, the file must also lie beneath one of them: a root a client declared is a boundary it expects the server to honour, not a hint.
Raises:
FileNotFoundError— No such file beneath the root.PermissionError— The path escapes the root, traverses a symlink, is- not a regular file, is larger than
MCPLimits.max_file_bytes, - or lies outside every root the client declared.
RuntimeError— This server was declared with nofile_root=.
roots#
async roots() -> tuple[str, ...]The filesystem roots this client declared, as absolute paths.
Asked of the client once per session with roots/list and cached until it sends notifications/roots/list_changed. Empty when the client did not advertise the capability, which is the case read_file treats as "the server's own root is the only bound".
sample#
async sample(messages: Any, *, max_tokens: int = 1024, system_prompt: str | None = None, temperature: float | None = None, stop_sequences: Any = None, model_preferences: Any = None, include_context: str | None = None, metadata: Any = None) -> dict[str, Any]Ask the client's model to generate, and wait for what it produced.
sampling/createMessage is the one MCP method where the server is the one asking for work, and the tool doing the asking must have declared that it may: @mcp.tool(sampling="Model::sample") gates it on Cedar exactly as action= gates the call itself, and sampling=True declares it with no policy. A tool that declared neither cannot sample at all. The request also spends a token from the tool's own rate_limit= bucket, and leaves its own Flight Recorder marker beside the call's.
messages is a string -- the common case, one user turn -- or a list of {"role": ..., "content": ...} mappings already in MCP's shape.
Returns:
The client's result—role,content,modelandstopReason.
Raises:
ClientRequestError— The client never advertised thesampling- capability, the tool did not declare
sampling=, the caller was - refused or throttled, or nobody answered.
ToolError#
A tool could not do its job, reported to the model rather than the wire.
This is the difference that makes an MCP server usable. A JSON-RPC error tells the client that the call failed, which a model can only retry blindly; a ToolError comes back as an ordinary result carrying isError: true and your message, which a model can read and act on. Raise it for "no sighting matched that species", not for "the database is down".
raise ToolError("no camera covers that trail; try 'ridge' or 'creek'")
ToolRateLimit#
How often one caller may invoke one tool.
Declared per tool rather than per endpoint, because the tools on one server are not alike: listing yesterday's sightings is cheap and idempotent, and the one that sends an email is neither. A single ceiling across the endpoint would have to be set for the expensive tool and would then make the cheap one useless.
The bucket is keyed on the caller -- so one client cannot spend another's allowance. Declaring a ceiling is itself enough to resolve who the caller is: a bounded tool authenticates the request before it charges the bucket, through MCPAuth when the endpoint carries one and through the application's own app.configure_auth(...) backend when it does not. Only when neither identifies anybody does the key fall back to the session -- and a session is free, so on an endpoint with no authentication at all a per-session ceiling is a ceiling per initialize, which is to say none.
Attributes: limit: Calls allowed per window. window: The window, in seconds. burst: Calls allowed back to back before the sustained rate binds. Defaults to limit. A model that fans out a plan into five parallel calls is not abusing anything, and a burst of one would refuse it.
Raises:
ValueError— A bound is not positive. Aburstbelowlimitis allowed- and simply means the window's full allowance is never reachable in
- one go, which is a legitimate shape rather than a mistake.
ToolRateLimit(limit: int, window: float = 60.0, burst: int | None = None) -> Nonecapacity (property)#
capacity: floatrate (property)#
rate: floatTokens per second, the shape RateLimitStore.configure wants.
ToolSignatureError#
A handler's signature cannot be expressed as an MCP tool.
expose_routes#
expose_routes(mcp: Any, app: Any = None, *, tags: Iterable[str] = (), include: Iterable[str] = (), predicate: Callable[[Any], bool] | None = None, prefix: str = '', rate_limit: ToolRateLimit | None = None) -> tuple[Tool, ...]Declare selected existing routes as MCP tools.
Call it after the routes exist: an application is read here, not retained, so a route registered afterwards is not picked up.
expose_routes(mcp, app, tags=("sightings",))
A route is selected when it matches any selector given, and at least one selector must be given -- there is deliberately no way to say "all of them". A selector that matches nothing raises rather than exposing nothing quietly, because a tag that no longer exists is a typo far more often than it is an intention.
The tool's name is the route's operation_id when it has one and the handler's name otherwise, its description is the handler's docstring, and its inputSchema is derived from the signature by the same code that derives a declared tool's. One tool per route, not per method: a tool invokes the handler directly, so the HTTP verb the route answers on is not part of the call.
Args:
mcp— TheMCPserver to declare the tools on.app— The application orRouterto read routes from. Defaults to the- one
mcpis mounted on. tags— Expose every route carrying any of these tags.include— Expose every route whose path is named here, exactly.predicate— Expose every route this returns true for. Receives the- `
RouteDefinition, solambda route— "GET" in route.methods` is how - you keep the mutating half of a tag out.
prefix— Prepended to each derived tool name, for a server that exposes- two applications and would otherwise collide on
list_items. rate_limit— AToolRateLimitapplied to every tool this call declares.Route-derived tools are the ones most likely to want it— a route was- written for a person clicking, and a model does not click.
Returns:
- The tools that were declared, in the order the routes were registered.
Raises:
ValueError— No selector was given, no route matched, a selected route- has no docstring, a selected route carries route middleware or route
- dependencies (see
_check_carryable), or a derived name collides - with a declared tool.
ToolSignatureError— A selected route binds a parameter an MCP call- cannot supply. The message names the route and the parameter.
Start with Build an MCP server. The control-room story adds OAuth, recent human proof and resource updates.