Skip to content

Mount the server in an HTTP service

You'll build a small notes service with a JSON route of its own and an MCP endpoint beside it, sharing one authentication middleware and one lifecycle. By the end an assistant can list and add notes through /mcp with the same API key that guards /notes, and Ctrl-C drains both cleanly. Allow twenty minutes.

You'll need Go 1.27.1 or later and curl. Read register and invoke an operation first if an operation, a policy or a registry is new to you; this page uses all three without re-explaining them.

Create the module

mkdir notes && cd notes
go mod init example.com/notes

Everything below goes in one file, main.go, in the order shown. Start it with the imports:

package main

import (
    "context"
    "encoding/json"
    "log/slog"
    "net/http"
    "os"
    "sync"
    "time"

    "gitlab.com/phpboyscout/go/authn"
    "gitlab.com/phpboyscout/go/controls"
    transithttp "gitlab.com/phpboyscout/go/transit/http"
    transporthttp "gitlab.com/phpboyscout/go/transport/http"

    mcp "gitlab.com/phpboyscout/go/mcp"
    "gitlab.com/phpboyscout/go/mcp/server"
)

Five toolkit modules, each with one job: authn verifies credentials, transit supplies the middleware chain, transport builds the hardened server, controls owns the lifecycle, and mcp publishes the operations.

Write the application

The store is the whole application. Two operations read and write it, and one HTTP handler serves it as JSON so you can see that the service is more than its MCP endpoint:

type notes struct {
    mu    sync.Mutex
    items []string
}

func (n *notes) list(context.Context, *mcp.Invocation) (mcp.Outcome, error) {
    n.mu.Lock()
    defer n.mu.Unlock()

    result, err := mcp.JSONResult(n.items)

    return mcp.Finish(result), err
}

func (n *notes) add(_ context.Context, in *mcp.Invocation) (mcp.Outcome, error) {
    var args struct {
        Text string `json:"text"`
    }
    if err := json.Unmarshal(in.Arguments(), &args); err != nil {
        return mcp.Outcome{}, err
    }

    n.mu.Lock()
    n.items = append(n.items, args.Text)
    n.mu.Unlock()

    result, err := mcp.TextResult("added")

    return mcp.Finish(result), err
}

func (n *notes) handleList(w http.ResponseWriter, r *http.Request) {
    n.mu.Lock()
    defer n.mu.Unlock()

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(n.items)
}

in.Arguments() is the validated JSON the registry already checked against the operation's schema, so add can trust that text is a non-empty string by the time it runs.

Read the identity the service establishes

The policy is where the service's authentication meets the module. It reads the identity the transport's middleware put in the context and decides on that. Here anyone authenticated may discover and run notes.list, and only the editor subject may run notes.add:

func authorize(ctx context.Context, req mcp.AccessRequest) error {
    identity, ok := transporthttp.IdentityFromContext(ctx)
    if !ok {
        return mcp.NewFailure(mcp.FailureUnavailable, "Operation unavailable", nil)
    }

    if req.Operation == "notes.add" && identity.Subject != "editor" {
        return mcp.NewFailure(mcp.FailureUnavailable, "Operation unavailable", nil)
    }

    return nil
}

func scopeKey(ctx context.Context) (string, error) {
    identity, ok := transporthttp.IdentityFromContext(ctx)
    if !ok {
        return "", mcp.NewFailure(mcp.FailureUnavailable, "No identity", nil)
    }

    return identity.Subject, nil
}

Denying with FailureUnavailable matters: a caller who may not run notes.add gets the same answer as one asking for a name that does not exist, so the catalogue cannot be probed. scopeKey names the caller so that search cursors and pending confirmations issued to one subject cannot be replayed by another.

Build the server and the chain

main and run are the rest of the file. The first half builds the pieces and none of them listens yet:

func main() {
    if err := run(); err != nil {
        slog.Error("service failed", "error", err)
        os.Exit(1)
    }
}

