Home

11 api keys and mcp

7b. API Keys and Built-in MCP Server

The same Go binary that serves REST + WebSocket also hosts a Model Context Protocol (MCP) server, so that AI assistants, IDE agents and automation scripts can drive the command station through Anthropic's Model Context Protocol. The MCP surface re-uses the same services (LocoService, RadioService, …) as the human-facing UI – there is no second implementation of the business rules.

7b.1 API key lifecycle

                ┌──────────────┐    POST /apikeys (≤ 365d)
                │ User in UI   │ ─────────────────────────────────────►
                └──────────────┘                                      │
                                                                      ▼
                                          ┌────────────────────────────────┐
                                          │ APIKeyService.Create:           │
                                          │   1. validate ExpiresAt-Now ≤   │
                                          │      365 days                   │
                                          │   2. generate "rb_" + 24 random │
                                          │      base64 chars               │
                                          │   3. hash secret part, store    │
                                          │      KeyPrefix + KeyHash        │
                                          │   4. return plaintext ONCE      │
                                          └─────────────┬───────────────────┘
                                                        ▼
                                              "rb_abcd1234.SECRET..."
                                              (shown one time in the UI;
                                               copy-to-clipboard + download)

Verification path (REST, MCP/HTTP, or stdio):

incoming request → header/query/env "rb_<prefix>.<secret>"
                 → APIKeyService.Verify:
                     - look up row by KeyPrefix (indexed)
                     - constant-time compare hash of <secret>
                     - reject if RevokedAt != nil or ExpiresAt < now()
                     - update LastUsedAt (async)
                 → returns auth.Identity { user, effectiveRoles, dccPool, scopes }
                 → same downstream middleware as login-based sessions

APIKeyService.Create:

const APIKeyMaxLifetime = 365 * 24 * time.Hour

func (s *APIKeyService) Create(ctx context.Context, ownerID uint, name string,
    expiresAt time.Time, scopes []string) (plaintext string, key domain.APIKey, err error) {

    if expiresAt.Sub(time.Now()) > APIKeyMaxLifetime {
        return "", domain.APIKey{}, ErrAPIKeyLifetimeTooLong
    }
    // "rb_" + 12 chars prefix (public) + "." + 24 chars secret
    prefix := randomBase62(12)
    secret := randomBase62(24)
    plaintext = "rb_" + prefix + "." + secret

    key = domain.APIKey{
        UserID:    ownerID,
        Name:      name,
        KeyPrefix: prefix,
        KeyHash:   hashSecret(secret), // argon2id or sha256-hmac with server pepper
        Scopes:    strings.Join(scopes, ","),
        CreatedAt: time.Now(),
        ExpiresAt: expiresAt,
    }
    return plaintext, key, s.repo.Insert(ctx, &key)
}

7b.2 Authentication carriers

The same key works through three transports:

Transport Where the key goes
REST Authorization: Bearer rb_<prefix>.<secret>
MCP over HTTP/SSE Same Authorization: Bearer … header on the SSE upgrade
MCP over stdio Environment variable BIGFRED_API_KEY read by the client (e.g. Claude Desktop / Cursor) and forwarded by the spawned loco server --mcp-stdio process

APIKeyMiddleware is just one more entry point into auth.Identity; once an identity is attached to the request context, the existing RequireRole / RequireVehicleDrive / RequireVehicleEdit middleware keeps working unchanged – API keys are not a privilege escalation, they are merely another way to authenticate as the owning user, restricted further by the Scopes field.

7b.3 Exposed MCP tools

Mounted with github.com/mark3labs/mcp-go. Each MCP tool is a thin wrapper around the same service method that the REST / WS layer calls:

MCP tool Required scope Underlying call
loco.list loco.read LocoService.List(identity)
loco.get loco.read LocoService.GetState(identity, addr)
loco.set_speed loco.drive LocoService.SetSpeed(identity, addr, …)
loco.toggle_fn loco.drive LocoService.ToggleFn(identity, addr, fn, on)
train.set_speed loco.drive LocoService.SetTrainSpeed(identity, …)
radio.send radio.send RadioService.Send(identity, to, phrase, …)
interlocking.list interlocking.read InterlockingService.List()
system.status system.read LocoService.SystemStatus()

The set is intentionally smaller than the REST API – CV writes, admin endpoints (user/role/DCC pool management) and API-key minting itself are deliberately not exposed via MCP, to keep blast radius of a leaked key small.

7b.4 Mounting MCP next to chi

// pkgs/bigfred/server/mcp/server.go
package mcp

import (
    "context"

    "github.com/mark3labs/mcp-go/server"

    "github.com/keskad/loco/pkgs/bigfred/server/service"
)

func New(loco *service.LocoService, radio *service.RadioService) *server.MCPServer {
    s := server.NewMCPServer("bigfred", "0.1.0",
        server.WithToolCapabilities(true),
    )
    registerLocoTools(s, loco)
    registerRadioTools(s, radio)
    return s
}
// pkgs/bigfred/server/mcp/tools_loco.go
import (
    "context"

    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)

func registerLocoTools(s *server.MCPServer, loco *service.LocoService) {
    setSpeed := mcp.NewTool("loco.set_speed",
        mcp.WithDescription("Set the speed and direction of a locomotive"),
        mcp.WithNumber("addr",    mcp.Required(), mcp.Description("DCC address")),
        mcp.WithNumber("speed",   mcp.Required(), mcp.Description("0..127")),
        mcp.WithBoolean("forward",                mcp.Description("Travel direction; default true")),
    )

    s.AddTool(setSpeed, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        ident := authFromCtx(ctx) // injected by API-key middleware
        addr  := uint16(req.Params.Arguments["addr"].(float64))
        speed := uint8(req.Params.Arguments["speed"].(float64))
        fwd, _ := req.Params.Arguments["forward"].(bool)

        if err := loco.SetSpeed(ctx, ident, addr, speed, fwd); err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }
        return mcp.NewToolResultText("ok"), nil
    })
}
// pkgs/bigfred/server/main.go – mount both HTTP and MCP from a single process
sseHandler := server.NewSSEServer(mcpSrv,
    server.WithBaseURL("/mcp"),
)

r.Route("/mcp", func(r chi.Router) {
    r.Use(apikey.Middleware(apikeySvc)) // identity from "Authorization: Bearer rb_…"
    r.Use(apikey.RequireScope("loco.read")) // baseline scope to even connect
    r.Mount("/", sseHandler)
})

// Additionally, a "loco server --mcp-stdio" subcommand starts the MCP
// server on stdio for local tools like Claude Desktop / Cursor.
if mcpStdio {
    return server.ServeStdio(mcpSrv) // reads BIGFRED_API_KEY from env
}

7b.5 Why mount MCP inside the same binary