230 lines
7.4 KiB
Go
230 lines
7.4 KiB
Go
// Package mcp implements a Model Context Protocol server over Streamable HTTP, so
|
|||
|
|
// verdande can be added to Claude as a connector.
|
||
|
|
//
|
||
|
|
// MCP is JSON-RPC 2.0 with a fixed handshake and a fixed set of methods. It is
|
||
|
|
// implemented directly here rather than through an SDK: the surface verdande needs
|
||
|
|
// is initialize, tools/list and tools/call, and the whole thing is smaller than the
|
||
|
|
// dependency would be.
|
||
|
|
//
|
||
|
|
// Authentication is a personal API token in the Authorization header, checked by
|
||
|
|
// the same middleware as the rest of the API — so a tool call can reach exactly
|
||
|
|
// what its owner can reach, and nothing else. There is no separate service identity
|
||
|
|
// to reason about, which is the point.
|
||
|
|
package mcp
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
)
|
||
|
|
|
||
|
|
// The protocol version this server speaks. Claude sends its own in initialize; a
|
||
|
|
// mismatch is not fatal, and answering with what we actually implement is what the
|
||
|
|
// spec asks for.
|
||
|
|
const ProtocolVersion = "2024-11-05"
|
||
|
|
|
||
|
|
// --- JSON-RPC ---------------------------------------------------------------------
|
||
|
|
|
||
|
|
type Request struct {
|
||
|
|
JSONRPC string `json:"jsonrpc"`
|
||
|
|
ID json.RawMessage `json:"id,omitempty"`
|
||
|
|
Method string `json:"method"`
|
||
|
|
Params json.RawMessage `json:"params,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Response struct {
|
||
|
|
JSONRPC string `json:"jsonrpc"`
|
||
|
|
ID json.RawMessage `json:"id,omitempty"`
|
||
|
|
Result any `json:"result,omitempty"`
|
||
|
|
Error *RPCError `json:"error,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type RPCError struct {
|
||
|
|
Code int `json:"code"`
|
||
|
|
Message string `json:"message"`
|
||
|
|
Data any `json:"data,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// The JSON-RPC error codes, as the spec numbers them.
|
||
|
|
const (
|
||
|
|
CodeParseError = -32700
|
||
|
|
CodeInvalidRequest = -32600
|
||
|
|
CodeMethodNotFound = -32601
|
||
|
|
CodeInvalidParams = -32602
|
||
|
|
CodeInternalError = -32603
|
||
|
|
)
|
||
|
|
|
||
|
|
func result(id json.RawMessage, value any) *Response {
|
||
|
|
return &Response{JSONRPC: "2.0", ID: id, Result: value}
|
||
|
|
}
|
||
|
|
|
||
|
|
func failure(id json.RawMessage, code int, message string) *Response {
|
||
|
|
return &Response{JSONRPC: "2.0", ID: id, Error: &RPCError{Code: code, Message: message}}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- tools -------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// Tool is one thing Claude can do. The schema is what it reads to decide how to
|
||
|
|
// call it, so the descriptions are written for a model rather than for a developer:
|
||
|
|
// they say when to use the tool, not merely what it does.
|
||
|
|
type Tool struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
InputSchema map[string]any `json:"inputSchema"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handler executes a tool call for a particular user. Returning an error produces a
|
||
|
|
// tool error rather than a protocol error — a model can read and recover from the
|
||
|
|
// first, and can do nothing at all with the second.
|
||
|
|
type Handler func(ctx context.Context, userID string, args json.RawMessage) (any, error)
|
||
|
|
|
||
|
|
type Server struct {
|
||
|
|
tools []Tool
|
||
|
|
handlers map[string]Handler
|
||
|
|
// Name and Version identify this instance in the connector list.
|
||
|
|
Name string
|
||
|
|
Version string
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewServer(name, version string) *Server {
|
||
|
|
return &Server{Name: name, Version: version, handlers: map[string]Handler{}}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) Register(tool Tool, handler Handler) {
|
||
|
|
s.tools = append(s.tools, tool)
|
||
|
|
s.handlers[tool.Name] = handler
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle dispatches one JSON-RPC message.
|
||
|
|
//
|
||
|
|
// A nil response means the message was a notification — it had no id, so the spec
|
||
|
|
// says to answer with nothing at all. Answering anyway is a protocol violation that
|
||
|
|
// some clients tolerate and others disconnect over.
|
||
|
|
func (s *Server) Handle(ctx context.Context, userID string, raw []byte) *Response {
|
||
|
|
var req Request
|
||
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||
|
|
return failure(nil, CodeParseError, "invalid JSON")
|
||
|
|
}
|
||
|
|
if req.JSONRPC != "2.0" {
|
||
|
|
return failure(req.ID, CodeInvalidRequest, "jsonrpc must be \"2.0\"")
|
||
|
|
}
|
||
|
|
|
||
|
|
isNotification := len(req.ID) == 0
|
||
|
|
|
||
|
|
switch req.Method {
|
||
|
|
case "initialize":
|
||
|
|
return result(req.ID, map[string]any{
|
||
|
|
"protocolVersion": ProtocolVersion,
|
||
|
|
"capabilities": map[string]any{
|
||
|
|
// Only tools. verdande has no prompts or resources to offer that
|
||
|
|
// the tools do not already cover, and declaring capabilities that
|
||
|
|
// answer with nothing wastes a round trip on every connection.
|
||
|
|
"tools": map[string]any{},
|
||
|
|
},
|
||
|
|
"serverInfo": map[string]any{"name": s.Name, "version": s.Version},
|
||
|
|
})
|
||
|
|
|
||
|
|
case "notifications/initialized", "initialized":
|
||
|
|
return nil
|
||
|
|
|
||
|
|
case "ping":
|
||
|
|
return result(req.ID, map[string]any{})
|
||
|
|
|
||
|
|
case "tools/list":
|
||
|
|
return result(req.ID, map[string]any{"tools": s.tools})
|
||
|
|
|
||
|
|
case "tools/call":
|
||
|
|
return s.callTool(ctx, userID, req)
|
||
|
|
|
||
|
|
default:
|
||
|
|
if isNotification {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return failure(req.ID, CodeMethodNotFound, "unknown method: "+req.Method)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type toolCallParams struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Arguments json.RawMessage `json:"arguments"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) callTool(ctx context.Context, userID string, req Request) *Response {
|
||
|
|
var params toolCallParams
|
||
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||
|
|
return failure(req.ID, CodeInvalidParams, "could not read the call parameters")
|
||
|
|
}
|
||
|
|
|
||
|
|
handler, ok := s.handlers[params.Name]
|
||
|
|
if !ok {
|
||
|
|
return failure(req.ID, CodeMethodNotFound, "unknown tool: "+params.Name)
|
||
|
|
}
|
||
|
|
|
||
|
|
value, err := handler(ctx, userID, params.Arguments)
|
||
|
|
if err != nil {
|
||
|
|
// A failed tool call is a *result* with isError set, not a JSON-RPC error.
|
||
|
|
// The distinction matters: this way the model is told what went wrong and
|
||
|
|
// can try something else, rather than the conversation failing.
|
||
|
|
return result(req.ID, map[string]any{
|
||
|
|
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
||
|
|
"isError": true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Tool results are text content. JSON is rendered indented because a model
|
||
|
|
// reads it as text, and one line of dense JSON is markedly harder for it to
|
||
|
|
// pick a field out of than a formatted block.
|
||
|
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||
|
|
if err != nil {
|
||
|
|
return failure(req.ID, CodeInternalError, "could not encode the result")
|
||
|
|
}
|
||
|
|
return result(req.ID, map[string]any{
|
||
|
|
"content": []map[string]any{{"type": "text", "text": string(encoded)}},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- schema helpers -------------------------------------------------------------------
|
||
|
|
|
||
|
|
// Schema builds a JSON Schema object for a tool's arguments.
|
||
|
|
func Schema(properties map[string]any, required ...string) map[string]any {
|
||
|
|
if required == nil {
|
||
|
|
required = []string{}
|
||
|
|
}
|
||
|
|
return map[string]any{
|
||
|
|
"type": "object",
|
||
|
|
"properties": properties,
|
||
|
|
"required": required,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func Str(description string) map[string]any {
|
||
|
|
return map[string]any{"type": "string", "description": description}
|
||
|
|
}
|
||
|
|
|
||
|
|
func Int(description string) map[string]any {
|
||
|
|
return map[string]any{"type": "integer", "description": description}
|
||
|
|
}
|
||
|
|
|
||
|
|
func Bool(description string) map[string]any {
|
||
|
|
return map[string]any{"type": "boolean", "description": description}
|
||
|
|
}
|
||
|
|
|
||
|
|
func Enum(description string, values ...any) map[string]any {
|
||
|
|
return map[string]any{"type": "string", "description": description, "enum": values}
|
||
|
|
}
|
||
|
|
|
||
|
|
func StrArray(description string) map[string]any {
|
||
|
|
return map[string]any{
|
||
|
|
"type": "array",
|
||
|
|
"description": description,
|
||
|
|
"items": map[string]any{"type": "string"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ArgError is what a handler returns when the model called it wrongly. Phrased as
|
||
|
|
// an instruction rather than a complaint, because the reader is a model deciding
|
||
|
|
// what to do next.
|
||
|
|
func ArgError(format string, args ...any) error {
|
||
|
|
return fmt.Errorf(format, args...)
|
||
|
|
}
|