func run() error {
    logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
    store := &notes{items: []string{}}

    registry, err := mcp.New([]mcp.Operation{
        {Definition: mcp.Definition{Name: "notes.list", Summary: "List the notes", InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}, Handle: store.list},
        {Definition: mcp.Definition{Name: "notes.add", Summary: "Add a note", InputSchema: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string","minLength":1}},"required":["text"],"additionalProperties":false}`)}, Handle: store.add},
    }, mcp.WithPolicy(mcp.Policy{Authorize: authorize, ScopeKey: scopeKey}))
    if err != nil {
        return err
    }

    host, err := server.New(registry, server.WithIdentity("notes", "0.1.0"), server.WithLogger(logger))
    if err != nil {
        return err
    }

    verifier, err := authn.NewAPIKeyVerifier(
        authn.KeyEntry{Key: "reader-key", Subject: "reader"},
        authn.KeyEntry{Key: "editor-key", Subject: "editor"},
    )
    if err != nil {
        return err
    }

    auth, err := transporthttp.AuthMiddleware(transporthttp.WithAPIKeyHeader("X-API-Key", verifier), transporthttp.WithAuthLogger(logger))
    if err != nil {
        return err
    }

    chain := transithttp.NewChain(transithttp.LoggingMiddleware(logger), auth)

server.New returns a handler and starts nothing. The two API keys are fine for a tutorial; a real service verifies a bearer token or a client certificate through the same AuthMiddleware, and nothing below changes.

Mount both routes under the transport

The second half of run puts the JSON route and the MCP handler on one mux, registers that mux with the transport behind the chain, and registers the MCP server's own lifecycle beside it:

    mux := http.NewServeMux()
    mux.HandleFunc("/notes", store.handleList)
    mux.Handle("/mcp", host.Handler())

    ctx := context.Background()
    controller := controls.NewController(ctx, controls.WithLogger(logger), controls.WithSignals(), controls.WithShutdownTimeout(10*time.Second))

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

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

    controller.Start()
    controller.Wait()

    return nil
}

Three lines here are load-bearing. WithWriteTimeout(0) because an MCP call answers over a server-sent event stream that stays open for the life of the call, and the transport's default ten-second write timeout would cut it off. WithSignals() because this main is the outermost thing in the process, so the controller is the right owner of Ctrl-C; inside a CLI framework that already turns signals into context cancellation you'd leave it out. And the MCP lifecycle is registered after the transport: the controller stops services in reverse order, so the MCP server cancels its in-flight calls first and the listener then drains at once.

Now fetch the dependencies. This downloads a few dozen modules the first time, the MCP SDK and OpenTelemetry among them:

go mod tidy

Run it

go run .

You'll see one line on stderr:

time=... level=INFO msg="starting http server" tls=false addr=127.0.0.1:8080

In a second terminal, ask the transport's health endpoint what is running. It sits outside the chain, so it needs no key:

curl -s http://127.0.0.1:8080/healthz
{"overall_healthy":true,"state":"running","services":[{"name":"http","status":"OK"},{"name":"mcp","status":"OK"}]}

Both services report, because both are registered with the one controller.

Call it as an assistant would

A real client negotiates the protocol version and sends the routing headers the current version wants. By hand, the November 2025 version is the simplest to speak with curl: one POST, one JSON-RPC message, and the server answers as an event stream. Discover what the reader key may see:

curl -s -X POST http://127.0.0.1:8080/mcp \
  -H 'X-API-Key: reader-key' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_tools","arguments":{}}}'
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"operations\":[{\"name\":\"notes.list\",\"summary\":\"List the notes\"}],\"revision\":\"a1b8…\"}"}],"structuredContent":{"operations":[{"name":"notes.list","summary":"List the notes"}],"revision":"a1b8…"}}}

Only notes.list is there. The policy hid notes.add from a reader, and asking for it by name gets the same safe answer as an unknown operation. Change the key to editor-key and the same search lists both. Now add a note as the reader, and watch it refused:

curl -s -X POST http://127.0.0.1:8080/mcp \
  -H 'X-API-Key: reader-key' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"call_tool","arguments":{"name":"notes.add","arguments":{"text":"buy milk"}}}}'
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{\"failure\":{\"code\":\"unavailable\",\"message\":\"Operation unavailable\",\"retryable\":false}}"}],"structuredContent":{"failure":{"code":"unavailable","message":"Operation unavailable","retryable":false}},"isError":true}}

Send the same request with X-API-Key: editor-key and it lands:

event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"added","type":"text"}]}}

The JSON route sees the same store, through the same middleware:

curl -s -H 'X-API-Key: reader-key' http://127.0.0.1:8080/notes
["buy milk"]

See what the chain refuses

Two requests never reach the protocol. Without a key, the authentication middleware answers first:

curl -s -i -X POST http://127.0.0.1:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' -d '{}' | head -1
HTTP/1.1 401 Unauthorized

And with a browser origin the service did not name, the handler's own Origin rule refuses before parsing anything, even with a valid key:

curl -s -i -X POST http://127.0.0.1:8080/mcp \
  -H 'X-API-Key: editor-key' -H 'Origin: https://attacker.example' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' -d '{}' | head -1
HTTP/1.1 403 Forbidden

A browser-based client you do trust goes in server.WithTrustedOrigins when you build the server. Clients without a browser send no Origin and are unaffected.

Stop it

Back in the first terminal, press Ctrl-C. The controller takes the signal, stops the MCP server (cancelling any call still running), then drains the listener:

level=WARN msg="received signal" component=controller signal=interrupt
level=WARN msg="Stopping Services" component=controller
level=INFO msg="stopping http server" addr=127.0.0.1:8080
level=INFO msg=Stopped component=controller

The port is closed once the process exits; a call that was mid-flight was cancelled rather than abandoned, and its handler saw ctx.Done(). The shutdown budget you gave the controller, ten seconds, is how long a call that ignores cancellation can hold that up.

Where next