MCP stdio server with 6 tools

- internal/mcp: minimal JSON-RPC 2.0 over newline-delimited JSON, stdio
  transport. Handles initialize / tools/list / tools/call / ping /
  notifications. No deps — stdlib only.
- cmd: 'mithril-go mcp' subcommand brings up the server. Tools:
    mithril_info
    mithril_list_snapshots
    mithril_show_snapshot
    mithril_get_certificate
    mithril_walk_cert_chain
    mithril_verify_genesis
- verified end-to-end against mainnet via tools/call: verify_genesis walks
  the 89-cert chain and returns verified=true

Any MCP client (Claude Code, Cursor, Zed, etc.) can now point at this
binary and get a discoverable, typed tool surface.
This commit is contained in:
Kayos 2026-04-23 15:40:34 -07:00
parent 20853a9d44
commit 8e3a46e90f
3 changed files with 512 additions and 1 deletions

253
internal/mcp/server.go Normal file
View file

@ -0,0 +1,253 @@
// Package mcp implements a minimal Model Context Protocol server over stdio.
//
// MCP is a JSON-RPC 2.0 protocol for AI agents to discover and call tools.
// The stdio transport is newline-delimited JSON: one JSON object per line
// on stdin, one JSON response per line on stdout. All diagnostics go to
// stderr — stdout is reserved for the protocol channel.
//
// Spec: https://modelcontextprotocol.io
package mcp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"sync"
)
// ProtocolVersion — the MCP version we implement. Clients negotiate via
// the initialize handshake; if they ask for a different version we accept
// theirs and hope for the best (graceful degradation).
const ProtocolVersion = "2024-11-05"
// ServerInfo identifies the server to the client.
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// ToolHandler is the signature for a registered tool's Go implementation.
// args is the decoded arguments map (types per the tool's input schema);
// the returned value is JSON-encoded as the tool's result.
type ToolHandler func(ctx context.Context, args map[string]any) (any, error)
// Tool describes a single callable function — name, description, JSON
// schema for inputs, and the Go handler.
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
Handler ToolHandler `json:"-"`
}
// Server is the stdio MCP loop.
type Server struct {
info ServerInfo
tools []Tool
mu sync.Mutex // guards writes to stdout
w *bufio.Writer
}
func New(info ServerInfo) *Server {
return &Server{
info: info,
w: bufio.NewWriter(os.Stdout),
}
}
// RegisterTool adds a tool to the server's tool list.
func (s *Server) RegisterTool(t Tool) {
s.tools = append(s.tools, t)
}
// Run reads line-delimited JSON-RPC 2.0 requests from stdin and writes
// responses to stdout until EOF or the context is canceled.
func (s *Server) Run(ctx context.Context) error {
scanner := bufio.NewScanner(os.Stdin)
// 10 MiB line buffer — MCP payloads can get chunky (big tool lists,
// long tool results); default 64 KiB is too tight.
buf := make([]byte, 0, 1<<16)
scanner.Buffer(buf, 10*1024*1024)
logf := func(format string, a ...any) {
fmt.Fprintf(os.Stderr, "[mcp] "+format+"\n", a...)
}
for scanner.Scan() {
if err := ctx.Err(); err != nil {
return err
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
s.dispatch(ctx, line, logf)
}
return scanner.Err()
}
// jsonrpcRequest is the subset of JSON-RPC 2.0 we care about.
type jsonrpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"` // may be absent (notification) or null/number/string
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type jsonrpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type jsonrpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
const (
errParse = -32700
errInvalidReq = -32600
errMethodNotFnd = -32601
errInvalidParms = -32602
errInternal = -32603
)
func (s *Server) writeResponse(resp jsonrpcResponse) {
resp.JSONRPC = "2.0"
b, err := json.Marshal(resp)
if err != nil {
fmt.Fprintf(os.Stderr, "[mcp] marshal response: %v\n", err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.w.Write(b)
s.w.WriteByte('\n')
s.w.Flush()
}
func (s *Server) writeError(id json.RawMessage, code int, msg string) {
s.writeResponse(jsonrpcResponse{ID: id, Error: &jsonrpcError{Code: code, Message: msg}})
}
func (s *Server) dispatch(ctx context.Context, line []byte, logf func(string, ...any)) {
var req jsonrpcRequest
if err := json.Unmarshal(line, &req); err != nil {
s.writeError(nil, errParse, "parse error: "+err.Error())
return
}
if req.JSONRPC != "2.0" {
s.writeError(req.ID, errInvalidReq, "jsonrpc must be 2.0")
return
}
// Notifications (no id) don't get responses.
isNotification := len(req.ID) == 0
switch req.Method {
case "initialize":
s.handleInitialize(req)
case "initialized", "notifications/initialized":
// Acknowledge; no response for notifications.
if !isNotification {
s.writeResponse(jsonrpcResponse{ID: req.ID, Result: struct{}{}})
}
case "tools/list":
s.handleToolsList(req)
case "tools/call":
s.handleToolsCall(ctx, req, logf)
case "ping":
s.writeResponse(jsonrpcResponse{ID: req.ID, Result: struct{}{}})
default:
if !isNotification {
s.writeError(req.ID, errMethodNotFnd, "method not found: "+req.Method)
}
}
}
func (s *Server) handleInitialize(req jsonrpcRequest) {
result := map[string]any{
"protocolVersion": ProtocolVersion,
"serverInfo": s.info,
"capabilities": map[string]any{
"tools": map[string]any{
// Announce we ship a tool list but don't dynamically change it.
"listChanged": false,
},
},
}
s.writeResponse(jsonrpcResponse{ID: req.ID, Result: result})
}
func (s *Server) handleToolsList(req jsonrpcRequest) {
// MCP expects `{"tools": [...]}`. Strip the Go-only Handler field via the JSON tag `-`.
s.writeResponse(jsonrpcResponse{ID: req.ID, Result: map[string]any{"tools": s.tools}})
}
func (s *Server) handleToolsCall(ctx context.Context, req jsonrpcRequest, logf func(string, ...any)) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
s.writeError(req.ID, errInvalidParms, "parse params: "+err.Error())
return
}
var tool *Tool
for i := range s.tools {
if s.tools[i].Name == p.Name {
tool = &s.tools[i]
break
}
}
if tool == nil {
s.writeError(req.ID, errMethodNotFnd, "unknown tool: "+p.Name)
return
}
result, err := tool.Handler(ctx, p.Arguments)
if err != nil {
// MCP convention: tool errors are result payloads with isError=true,
// not JSON-RPC errors (those are for protocol failures).
s.writeResponse(jsonrpcResponse{ID: req.ID, Result: map[string]any{
"isError": true,
"content": []map[string]any{
{"type": "text", "text": err.Error()},
},
}})
return
}
// Serialize the result to JSON and wrap as a text content block.
payload, err := json.MarshalIndent(result, "", " ")
if err != nil {
s.writeError(req.ID, errInternal, "marshal result: "+err.Error())
return
}
s.writeResponse(jsonrpcResponse{ID: req.ID, Result: map[string]any{
"content": []map[string]any{
{"type": "text", "text": string(payload)},
},
}})
}
// asString is a small helper for tool handlers pulling string args.
func asString(args map[string]any, key string) string {
if v, ok := args[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// ArgString is the public form; exposed for tool handlers defined outside
// this package.
var ArgString = asString
// Discard is a handy helper to silence unused-import complaints.
var _ io.Writer = io.Discard