MCP Tools
App.MCP serves a Model Context Protocol endpoint. An MCP
client, such as a desktop assistant, an IDE agent or a model behind your own orchestration,
connects to it, lists the tools it offers and calls them. Each tool is a route you already have, described from
the OpenAPI document and called through the whole application, so a model gets exactly the
operations you chose, under exactly the rules an HTTP client gets them under.
app := muzak.New(muzak.AppOptions{
Title: "Shop API",
Version: "2.1.0",
SecuritySchemes: map[string]muzak.SecurityScheme{
"oidc": muzak.JWTBearer(muzak.JWTOptions{
Issuers: []string{"https://login.example.com/"},
Audience: "https://api.example.com/mcp",
Algorithms: []string{"RS256"},
JWKS: muzak.JWKSOptions{URL: "https://login.example.com/.well-known/jwks.json"},
// Clients that know only the endpoint's URL discover the
// authorization server through this document.
ResourceMetadata: &muzak.ResourceMetadata{Resource: "https://api.example.com/mcp"},
}),
},
})
// The endpoint is a route like any other: its security, guards and rate limit
// apply to every message. Nothing is a tool until it is chosen.
app.MCP("/mcp", muzak.MCPOptions{
Tags: []string{"agent"},
Instructions: "Use get_order to look an order up before cancelling it.",
}, muzak.WithSecurity(muzak.Require("oidc")))
app.Include(routers.Orders(), muzak.WithPrefix("/v1"))
Choosing tools
Nothing is exposed by default. A route becomes a tool in any of three ways, and they add up:
| Chosen by | How |
|---|---|
| The route | muzak.MCPTool() among its options |
| A tag | MCPOptions.Tags names one of the route's tags, its own or a router's |
| A rule | MCPOptions.Include returns true for the *muzak.Route |
Include is called once per route when the application is built, and only for routes that
can be tools; it cannot take back a route a tag or MCPTool chose, so a policy with exceptions
is written in Include alone.
Some things are never tools, however eagerly they are chosen: WebSocket and event stream
routes, Hidden routes, the endpoint itself, a method OpenAPI has no place for, and everything
that is not a route at all, Mount, Static, Frontend, the documentation and the health
endpoints. MCPTool is a route option, so it cannot be given to Mount, Static or
Frontend at all: it does not compile there. On a route that cannot be a tool, a WebSocket or
event stream route, a Hidden one, the endpoint itself or one whose method OpenAPI has no
place for, it is a build error naming the route, rather than an option quietly ignored.
An application without an MCP endpoint ignores MCPTool on every route, so a router shared by
applications with and without one can declare its tools once.
A chosen route that a tool call cannot write a request for is a build error too, naming the
route and the reason. A tool call writes its request as
Endpoint.Call does, so whatever Call refuses is refused
here: a muzak.File field (declare it []byte or [][]byte), a header net/http writes
itself, and a route that answers with muzak.FileResponse. So is an input that binds a header
or cookie the endpoint forwards, such as Authorization, or a header a trusted proxy names the
client in, since its value comes from the client's own request and never from a model, and a
versioned route whose version is chosen by a custom extractor, which a call cannot satisfy.
Nor may an input bind any other place a credential is read from: the header, query parameter
or cookie of an API key scheme, whether the scheme verifies the key or only describes it, or
the cookie AppOptions.Sessions keeps the session in. Binding one is a build error naming the
route even when the endpoint does not forward it, so a model can never choose the credential a
call is made with, or present a key it read somewhere as its own.
What a tool looks like
{
"name": "get_order",
"title": "Get Order",
"description": "Fetch one order",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "object",
"properties": {"id": {"type": "string", "description": "The order's identifier"}},
"required": ["id"],
"additionalProperties": false
},
"query": {
"type": "object",
"properties": {"expand": {"type": "array", "items": {"type": "string"}}},
"additionalProperties": false
}
},
"required": ["path"],
"additionalProperties": false
},
"outputSchema": {"type": "object", "properties": {"id": {"type": "string"}, "total": {"type": "integer"}}, "required": ["id", "total"]},
"annotations": {"readOnlyHint": true}
}
| Member | Comes from |
|---|---|
name | The operation id, with every character other than an ASCII letter, a digit, _ or - replaced by _, cut to 64; two routes that end up with one name are a build error, so set muzak.OperationID to choose |
title | muzak.Title |
description | muzak.Summary and muzak.Description |
inputSchema | The operation's parameters and request body in the OpenAPI document |
outputSchema | The success response's JSON schema |
annotations | The method: GET, HEAD and OPTIONS are read-only, DELETE destructive and idempotent, PUT idempotent |
The input is grouped by where the route reads it: path, query, header and cookie hold
the parameters by the names a request uses, body holds the JSON body, and form holds the
fields of a form, files as standard base64. Grouping is what keeps a path parameter and a body
member of the same name apart, and it is the shape the request takes. Every schema is the
document's own, with the same required members and the same rules, and every reference is
resolved within the tool: inlined, or written once under $defs for a type that contains
itself.
Calling a tool
A call is the route's own request. The arguments are decoded into the route's input type by
the binder's own rules, defaults included, written as a request by the encoder
Endpoint.Call uses, and served in-process by the whole
application: middleware, the host allowlist, the security gate, guards, providers, the rate
limit, binding, validation and releases all run, and nothing is skipped because the caller is
a model.
Because the request is written by the rules Call writes one by, an argument Call would
refuse is refused here, as an error result. The one a model is likely to meet is a body member
with a default tag whose json tag carries omitzero or omitempty: set to its zero value,
it would be left out of the body and arrive as the default, so it is refused rather than
quietly replaced. Leaving the member out is how a call asks for the default.
The request a call writes is held to the smaller of two limits: the route's own body limit
(MaxBodySize, or MaxUploadSize for a route that takes files) and the endpoint's
MaxBodySize, which the arguments arrived under. Written out as a body, a few bytes of
arguments, such as a list of empty strings or empty objects, can be many times their size, so
arguments that would exceed the limit get an error result with status 413, and the route does
not run.
The request carries over from the MCP request only what has to:
| Carried | Why |
|---|---|
Authorization, and ForwardHeaders and ForwardCookies | The client's credentials, unchanged |
| The client's address, TLS state and the headers a trusted proxy names it in | Rate limits, client IP and the HTTPS redirect see the real client |
| The request identifier | One request_id in both access log lines and in the tool's error envelope |
| The trace | The call's span is a child of the MCP request's |
| Cancellation and deadline | A client that disconnects stops the call; no other value of the MCP request's context is |
User-Agent, Accept-Language | Unless the input binds them itself |
Nothing else is: no Origin, no session, no cookie the options do not name. A call reaches its
own route and no other. An argument that spells the path of another route, a static sibling of
a parameter or anything under a wildcard, is refused before the other route runs. Only routes
are refused beforehand: App.Use middleware and the documentation answer ahead of routing, so
when a wildcard argument spells a path one of them answers, that answer runs, and only then is
the result refused. A health endpoint's path cannot be spelled at all, since a route that
answers it is a build error.
The route's answer becomes the result:
| The route answered | The result |
|---|---|
| JSON | The JSON as text, and as structuredContent (an object before 2026-07-28, any value from it) |
| Text or HTML | Text |
| An image or audio | An image or audio item, in base64 |
| A redirect | The Location as text; it is not followed |
| No body | An empty content |
| Any status from 400 | isError: true with the error envelope as text |
Anything else, or more than MaxResultSize | isError: true saying why |
Only the status, the body, its Content-Type and a redirect's Location make the result. The
route's other response headers, Set-Cookie included, are not returned to the MCP client: a
session in the cookie store that a tool changes is not kept, and Retry-After and
WWW-Authenticate do not reach the client either.
A failure of the call, arguments that do not fit, a 401 from the route, a validation error, a
handler's panic, is a result with isError set, carrying the error envelope your error
renderer wrote for clients, so a model reads it and can try again. Only a malformed message,
an unknown tool or arguments that are not an object are JSON-RPC errors.
Authorization
Protect the endpoint with the same scheme as the routes. An unauthenticated client then gets
the 401 whose WWW-Authenticate names the resource_metadata document, which is how an MCP
client finds the authorization server and starts the OAuth flow:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"
Set Audience and ResourceMetadata.Resource to the endpoint's URL, so a token issued for it
is accepted here and nowhere else. The token reaches the routes of this application, unchanged,
and no other service: a route that calls another service presents that service its own
credential, never the client's.
Only the endpoint can challenge a client. A route's 401 or 403 becomes an error result, not an HTTP challenge, so a tool call never sends a client to sign in or to get a token with more scopes. Protect the endpoint itself with the scopes its tools need, so that a client is challenged for them when it connects:
app.MCP("/mcp", muzak.MCPOptions{Tags: []string{"agent"}},
muzak.WithSecurity(muzak.Require("oidc", "orders:read", "orders:write")))
An API key a call does not carry cannot authenticate it. A call's query string is written from
its arguments alone, and an input may not bind the key's parameter, so a key read from the
query string never reaches the route; nor does one read from a header or cookie that
ForwardHeaders or ForwardCookies does not name. A route that only such a key admits fails
closed: every call of it is an error result with status 401.
A session is bound to the principal that opened it: the issuer, subject and scheme of a
bearer token, or the identifier and scheme of an API key. A client presenting another
principal's session identifier is told it does not exist. A session opened without a
credential the endpoint verified is bound to nobody, so any principal may use it, and JWTs
without a sub from one issuer and scheme share an owner. Sessions grant nothing in any case:
every message is authenticated by the endpoint's security and every call by its route's.
Protocol support
| Revision | How it is served |
|---|---|
| 2026-07-28 | Stateless: every request carries its revision, no session, server/discover |
| 2025-11-25 | Session opened with initialize |
| 2025-06-18 | Session opened with initialize |
| 2025-03-26 | Session opened with initialize; no titles, structured content or output schemas, and no batches |
initialize answers the revision a client asks for when it is one of the session revisions,
and 2025-11-25 otherwise. A 2026-07-28 client may send any request first and is told the
revisions with -32022 if its own is not one of them.
| Request | Answer |
|---|---|
POST with a request | 200 and application/json |
POST with a notification or a response | 202, no body |
POST without a session a session revision needs | 400 |
POST or DELETE with an unknown or expired session | 404; the client opens another |
DELETE | 204, the session ends |
GET | 405; no stream from the server is offered |
An Origin that is not allowed | 403 |
A body that is not application/json, or an Accept without it | 415, 406 |
An MCP-Protocol-Version other than the session's | 400 |
An Mcp-Method, or on tools/call an Mcp-Name, that does not match the body or is sent twice | 400 with -32020; 2026-07-28 requires both, and a session revision, which may leave them out, is held to them when it sends them |
A body larger than the endpoint's MaxBodySize | 413 |
| A batch | 400 with -32600, for every revision |
Messages are read strictly: the JSON-RPC 2.0 envelope and nothing else, jsonrpc exactly
"2.0", an id that is a string or a number and never null, params that are an object, no
member twice, nested at most 128 deep, and no larger than the endpoint's MaxBodySize.
Browser clients and the Origin check
Only a browser sends Origin, and the check exists for one attack: DNS rebinding, where a
page served from the attacker's name is made to resolve to your server and sends that name as
Origin and Host alike. The endpoint's own origin therefore counts only when its Host is
one the application vouches for: listed in AppOptions.AllowedHosts, or localhost or an
address. Any other origin needs MCPOptions.AllowedOrigins or AllowOriginFunc, and "*" is
refused. A browser client also needs CORS, with Mcp-Session-Id among
the exposed headers:
muzak.AppOptions{
AllowedHosts: []string{"api.example.com"},
CORS: muzak.CORSOptions{
AllowedOrigins: []string{"https://inspector.example.com"},
AllowedHeaders: []string{"Content-Type", "Authorization", muzak.HeaderMCPSessionID, muzak.HeaderMCPProtocolVersion, muzak.HeaderMCPMethod, muzak.HeaderMCPName},
ExposedHeaders: []string{muzak.HeaderMCPSessionID},
},
}
A server run on a developer's machine binds to 127.0.0.1, not to every interface.
Bounds
| Option | Default | Bound |
|---|---|---|
MaxResultSize | 1 MiB | At most 64 MiB; a larger answer is an error result and stops the route |
The endpoint's MaxBodySize | The application's, 1 MiB unless set | Bounds each message, and the request a call writes, which is also held to the route's own MaxBodySize, or MaxUploadSize for a route that takes files; arguments that would exceed the smaller are an error result with status 413 |
PageSize | 100 tools | At most 1000 |
MaxSessions | 10,000 | At most 1,000,000; the session idle longest is evicted |
SessionIdleTimeout | 24 hours | |
ListTTL | 5 minutes | How long a 2026-07-28 client may cache the list |
Anyone the endpoint admits can open sessions, and there is no cap for each principal: a flood
of initialize evicts the sessions idle longest, whose clients are told 404 and initialize
again. Guard the endpoint or give it a rate limit, so that one client cannot push everyone
else's sessions out. In the same way, tools/list shows every tool to whoever the endpoint
admits, whether or not the tool's own route would admit them, and does so even with
DisableDocs.
tools/list cursors are opaque and authenticated, so a client can neither forge nor alter one,
and one from before a restart is refused. A 2026-07-28 listing is marked private to caches
when the endpoint is guarded. A tool call counts once against the endpoint's rate limit and
once against its route's, both for the same client, so a model calling a route through the
endpoint spends the same budget an HTTP client does; see
Rate Limiting.
Limits
- Batches are refused for every revision, 2025-03-26 included, which allowed them. A client sends each message in a request of its own.
- Sessions live in memory. A deployment of several instances serving the session revisions
(2025-03-26 to 2025-11-25) needs sticky routing, so that each client keeps reaching the
instance that opened its session; a client of another instance is told
404and opens a new one. 2026-07-28 clients are stateless and need nothing. - A route that binds
User-Agentto a non-pointer string cannot be called unless the model supplies a value: a call refuses to send an emptyUser-Agent. Bind it to a*stringwhen it is optional. - An optional header or cookie bound to a non-pointer string is sent empty when the model
leaves it out, as
Endpoint.Callsends it. Bind it to a pointer when an empty value and an absent one mean different things to the route.
Testing
func TestAgentCanReadAnOrder(t *testing.T) {
client := testclient.New(t, testApp(t), testclient.WithHeader("Authorization", "Bearer "+testToken(t)))
accept := testclient.Header("Accept", "application/json, text/event-stream")
res := client.Post("/mcp", accept, testclient.RawJSON(`{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`))
res.AssertStatus(http.StatusOK)
session := testclient.Header(muzak.HeaderMCPSessionID, res.Header.Get(muzak.HeaderMCPSessionID))
res = client.Post("/mcp", accept, session, testclient.RawJSON(`{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"get_order","arguments":{"path":{"id":"42"}}}}`))
res.AssertStatus(http.StatusOK)
}
Where to go next
Authentication covers the schemes the endpoint and its tools are protected by and the protected resource metadata a client signs in through, OpenAPI covers the operation ids and schemas a tool is described with, and Typed Endpoints covers the encoder a call is written by.