244 lines
9.1 KiB
Go
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
|
|
}
|