mcp connection
This commit is contained in:
243
go-api/internal/mcpserver/jsonrpc.go
Normal file
243
go-api/internal/mcpserver/jsonrpc.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user