Files
krow_backend/go-api/internal/mcpserver/server.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

388 lines
15 KiB
Go

package mcpserver
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/tools"
)
// ProtocolVersion is the MCP revision this server implements.
//
// Echoed back from initialize. A client asking for a different revision is not
// refused: the spec's negotiation is that the server states what it speaks and
// the client decides whether it can work with that. Refusing would turn a
// version skew into an outage where it is usually a compatible difference.
const ProtocolVersion = "2025-06-18"
// ServerName and ServerVersion identify this implementation to a client.
const (
ServerName = "krow-mcp"
ServerVersion = "0.1.0"
)
// maxToolArgumentBytes bounds one tool call's arguments.
//
// The same reasoning as runs.go's maxRunRequestBytes: arguments are a handful
// of scalars against a schema that sets additionalProperties:false, so anything
// large is either a mistake or an attempt to push text past the tool layer into
// a prompt. The transport bounds the whole body too; this bounds the part that
// reaches a handler.
const maxToolArgumentBytes = 64 << 10
// Server answers MCP methods against the existing tool registry.
//
// It holds a *tools.Registry and nothing else that matters. There is no second
// registry, no adapter table and no per-tool code in this package: what MCP
// publishes is what the registry holds, filtered by the rule in tools.go.
type Server struct {
reg *tools.Registry
log *slog.Logger
// tokens resolves a bearer credential into an identity. Nil means this
// surface cannot authenticate anyone, and every call is refused — see
// ErrNoAuthenticator. Failing closed is the only safe default for a field
// whose absence would otherwise mean "let everyone in".
tokens TokenAuthenticator
// resourceMetadataURL is where a 401 points a client so it can begin
// discovery. Empty means the challenge carries no pointer, which is a
// valid but less useful 401: a client then has nowhere to look.
resourceMetadataURL string
// orgLimiter bounds tool calls per organisation.
//
// HERE rather than in the HTTP middleware, and that placement is the whole
// point: an organisation is not knowable until the bearer token has been
// resolved to a user and that user's row read. A middleware running before
// authentication could only key by something the CLIENT supplied, which is
// precisely the identity this surface refuses to trust.
//
// Nil means no per-organisation ceiling, which is the correct default for
// a deployment that has not configured one.
orgLimiter OrgLimiter
}
// OrgLimiter bounds how much one organisation may ask for.
//
// Takes an org id that the caller has already established from an authenticated
// identity. It cannot be handed anything from a request, because the only
// caller is dispatch, which has an authctx.Identity and nothing else.
type OrgLimiter interface {
// AllowOrg reports whether this organisation may make another call, and
// how long until its window rolls over.
AllowOrg(ctx context.Context, orgID string) (allowed bool, retryAfter time.Duration, err error)
}
// WithOrgLimiter installs the per-organisation ceiling.
func (s *Server) WithOrgLimiter(l OrgLimiter) *Server {
s.orgLimiter = l
return s
}
// WithResourceMetadataURL sets the RFC 9728 document a 401 points at.
//
// Supplied by the caller rather than derived here, because this package does
// not know its own deployment's URLs and must not invent them. A hardcoded
// hostname would be one deployment's identity baked into every other one.
func (s *Server) WithResourceMetadataURL(u string) *Server {
s.resourceMetadataURL = u
return s
}
// challenge builds the WWW-Authenticate header for a 401.
//
// RFC 9728 section 5.1: the client reads `resource_metadata` from here to find
// the protected-resource document, and from there the authorization server.
// Without the parameter a compliant client has a 401 and nowhere to go, which
// is why this is the difference between "authentication failed" and "here is
// how to authenticate".
func (s *Server) challenge() string {
c := `Bearer realm="` + ServerName + `"`
if s.resourceMetadataURL != "" {
c += `, resource_metadata="` + s.resourceMetadataURL + `"`
}
return c
}
// New builds a server over an existing registry.
//
// The registry is the one the rest of the service already built — the caller
// passes runtime.DefaultTools(...)'s result, the same value the HTTP server
// uses for its author catalogue. Taking it as a parameter rather than building
// one here is what guarantees there is only ever one.
func New(reg *tools.Registry, tokens TokenAuthenticator, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
return &Server{reg: reg, tokens: tokens, log: log}
}
// Handle dispatches one parsed JSON-RPC request on behalf of an identity.
//
// The identity is a PARAMETER, not something read from the context, and that is
// the security property rather than a style choice. If this function resolved
// the caller from ctx, then mounting the endpoint behind the cookie middleware
// would make a browser session sufficient to call MCP tools — the middleware
// puts an Identity in the context, and this code would find it. Taking it as an
// argument means only the MCP transport's own bearer authentication can supply
// one. See auth.go.
//
// A nil identity means unauthenticated. The three handshake methods are allowed
// without one; tools/call is not.
//
// Returns a result or an error, never both. Notifications are handled by the
// transport, which discards whatever comes back.
func (s *Server) Handle(ctx context.Context, ident *authctx.Identity, req request) (any, *rpcError) {
switch req.Method {
case "initialize":
return s.handleInitialize(req.Params)
case "notifications/initialized":
// The client telling us it is ready. Nothing to do, and answering is
// not required — it arrives as a notification.
return map[string]any{}, nil
case "ping":
// Cheap liveness, defined by the spec as an empty result. Costs nothing
// and saves a client from using tools/list as a heartbeat.
return map[string]any{}, nil
case "tools/list":
return s.handleToolsList(req.Params)
case "tools/call":
return s.handleToolsCall(ctx, ident, req.Params)
default:
return nil, errMethodNotFound(req.Method)
}
}
/* ── initialize ─────────────────────────────────────────────────────────── */
type initializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities json.RawMessage `json:"capabilities"`
ClientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"clientInfo"`
}
type initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo map[string]any `json:"serverInfo"`
Instructions string `json:"instructions,omitempty"`
}
// handleInitialize answers the opening handshake.
//
// Declares exactly one capability, because exactly one is implemented. A server
// that advertised resources or prompts here would be promising methods that
// answer method-not-found, and a client would reasonably call them.
//
// listChanged is false: the tool set is fixed at process start by the registry,
// so there is no change to notify anyone about.
func (s *Server) handleInitialize(raw json.RawMessage) (any, *rpcError) {
var p initializeParams
if err := decodeParams(raw, &p); err != nil {
return nil, err
}
s.log.Info("mcp initialize",
"client_name", p.ClientInfo.Name,
"client_version", p.ClientInfo.Version,
"client_protocol", p.ProtocolVersion,
"server_protocol", ProtocolVersion)
return initializeResult{
ProtocolVersion: ProtocolVersion,
Capabilities: map[string]any{
"tools": map[string]any{"listChanged": false},
},
ServerInfo: map[string]any{
"name": ServerName,
"version": ServerVersion,
},
Instructions: "Read-only access to KROW workforce and hiring data. " +
"Every call is scoped to the authenticated user's organisation and role; " +
"results are structured data for you to summarise, not prose.",
}, nil
}
/* ── tools/list ─────────────────────────────────────────────────────────── */
type toolsListResult struct {
Tools []mcpTool `json:"tools"`
}
// handleToolsList publishes the exposed tools, straight from the registry.
func (s *Server) handleToolsList(raw json.RawMessage) (any, *rpcError) {
// Params are optional here (cursor, for pagination this server does not
// need), but a malformed object is still worth refusing rather than
// ignoring — silently accepting nonsense trains a client to send it.
var p struct {
Cursor string `json:"cursor,omitempty"`
}
if err := decodeParams(raw, &p); err != nil {
return nil, err
}
infos := exposed(s.reg)
out := make([]mcpTool, 0, len(infos))
for _, info := range infos {
out = append(out, toMCPTool(info))
}
return toolsListResult{Tools: out}, nil
}
/* ── tools/call ─────────────────────────────────────────────────────────── */
type toolsCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
// toolsCallResult is MCP's shape for a tool's output.
//
// IsError is part of the RESULT, not a JSON-RPC error: a tool that refused is
// not a protocol fault, and reporting it as one would deny the model the chance
// to read the refusal and do something sensible. It is the same distinction
// tools.Result already draws, and runs.go draws for terminations.
type toolsCallResult struct {
Content []contentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
func textResult(payload any, isError bool) (toolsCallResult, *rpcError) {
encoded, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return toolsCallResult{}, errInternal()
}
return toolsCallResult{
Content: []contentBlock{{Type: "text", Text: string(encoded)}},
IsError: isError,
}, nil
}
// handleToolsCall validates a call and dispatches it through the registry.
//
// The order is: exposure, then bounds, then identity, then dispatch.
//
// Exposure is checked BEFORE identity on purpose. "There is no such tool here"
// does not depend on who is asking, and answering it first means the surface's
// tool inventory is not something an attacker can probe by comparing an
// authenticated 404 against an unauthenticated 401.
//
// Authentication is the transport's job and has already happened by the time
// this runs; `ident` is nil only when it failed or was never attempted. This
// function does not read the ambient context for a caller — see Handle.
func (s *Server) handleToolsCall(ctx context.Context, ident *authctx.Identity, raw json.RawMessage) (any, *rpcError) {
var p toolsCallParams
if err := decodeParams(raw, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, errInvalidParams("name is required")
}
if len(p.Arguments) > maxToolArgumentBytes {
return nil, errInvalidParams("arguments are too large")
}
// Unexposed and unknown are the SAME answer, deliberately. See
// isExposedName — distinguishing them inventories what this surface is
// hiding.
if !isExposedName(s.reg, p.Name) {
s.log.Warn("mcp tool call refused", "tool", p.Name, "reason", "not_exposed")
return textResult(map[string]any{
"error": map[string]any{
"code": "mcp.unknown_tool",
"message": "there is no tool called " + p.Name + " on this surface",
},
}, true)
}
// Unauthenticated calls never reach a handler. The transport answers 401
// before this point in the ordinary case; this is the second gate, so that
// a future caller of Handle that forgets to authenticate fails closed
// rather than dispatching as nobody.
if ident == nil {
s.log.Warn("mcp tool call refused", "tool", p.Name, "reason", "no_identity")
return textResult(map[string]any{
"error": map[string]any{
"code": "mcp.unauthenticated",
"message": "this call is not authenticated",
},
}, true)
}
return s.dispatch(ctx, *ident, p)
}
// dispatch runs the tool through the existing registry.
//
// This is the only place this package touches the tool layer, and it is four
// lines on purpose. Everything that decides what comes back — the policy table,
// the org pre-filter, the row scopes, the opaque denial, the truncation — is
// inside Dispatch and the handler beneath it, unchanged and unreachable from
// here.
//
// The principal is the caller's, from the context. It is never read from
// params: an MCP client that could name its own principal could read anything,
// which is the bug I1 exists to prevent.
func (s *Server) dispatch(ctx context.Context, identity authctx.Identity, p toolsCallParams) (any, *rpcError) {
// The per-organisation ceiling, checked after authentication and before
// any work. The org comes from `identity`, which came from the token —
// there is no path by which a request can name a different bucket, because
// this function is never given anything from the request except the tool
// name and its arguments.
if s.orgLimiter != nil {
allowed, retryAfter, err := s.orgLimiter.AllowOrg(ctx, identity.OrgID)
if err != nil {
// The limiter has already decided whether a failure permits the
// call. Logged without the org's usage, which is not the caller's
// business.
s.log.Error("org rate limiter unavailable", "error", err)
}
if !allowed {
s.log.Warn("mcp org rate limit exceeded",
"org_id", identity.OrgID, "tool", p.Name)
return nil, errRateLimited(retryAfter)
}
}
args := p.Arguments
if len(skipSpace(args)) == 0 {
args = json.RawMessage(`{}`)
}
tc := tools.Context{
Principal: identity,
// No RunID: an MCP call is not an agent run and writes no trajectory.
// No KnowledgeSources: there is no spec, which is why knowledge_search
// is deferred rather than published — see tools.go.
}
res := s.reg.Dispatch(ctx, tc, p.Name, args)
s.log.Info("mcp tool call",
"tool", p.Name,
"user_id", identity.UserID,
"org_id", identity.OrgID,
"ok", res.Error == nil,
"truncated", res.Truncated)
if res.Error != nil {
return textResult(map[string]any{"error": res.Error}, true)
}
return textResult(map[string]any{
"data": res.Data,
"truncated": res.Truncated,
}, false)
}