diff --git a/cmd/mithril-go/main.go b/cmd/mithril-go/main.go index 024b340..0edeade 100644 --- a/cmd/mithril-go/main.go +++ b/cmd/mithril-go/main.go @@ -24,6 +24,7 @@ import ( "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/mcp" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/networks" "git.sulkta.coop/Sulkta-Coop/mithril-go/internal/verify" ) @@ -63,6 +64,8 @@ func main() { os.Exit(cmdVerify(ctx, args)) case "cert": os.Exit(cmdCert(ctx, args)) + case "mcp": + os.Exit(cmdMCP(ctx, args)) case "info": os.Exit(cmdInfo(args)) case "version", "--version", "-v": @@ -87,7 +90,8 @@ Commands: show Show detail for one snapshot (hash or "latest") cert Show a certificate or walk the chain back to genesis 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 version Print version help Show this help @@ -467,6 +471,23 @@ func cmdInfo(args []string) int { 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) { if hashOrLatest == "latest" { snaps, err := c.ListCardanoDBSnapshots(ctx) diff --git a/cmd/mithril-go/mcp.go b/cmd/mithril-go/mcp.go new file mode 100644 index 0000000..290d4d3 --- /dev/null +++ b/cmd/mithril-go/mcp.go @@ -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 + }, + }) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..21d97ab --- /dev/null +++ b/internal/mcp/server.go @@ -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