Files
krow_backend/go-api/internal/mcpserver/jsonrpc.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

244 lines
9.1 KiB
Go

// Package mcpserver is the Model Context Protocol surface: a second way into
// the tool layer, for clients that speak MCP rather than HTTP+cookie.
//
// It is an ADDITIONAL interface and nothing else. It owns no business logic, no
// SQL and no authorization rules. Every call it serves ends up in
// tools.Registry.Dispatch — the same entry point the agent loop uses — so a
// question asked through MCP is answered by the same handler, under the same
// policy table, behind the same org pre-filter as the same question asked by
// Owliver. That is the whole design, and the reason this package is small.
//
// What lives here:
//
// - JSON-RPC 2.0 framing (this file)
// - the three methods MCP needs to be useful: initialize, tools/list,
// tools/call (server.go)
// - which tools are published, derived from the registry (tools.go)
// - bearer authentication, as a seam an OAuth implementation plugs into
// (auth.go)
// - the Streamable HTTP binding (transport.go)
//
// Identity is established by this package's own bearer authentication and is
// PASSED to the handlers, never read from the ambient request context. That is
// what stops a browser cookie from authenticating an MCP call — see auth.go.
package mcpserver
import (
"encoding/json"
"errors"
"fmt"
"time"
)
// jsonRPCVersion is the only version this server speaks. A request naming
// anything else is malformed rather than merely unsupported: "2.0" is a
// constant in the spec, not a negotiation.
const jsonRPCVersion = "2.0"
/* ── Wire types ─────────────────────────────────────────────────────────── */
// request is one inbound JSON-RPC message.
//
// ID is json.RawMessage rather than any, because the spec allows a string, a
// number or null, and the response MUST echo it back byte-for-byte. Decoding it
// into an `any` turns 1 into 1.0 on the way back out, which is a different id to
// a client matching responses to requests.
type request struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// isNotification reports that no response is expected.
//
// A notification is a request with no id. The spec is explicit that a server
// must not answer one, so the transport drops the response and returns 202.
func (r request) isNotification() bool {
return len(r.ID) == 0 || string(r.ID) == "null"
}
// response is one outbound JSON-RPC message.
//
// Result and Error are pointers so exactly one is ever serialised: the spec
// forbids both together, and a non-pointer Result would emit `"result":null`
// alongside an error.
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// rpcError is a JSON-RPC error object.
//
// retryAfter is NOT serialised. It exists so a rate-limited refusal produced
// deep in the handler can reach the transport, which is the only layer that can
// set an HTTP status and a Retry-After header. The alternative — returning a
// 200 with a JSON-RPC error and no header — would give a client no way to know
// how long to wait.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
retryAfter time.Duration
}
func (e *rpcError) Error() string { return fmt.Sprintf("jsonrpc %d: %s", e.Code, e.Message) }
/* ── Error codes ────────────────────────────────────────────────────────── */
// The standard JSON-RPC 2.0 codes. Reserved range is -32768..-32000; anything
// this server invents lives outside it.
const (
codeParseError = -32700
codeInvalidRequest = -32600
codeMethodNotFound = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
// codeUnauthorized is outside the JSON-RPC reserved range (-32768..-32000),
// because it is this server's own condition rather than a protocol fault. It
// accompanies an HTTP 401: the transport layer carries the authoritative
// signal, and this gives a client reading only the JSON-RPC body the same
// answer.
const codeUnauthorized = -32001
// codeRateLimited is this server's own condition, outside the reserved range.
// It accompanies an HTTP 429 and a Retry-After header.
const codeRateLimited = -32002
// errRateLimited refuses a call that exceeded its organisation's ceiling.
//
// The message names no number and no organisation. How much quota a tenant has
// and how much of it they have spent is not something one caller should learn
// from a refusal — it is the same reasoning as the opaque tool denial.
func errRateLimited(retryAfter time.Duration) *rpcError {
return &rpcError{
Code: codeRateLimited,
Message: "too many requests for this organisation; retry after the interval in the Retry-After header",
retryAfter: retryAfter,
}
}
func errParse(detail string) *rpcError {
return &rpcError{Code: codeParseError, Message: "invalid JSON", Data: detail}
}
func errInvalidRequest(detail string) *rpcError {
return &rpcError{Code: codeInvalidRequest, Message: "invalid JSON-RPC request", Data: detail}
}
func errMethodNotFound(method string) *rpcError {
return &rpcError{
Code: codeMethodNotFound,
Message: "method not found",
Data: fmt.Sprintf("this server implements initialize, tools/list and tools/call; it does not implement %q", method),
}
}
func errInvalidParams(detail string) *rpcError {
return &rpcError{Code: codeInvalidParams, Message: "invalid params", Data: detail}
}
// errInternal deliberately carries no detail.
//
// An internal failure is the one case where the thing that went wrong is this
// server's business and not the caller's: a wrapped database error or a panic
// message is reconnaissance. The detail goes to the log, where the operator is.
func errInternal() *rpcError {
return &rpcError{Code: codeInternalError, Message: "internal error"}
}
/* ── Parsing ────────────────────────────────────────────────────────────── */
// errBatch marks a batch request, which this server does not accept.
//
// Rejecting it explicitly rather than failing to parse it is the point: a
// client that batches and gets a parse error will retry the same batch, where
// one told that batching is unsupported can fall back to sending messages
// singly. The current MCP transport binding sends one message per POST, so
// nothing a compliant client does requires batching.
var errBatch = errors.New("batch requests are not supported")
// parseRequest decodes one JSON-RPC message and validates its envelope.
//
// The two are separate returns because they have different fates: a message
// that could not be parsed has no id, so its error answers with a null id,
// while a message that parsed but is invalid answers with the id it carried.
func parseRequest(body []byte) (request, *rpcError) {
trimmed := skipSpace(body)
if len(trimmed) == 0 {
return request{}, errInvalidRequest("the request body was empty")
}
if trimmed[0] == '[' {
return request{}, errInvalidRequest(errBatch.Error())
}
var req request
if err := json.Unmarshal(trimmed, &req); err != nil {
return request{}, errParse(err.Error())
}
if req.JSONRPC != jsonRPCVersion {
return req, errInvalidRequest(fmt.Sprintf(
"jsonrpc must be %q, got %q", jsonRPCVersion, req.JSONRPC))
}
if req.Method == "" {
return req, errInvalidRequest("method is required")
}
// An id, when present, must be a string or a number. Objects and arrays are
// forbidden by the spec, and echoing one back would propagate the mistake.
if len(req.ID) > 0 && !isValidID(req.ID) {
return req, errInvalidRequest("id must be a string, a number or null")
}
return req, nil
}
// isValidID reports whether a raw id is a string, a number or null.
func isValidID(raw json.RawMessage) bool {
t := skipSpace(raw)
if len(t) == 0 {
return false
}
switch t[0] {
case '{', '[':
return false
}
var v any
return json.Unmarshal(t, &v) == nil
}
// decodeParams unmarshals params into dst, treating absent params as an empty
// object so a method with only optional fields can be called with none.
func decodeParams(raw json.RawMessage, dst any) *rpcError {
t := skipSpace(raw)
if len(t) == 0 || string(t) == "null" {
return nil
}
// Arrays are legal JSON-RPC (positional params) and are not used by MCP,
// whose methods all take an object. Saying so beats a confusing type error.
if t[0] == '[' {
return errInvalidParams("params must be an object; positional params are not supported")
}
if err := json.Unmarshal(t, dst); err != nil {
return errInvalidParams(err.Error())
}
return nil
}
func skipSpace(b []byte) []byte {
i := 0
for i < len(b) {
switch b[i] {
case ' ', '\t', '\r', '\n':
i++
default:
return b[i:]
}
}
return nil
}