388 lines
15 KiB
Go
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)
|
|
}
|