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:
parent
20853a9d44
commit
8e3a46e90f
3 changed files with 512 additions and 1 deletions
|
|
@ -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
237
cmd/mithril-go/mcp.go
Normal 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
253
internal/mcp/server.go
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue