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

View file

@ -24,6 +24,7 @@ import (
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/artifact"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify"
) )
@ -63,6 +64,8 @@ func main() {
os.Exit(cmdVerify(ctx, args)) os.Exit(cmdVerify(ctx, args))
case "cert": case "cert":
os.Exit(cmdCert(ctx, args)) os.Exit(cmdCert(ctx, args))
case "mcp":
os.Exit(cmdMCP(ctx, args))
case "info": case "info":
os.Exit(cmdInfo(args)) os.Exit(cmdInfo(args))
case "version", "--version", "-v": case "version", "--version", "-v":
@ -87,7 +90,8 @@ Commands:
show Show detail for one snapshot (hash or "latest") show Show detail for one snapshot (hash or "latest")
cert Show a certificate or walk the chain back to genesis cert Show a certificate or walk the chain back to genesis
download Download a snapshot to a target directory download Download a snapshot to a target directory
verify Verify an already-downloaded snapshot (not yet implemented) verify Verify a certificate (genesis Ed25519 working; STM BLS pending)
mcp Run as a Model Context Protocol server over stdio
info Show network + aggregator info info Show network + aggregator info
version Print version version Print version
help Show this help help Show this help
@ -467,6 +471,23 @@ func cmdInfo(args []string) int {
return exitOK return exitOK
} }
func cmdMCP(ctx context.Context, args []string) int {
s := mcp.New(mcp.ServerInfo{
Name: "mithril-go",
Version: version,
})
registerMCPTools(s)
fmt.Fprintf(os.Stderr, "[mcp] mithril-go %s MCP server ready on stdio (%d tools)\n", version, 6)
if err := s.Run(ctx); err != nil {
if err == context.Canceled || err == context.DeadlineExceeded {
return exitCanceled
}
fmt.Fprintln(os.Stderr, "mcp:", err)
return exitGeneric
}
return exitOK
}
func resolveSnapshot(ctx context.Context, c *aggregator.Client, hashOrLatest string) (*aggregator.CardanoDBSnapshot, error) { func resolveSnapshot(ctx context.Context, c *aggregator.Client, hashOrLatest string) (*aggregator.CardanoDBSnapshot, error) {
if hashOrLatest == "latest" { if hashOrLatest == "latest" {
snaps, err := c.ListCardanoDBSnapshots(ctx) snaps, err := c.ListCardanoDBSnapshots(ctx)

237
cmd/mithril-go/mcp.go Normal file
View file

@ -0,0 +1,237 @@
package main
import (
"context"
"fmt"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/aggregator"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/mcp"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks"
"git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify"
)
// networkArgOrDefault pulls a "network" string from the args map, defaulting
// to "preprod" if absent. Returns the resolved network + client.
func networkArgOrDefault(args map[string]any) (networks.Network, *aggregator.Client, error) {
name := mcp.ArgString(args, "network")
if name == "" {
name = "preprod"
}
n, ok := networks.ByName(name)
if !ok {
return networks.Network{}, nil, fmt.Errorf("unknown network: %s", name)
}
return n, aggregator.New(n.AggregatorURL), nil
}
// networkEnum is a shared JSON Schema fragment used across tools.
var networkEnum = map[string]any{
"type": "string",
"enum": []string{"mainnet", "preprod", "preview"},
"default": "preprod",
"description": "Cardano network",
}
func registerMCPTools(s *mcp.Server) {
s.RegisterTool(mcp.Tool{
Name: "mithril_info",
Description: "Get aggregator URL and genesis verification key for a Cardano network.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"network": networkEnum},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
n, _, err := networkArgOrDefault(args)
if err != nil {
return nil, err
}
return map[string]any{
"network": n.Name,
"aggregator": n.AggregatorURL,
"genesis_verify_key": n.GenesisVerifyKey,
}, nil
},
})
s.RegisterTool(mcp.Tool{
Name: "mithril_list_snapshots",
Description: "List available cardano-database snapshots on a Mithril aggregator, newest first.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"network": networkEnum},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
_, c, err := networkArgOrDefault(args)
if err != nil {
return nil, err
}
snaps, err := c.ListCardanoDBSnapshots(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(snaps), "snapshots": snaps}, nil
},
})
s.RegisterTool(mcp.Tool{
Name: "mithril_show_snapshot",
Description: "Fetch detail for a specific cardano-database snapshot. Pass 'latest' for the newest.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"network": networkEnum,
"hash": map[string]any{
"type": "string",
"description": "Snapshot hash or the literal 'latest'",
"default": "latest",
},
},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
_, c, err := networkArgOrDefault(args)
if err != nil {
return nil, err
}
hash := mcp.ArgString(args, "hash")
if hash == "" {
hash = "latest"
}
return resolveSnapshot(ctx, c, hash)
},
})
s.RegisterTool(mcp.Tool{
Name: "mithril_get_certificate",
Description: "Fetch a Mithril certificate by hash. Pass 'head' to resolve via the latest snapshot's certificate_hash.",
InputSchema: map[string]any{
"type": "object",
"required": []string{"hash"},
"properties": map[string]any{
"network": networkEnum,
"hash": map[string]any{
"type": "string",
"description": "Certificate hash or the literal 'head'",
},
},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
_, c, err := networkArgOrDefault(args)
if err != nil {
return nil, err
}
hash := mcp.ArgString(args, "hash")
if hash == "" {
return nil, fmt.Errorf("hash is required")
}
if hash == "head" {
snap, err := resolveSnapshot(ctx, c, "latest")
if err != nil {
return nil, err
}
hash = snap.CertificateHash
}
return c.GetCertificate(ctx, hash)
},
})
s.RegisterTool(mcp.Tool{
Name: "mithril_walk_cert_chain",
Description: "Walk the certificate chain from head back to the genesis cert. Returns each cert's hash, epoch, and genesis-or-not role.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"network": networkEnum,
"head_hash": map[string]any{
"type": "string",
"description": "Starting certificate hash; defaults to the latest snapshot's cert",
},
"max_depth": map[string]any{
"type": "integer",
"default": 1024,
},
},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
_, c, err := networkArgOrDefault(args)
if err != nil {
return nil, err
}
head := mcp.ArgString(args, "head_hash")
if head == "" {
snap, err := resolveSnapshot(ctx, c, "latest")
if err != nil {
return nil, err
}
head = snap.CertificateHash
}
maxDepth := 1024
if v, ok := args["max_depth"]; ok {
if f, ok := v.(float64); ok {
maxDepth = int(f)
}
}
chain, err := c.CertChain(ctx, head, maxDepth)
if err != nil {
return nil, err
}
brief := make([]map[string]any, len(chain))
for i, ct := range chain {
brief[i] = map[string]any{
"hash": ct.Hash,
"previous_hash": ct.PreviousHash,
"epoch": ct.Epoch,
"is_genesis": ct.GenesisSignature != "",
}
}
return map[string]any{"length": len(brief), "certs": brief}, nil
},
})
s.RegisterTool(mcp.Tool{
Name: "mithril_verify_genesis",
Description: "Walk the certificate chain back to the genesis cert and verify its Ed25519 signature against the network's " +
"baked-in genesis verification key. Returns verified: true|false with context.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"network": networkEnum},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
n, c, err := networkArgOrDefault(args)
if err != nil {
return nil, err
}
snap, err := resolveSnapshot(ctx, c, "latest")
if err != nil {
return nil, err
}
chain, err := c.CertChain(ctx, snap.CertificateHash, 2048)
if err != nil {
return nil, err
}
if len(chain) == 0 {
return nil, fmt.Errorf("empty chain")
}
gen := chain[len(chain)-1]
if gen.GenesisSignature == "" {
return nil, fmt.Errorf("chain tail is not a genesis certificate")
}
vk, err := verify.DecodeGenesisVerifyKey(n.GenesisVerifyKey)
if err != nil {
return nil, err
}
verr := verify.GenesisFromJSON(vk, gen.SignedMessage, gen.GenesisSignature, gen.ProtocolMessage)
out := map[string]any{
"network": n.Name,
"cert_hash": gen.Hash,
"epoch": gen.Epoch,
"signed_message": gen.SignedMessage,
"chain_length": len(chain),
"verified": verr == nil,
}
if verr != nil {
out["error"] = verr.Error()
}
return out, nil
},
})
}

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