mirror of
https://github.com/luxfi/mcp.git
synced 2026-09-24 00:25:04 +00:00
Split the flat governance MCP package into three independently-complete
packages so the generic parts are written ONCE and future MCP surfaces
(mining, treasury, bridge) are just a bag of read tools, never re-implementing
transport. One and only one way:
github.com/luxfi/mcp (root) - domain-agnostic READ-ONLY MCP
transport (Serve, Tool, Surface,
dispatch, per-call timeout, line
cap, panic->JSON-RPC recover) +
ChainObservation. Imports NO geth.
github.com/luxfi/mcp/evmread - the SOLE go-ethereum importer: a
minimal read-only Caller, Contract,
Call/CallStruct[T], and the
per-request Bounded call ceiling.
github.com/luxfi/mcp/governance - a Surface of the 8 read tools;
reads via evmread, contributes
[]mcp.Tool to the shared transport.
github.com/luxfi/mcp/cmd/lux-mcp - the binary (was aivm-gov-mcp).
What this generalizes (the real point, not just a move):
- Tool is now a value carrying its own Read closure; Serve composes
...Surface and rejects duplicate / empty / nil-Read tools.
- ChainObservation lifted to the root and WIRED INTO ALL 8 TOOLS: every read
returns (value, *ChainObservation) so an operator-LLM's signed verdict can
bind Observation.Hash() and re-verify the chain state it claims to have seen
- domain-independently. (Closes the previously-deferred MED-8.)
- The read-only AST gate is now MODULE-WIDE: it proves go-ethereum is imported
by exactly one package (evmread), no package imports bind/geth-crypto/
geth-rpc/reflect, the string-fragment denylist holds, and evmread.Caller's
method set is exactly {CallContract,ChainID,BlockNumber,HeaderByNumber}.
Behavior-preserving and adversarially re-verified (blue->red loop):
- HIGH-1 bonded-quorum tally still matches AIGovernor.settle()'s _bonded
filter exactly (PoC drives real compiled Solidity via core/vm/runtime).
- Parity tests remain real (independent ABI decode vs MCP output), struct
mirrors still field-for-field aligned with luxfi/standard.
- Per-request bounded ceiling cannot leak/bypass; line cap + timeout + recover
preserved. Read-only invariant intact and STRONGER (adding a write method to
Caller is now a compile failure).
- Tests: 33 pass, 0 fail, 0 skip across the 3 packages (was 23 pre-split;
none dropped, several strengthened); go build / vet / -race clean.
174 lines
5.8 KiB
Go
174 lines
5.8 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package governance
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/mcp"
|
|
)
|
|
|
|
// TestStdioServeRoundTrip exercises the ACTUAL JSON-RPC 2.0 stdio loop (Serve),
|
|
// not just the in-process CallTool: it feeds initialize, tools/list, and a
|
|
// tools/call(chain_state) over a pipe and asserts the framed responses. This proves
|
|
// the transport envelope (initialize handshake, tools/list shape, tools/call content
|
|
// block) works end-to-end against the real EVM-backed reads.
|
|
func TestStdioServeRoundTrip(t *testing.T) {
|
|
keys := genKeys(t, 1)
|
|
_, env := newEVMChain(t, keys)
|
|
srv := env.mcpServer()
|
|
|
|
// Three newline-delimited requests + a notification (no id, no response).
|
|
requests := strings.Join([]string{
|
|
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`,
|
|
`{"jsonrpc":"2.0","method":"notifications/initialized"}`,
|
|
`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`,
|
|
`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"chain_state","arguments":{}}}`,
|
|
}, "\n") + "\n"
|
|
|
|
var out strings.Builder
|
|
if err := srv.Serve(context.Background(), strings.NewReader(requests), &out); err != nil {
|
|
t.Fatalf("Serve: %v", err)
|
|
}
|
|
|
|
resps := decodeLines(t, out.String())
|
|
// initialize + tools/list + tools/call = 3 responses; the notification yields none.
|
|
if len(resps) != 3 {
|
|
t.Fatalf("expected 3 responses (notification yields none), got %d:\n%s", len(resps), out.String())
|
|
}
|
|
|
|
// 1) initialize: serverInfo.name == ServerName, protocolVersion set.
|
|
init := resps[0]
|
|
if idNum(init["id"]) != 1 {
|
|
t.Fatalf("initialize response id=%v, want 1", init["id"])
|
|
}
|
|
result := init["result"].(map[string]interface{})
|
|
si := result["serverInfo"].(map[string]interface{})
|
|
if si["name"] != mcp.ServerName {
|
|
t.Fatalf("serverInfo.name=%v, want %s", si["name"], mcp.ServerName)
|
|
}
|
|
if result["protocolVersion"] != mcp.ProtocolVersion {
|
|
t.Fatalf("protocolVersion=%v, want %s", result["protocolVersion"], mcp.ProtocolVersion)
|
|
}
|
|
|
|
// 2) tools/list: exactly 8 tools.
|
|
list := resps[1]["result"].(map[string]interface{})
|
|
tools := list["tools"].([]interface{})
|
|
if len(tools) != 8 {
|
|
t.Fatalf("tools/list returned %d tools, want 8", len(tools))
|
|
}
|
|
// Each tool descriptor must carry name + inputSchema (the MCP shape).
|
|
for _, tl := range tools {
|
|
td := tl.(map[string]interface{})
|
|
if _, ok := td["name"].(string); !ok {
|
|
t.Fatalf("tool descriptor missing name: %v", td)
|
|
}
|
|
if _, ok := td["inputSchema"].(map[string]interface{}); !ok {
|
|
t.Fatalf("tool descriptor missing inputSchema: %v", td)
|
|
}
|
|
}
|
|
|
|
// 3) tools/call(chain_state): content[0].text is a JSON object carrying the tool VALUE
|
|
// plus the verifiable observation. The chain_state fields live under "value"; the
|
|
// observation under "observation" (the MED-8 wiring — every result is bindable).
|
|
call := resps[2]["result"].(map[string]interface{})
|
|
content := call["content"].([]interface{})
|
|
if len(content) != 1 {
|
|
t.Fatalf("tools/call content length=%d, want 1", len(content))
|
|
}
|
|
block := content[0].(map[string]interface{})
|
|
if block["type"] != "text" {
|
|
t.Fatalf("content type=%v, want text", block["type"])
|
|
}
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal([]byte(block["text"].(string)), &payload); err != nil {
|
|
t.Fatalf("chain_state text not JSON: %v", err)
|
|
}
|
|
state, ok := payload["value"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("tool result missing value object: %v", payload)
|
|
}
|
|
wantChainID, _ := env.c.ChainID(context.Background())
|
|
if state["chainId"] != wantChainID.String() {
|
|
t.Fatalf("chain_state chainId=%v, want %s", state["chainId"], wantChainID)
|
|
}
|
|
if _, ok := state["blockHash"].(string); !ok {
|
|
t.Fatalf("chain_state missing blockHash: %v", state)
|
|
}
|
|
if _, ok := state["blockNumber"]; !ok {
|
|
t.Fatalf("chain_state missing blockNumber: %v", state)
|
|
}
|
|
|
|
// The observation must be present and carry a binding hash + the tool name.
|
|
obs, ok := payload["observation"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("tool result missing observation object: %v", payload)
|
|
}
|
|
if obs["tool"] != "chain_state" {
|
|
t.Fatalf("observation tool=%v, want chain_state", obs["tool"])
|
|
}
|
|
if _, ok := obs["hash"].(string); !ok {
|
|
t.Fatalf("observation missing binding hash: %v", obs)
|
|
}
|
|
}
|
|
|
|
// TestStdioUnknownToolIsError verifies an unknown tool returns an MCP tool error
|
|
// (isError=true content), not a transport crash.
|
|
func TestStdioUnknownToolIsError(t *testing.T) {
|
|
keys := genKeys(t, 1)
|
|
_, env := newEVMChain(t, keys)
|
|
srv := env.mcpServer()
|
|
|
|
req := `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"no_such_tool","arguments":{}}}` + "\n"
|
|
var out strings.Builder
|
|
if err := srv.Serve(context.Background(), strings.NewReader(req), &out); err != nil {
|
|
t.Fatalf("Serve: %v", err)
|
|
}
|
|
resps := decodeLines(t, out.String())
|
|
if len(resps) != 1 {
|
|
t.Fatalf("want 1 response, got %d", len(resps))
|
|
}
|
|
// An unknown tool is a JSON-RPC method-not-found error (we reject before dispatch).
|
|
if resps[0]["error"] == nil {
|
|
t.Fatalf("expected an error for unknown tool, got: %v", resps[0])
|
|
}
|
|
}
|
|
|
|
// decodeLines parses newline-delimited JSON-RPC responses.
|
|
func decodeLines(t *testing.T, s string) []map[string]interface{} {
|
|
t.Helper()
|
|
var out []map[string]interface{}
|
|
r := bufio.NewReader(strings.NewReader(s))
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if t := strings.TrimSpace(line); t != "" {
|
|
var m map[string]interface{}
|
|
if jerr := json.Unmarshal([]byte(t), &m); jerr != nil {
|
|
panic("bad response line: " + t)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("read line: %v", err)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// idNum extracts a numeric JSON-RPC id (decoded as float64).
|
|
func idNum(v interface{}) int {
|
|
if f, ok := v.(float64); ok {
|
|
return int(f)
|
|
}
|
|
return -1
|
|
}
|