Skip to content

feat(mcp): serve the MCP endpoint from the Go server - #238

Merged
jplanckeel merged 1 commit into
mainfrom
feat/mcp-server
Oct 26, 2026
Merged

jplanckeel merged 1 commit into
mainfrom
feat/mcp-server

Conversation

@jplanckeel

Copy link
Copy Markdown
Contributor

What

Tracker now serves the Model Context Protocol itself, at /mcp on the HTTP port. AI agents (Claude Code, Cursor, Kiro…) connect with a URL and an API key: no Python, no uv, nothing to install.

claude mcp add --transport http tracker https://tracker.example.com/mcp \
  --header "Authorization: Bearer trk_..."

The Python server in mcp-server/ is marked deprecated. It also sent no credential at all, so it stopped working as soon as anonymous access was restricted.

How

  • server/mcp.go, official SDK github.com/modelcontextprotocol/go-sdk v1.8.0, Streamable HTTP in stateless mode. Each MCP request runs with the context of its own HTTP request, so it carries the principal that auth.HTTPMiddleware resolved for that request. In stateful mode the principal would be frozen when the session is created.
  • Tools call Event, Catalog and Lock in process, under the name of the matching RPC (rpcContext(ctx, EventService_ListEvents_FullMethodName)). authz.Authorize, the per-service team scope (feat(auth): per-service team scope #216) and the forced password change (fix(auth): enforce the forced password change on the server #217) therefore apply unchanged; nothing in the auth code is MCP-specific.
  • A rejected credential gets HTTP 401 with WWW-Authenticate: Bearer before any MCP exchange, so clients report an authentication error instead of eight failing tools.
  • Mounted with HandlePath on the gateway mux. /mcp is added to the SPA pass-through, and to the route guard table (httpRouteSites) with its reason.
  • MCP_ENABLED (default true) turns it off.

Tools

Same eight read-only tools and names as the Python server, all annotated readOnlyHint: list_events, today_events, search_events, get_event, list_catalog, get_catalog_service, list_locks, get_lock. Responses are protojson (camelCase, enum names), like the REST API.

Differences with the Python server:

  • Pagination done by the MCP layer (per_page default 10, max 100, page, totalCount), because the list RPCs ignore per_page/page and return everything. Events come newest first, the catalog by name.
  • list_events filters (type, service, status) now apply (through SearchEvents). The Python server sent them to /events/list, which ignores them.
  • search_events drops impact and slack_id, which utils.CreateFilter never applied. get_event already accepts a Slack id.
  • list_catalog, get_catalog_service, get_event and get_lock work now: the Python server called /catalog/list, /catalog/{name}, /events/{id} and /locks/{id}, which do not exist.
  • An unknown enum value is a tool error (unknown environment "prod").

Checks

  • go test -race on all packages against MongoDB 7: pass. New e2e tests (server/mcp_test.go) use the SDK client through the real auth middleware:
    • the tool list is complete and read-only;
    • a team API key and a session user only see their service, and get, lock and catalog lookups outside the scope are tool errors;
    • a global key sees everything;
    • anonymous callers follow AUTH_ANONYMOUS_PERMISSIONS;
    • an invalid key gets 401;
    • unknown enum values are rejected.
  • Mutation checks: tools that drop the request context fail 3 tests; removing the 401 fails 1.
  • golangci-lint run ./...: 0 issues. go vet: clean.
  • Manual run of the real binary (container, port 18080): tools/list and tools/call with curl, 401 on a bad key, 405 on GET /mcp, SPA routes still serve index.html.
  • govulncheck reports golang.org/x/net v0.58.0 (fixed in v0.60.0) and crypto/x509 from the local Go toolchain. Both are already on main; neither comes from the SDK.

Notes

  • The SDK pulls golang-jwt/jwt/v5 from 5.3.0 to 5.3.1 (its minimum).
  • Found while testing, not fixed here: on main, CreateEvent panics with a nil pointer when the body has no links object (server/event.go:136). The HTTP server recovers, but the client gets an empty reply.

Follow-ups

  • Write tools (create an event, take or release a lock) behind the existing *:write permissions.
  • MCP OAuth (protected resource metadata) on top of the OIDC login, for clients that do not take a static header.
  • Remove mcp-server/, and its Dependabot entry from ci: include dependabot updates in release-please #237.

🤖 Generated with Claude Code

Serve the Model Context Protocol at /mcp (Streamable HTTP, stateless) with
the official Go SDK, so AI agents need nothing installed. The eight
read-only tools of the Python server are kept by name; they call the event,
catalog and lock services in process under the matching RPC name, so
permissions, the per-service team scope and the forced password change
apply as on the REST API. A refused credential answers 401 before the MCP
exchange. Lists are paginated by the MCP layer, events newest first.

MCP_ENABLED (default true) turns the endpoint off. The Python server is
deprecated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jplanckeel
jplanckeel merged commit ecede31 into main Oct 26, 2026
5 checks passed
@jplanckeel
jplanckeel deleted the feat/mcp-server branch October 26, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant