You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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 byHow
The routemuzak.MCPTool() among its options
A tagMCPOptions.Tags names one of the route's tags, its own or a router's
A ruleMCPOptions.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}
}
MemberComes from
nameThe 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
titlemuzak.Title
descriptionmuzak.Summary and muzak.Description
inputSchemaThe operation's parameters and request body in the OpenAPI document
outputSchemaThe success response's JSON schema
annotationsThe 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:

CarriedWhy
Authorization, and ForwardHeaders and ForwardCookiesThe client's credentials, unchanged
The client's address, TLS state and the headers a trusted proxy names it inRate limits, client IP and the HTTPS redirect see the real client
The request identifierOne request_id in both access log lines and in the tool's error envelope
The traceThe call's span is a child of the MCP request's
Cancellation and deadlineA client that disconnects stops the call; no other value of the MCP request's context is
User-Agent, Accept-LanguageUnless 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 answeredThe result
JSONThe JSON as text, and as structuredContent (an object before 2026-07-28, any value from it)
Text or HTMLText
An image or audioAn image or audio item, in base64
A redirectThe Location as text; it is not followed
No bodyAn empty content
Any status from 400isError: true with the error envelope as text
Anything else, or more than MaxResultSizeisError: 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

RevisionHow it is served
2026-07-28Stateless: every request carries its revision, no session, server/discover
2025-11-25Session opened with initialize
2025-06-18Session opened with initialize
2025-03-26Session 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.

RequestAnswer
POST with a request200 and application/json
POST with a notification or a response202, no body
POST without a session a session revision needs400
POST or DELETE with an unknown or expired session404; the client opens another
DELETE204, the session ends
GET405; no stream from the server is offered
An Origin that is not allowed403
A body that is not application/json, or an Accept without it415, 406
An MCP-Protocol-Version other than the session's400
An Mcp-Method, or on tools/call an Mcp-Name, that does not match the body or is sent twice400 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 MaxBodySize413
A batch400 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

OptionDefaultBound
MaxResultSize1 MiBAt most 64 MiB; a larger answer is an error result and stops the route
The endpoint's MaxBodySizeThe application's, 1 MiB unless setBounds 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
PageSize100 toolsAt most 1000
MaxSessions10,000At most 1,000,000; the session idle longest is evicted
SessionIdleTimeout24 hours
ListTTL5 minutesHow 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 404 and opens a new one. 2026-07-28 clients are stateless and need nothing.
  • A route that binds User-Agent to a non-pointer string cannot be called unless the model supplies a value: a call refuses to send an empty User-Agent. Bind it to a *string when 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.Call sends 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.