Files
verdande/internal/mcp/mcp.go
T
Kristian WindandClaude Opus 5 6b19ed062d feat(phase5): MCP server, CalDAV, mail-to-task, Gmail settings and an AI layer
The MCP server speaks JSON-RPC over Streamable HTTP with the six tools
the brief names, authenticated by a personal API token — so a connector
reaches exactly what its owner reaches and there is no second identity
to reason about. A failed tool call returns a tool error, not a protocol
error: a model can recover from the first and can do nothing with the
second.

CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can
read, create and complete tasks. Clients authenticate with Basic using
an API token as the password, never the account password — a client
stores what it is given, and what it is given should not be able to sign
in to the app. Completed tasks stay in the collection: a client that
stops seeing one treats it as deleted and drops its own record of having
done it.

The AI layer is one interface with adapters for Anthropic, OpenAI,
Google and any OpenAI-compatible endpoint. That last one is why the
abstraction exists: an integration that only spoke to a hosted API would
be useless to somebody running their own models. Every feature degrades
to "off" when nothing is configured, and the stored API key is never
returned to the settings page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:53:53 +02:00

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, &params); 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...)
}