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¶
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 := ¬es{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:
Run it¶
You'll see one line on stderr:
In a second terminal, ask the transport's health endpoint what is running. It sits outside the chain, so it needs no key:
{"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:
The JSON route sees the same store, through the same middleware:
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
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
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¶
- Mount the server in an existing service covers what this page skipped: the transport's body limit, trusted origins, what happens when a client disconnects mid-call, and what the module's own tests prove about this shape.
- Authorise operations for a caller goes further into the two policy callbacks.
- Register with an editor connects a
real client instead of
curl. - The server reference states the contracts, and spec 0001 D7 is why the service, not the module, owns the listener.