Skip to content

Mount the server in an existing HTTP service

A service that already runs an HTTP server under go/controls adds MCP as one more handler on that server. The module owns operation-level validation, authorisation and bounded results; the service keeps its listener, TLS, authentication middleware and lifecycle. New to this shape? The tutorial builds it from an empty directory.

Build the server

Register your operations explicitly against application functions (never discovered from routes or reflection), with a policy that reads the request's identity from the context:

registry, err := mcp.New(operations, mcp.WithPolicy(policy)) // see "Authorise operations"
if err != nil {
    return err
}

host, err := server.New(registry,
    server.WithIdentity("studio", version),
    server.WithLogger(logger),
    server.WithTrustedOrigins("https://studio.example.com"),
)
if err != nil {
    return err
}

server.New starts nothing. host.Handler() is a stateless Streamable HTTP handler; the identity your middleware puts in the request context reaches the registry and resource policies on every request.

Mount it under your middleware

Put the handler on the service's mux at a path you choose, and register the mux with the transport behind the chain you already run:

chain := transithttp.NewChain(
    transithttp.LoggingMiddleware(logger),
    authenticate, // transporthttp.AuthMiddleware(...) or your own
)

mux := http.NewServeMux()
mux.Handle("/api/", api)
mux.Handle("/mcp", host.Handler())

if _, err := transporthttp.Register(ctx, "http", controller, logger, mux, settings,
    transporthttp.WithMiddleware(chain),
    transporthttp.WithWriteTimeout(0),
); err != nil {
    return err
}

Authentication is the service's job: the module does not verify bearer tokens or API keys. Whatever authenticate establishes must be what Policy.ScopeKey and Policy.Authorize read; with the transport's AuthMiddleware that is transporthttp.IdentityFromContext. A request the chain refuses never reaches the protocol.

The handler applies MCP's Origin rule itself: a request with no Origin header passes, a browser origin passes only when it is the server's own or named in WithTrustedOrigins, and anything else is refused with 403 before the protocol sees it.

The transport's request-body limit (1 MiB unless you set WithMaxRequestBodyBytes) applies to MCP requests like any other; one over it is refused with 413, and nothing executes. Set the write timeout to zero for this server, or SSE responses are cut off mid-call (the transport's default is ten seconds).

A call is bound to its request. When the client disconnects, or the server cannot write to it any more, the operation's context is cancelled and the server stays healthy for the next call. This holds on every protocol version the adapter serves, not only where the SDK propagates it.

Register the lifecycle

The MCP server has its own shutdown and health, so register them with the controller after the transport:

controller.Register("mcp",
    controls.WithStopErr(host.Shutdown),
    controls.WithStatus(host.Status),
)

The controller stops services in reverse registration order, so the MCP server cancels its in-flight calls first and the listener then drains promptly; registered the other way round, the listener waits on calls that nothing is cancelling until the shutdown budget runs out.

Shutdown(ctx) stops admission, cancels active calls and waits within ctx; Status() stays non-nil while any work is unfinished, so an unresolved call remains visible in health after the controller's own stop has returned. Do not create a second controller or a signal handler for it: the service's is the owner (spec 0001 D7). The mounted server owns no background work of its own.

What the module proves

The tests under internal/servicefixture run exactly this shape: a service with a route of its own, API-key authentication in a transit chain, the handler registered through the transport under one controller. They show identity reaching policy for discovery and execution, an unauthenticated call refused before the protocol with no side effect, a foreign Origin refused with 403 through the chain, the transport's body limit answering 413, a stream that fails to write leaving the server healthy, a client disconnect cancelling the operation on the current and the legacy protocol, and the controller's shutdown cancelling an in-flight call, draining, closing the listener and reporting a clean stop, with no second listener, controller or signal owner. The server reference states the contracts.

Add resources or an Apps shell

server.WithResources(resources) publishes a mcp.Resources container with its own policy; server.WithApps(...) mounts a static UI shell on the compact dispatcher. Both are covered in the server reference.

What a CLI does instead

A command-line tool has no service to mount into. cli.Command's stream subcommand builds this shape for it: the transport module's server under a controller tied to the command context, with the MCP server and the Cobra binding registered beside it. Read the command tree reference.