mcp: decomplect into 3 orthogonal packages (transport / evmread / governance)

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.
This commit is contained in:
zeekay
2026-06-24 23:46:17 -07:00
parent e0b32e58ab
commit 0d9516147f
17 changed files with 1855 additions and 1136 deletions
@@ -1,10 +1,11 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Command aivm-gov-mcp is the drop-in, READ-ONLY governance MCP server binary for
// the Lux AIVM (A-Chain) governance stack. It reads its EVM RPC URL and the four
// deployed contract addresses from the environment and serves the eight read tools
// over stdio (JSON-RPC 2.0). It holds no key and submits no transaction.
// Command lux-mcp is the drop-in, READ-ONLY governance MCP server binary for the Lux
// AIVM (A-Chain) governance stack. It reads its EVM RPC URL and the four deployed
// contract addresses from the environment and serves the governance read tools over
// stdio (JSON-RPC 2.0) via the shared mcp transport. It holds no key and submits no
// transaction.
//
// Environment:
//
@@ -16,7 +17,7 @@
//
// Drop into an MCP client (hanzo-dev / claude desktop) as a stdio server:
//
// { "command": "aivm-gov-mcp", "env": { "LUX_GOV_EVM_RPC": "https://…/ext/bc/C/rpc", … } }
// { "command": "lux-mcp", "env": { "LUX_GOV_EVM_RPC": "https://…/ext/bc/C/rpc", … } }
package main
import (
@@ -27,12 +28,14 @@ import (
"syscall"
"github.com/luxfi/geth/common"
governance "github.com/luxfi/mcp/governance"
"github.com/luxfi/mcp"
"github.com/luxfi/mcp/governance"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "aivm-gov-mcp:", err)
fmt.Fprintln(os.Stderr, "lux-mcp:", err)
os.Exit(1)
}
}
@@ -46,11 +49,13 @@ func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv, err := governance.New(ctx, cfg)
surface, err := governance.New(ctx, cfg)
if err != nil {
return err
}
return srv.Serve(ctx, os.Stdin, os.Stdout)
// One transport, one surface: mcp.Serve runs the read-only stdio loop over the
// governance tools. Adding another domain is just another argument here.
return mcp.Serve(ctx, os.Stdin, os.Stdout, surface)
}
func configFromEnv() (governance.Config, error) {
+100
View File
@@ -0,0 +1,100 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mcp_test
import (
"context"
"sort"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/mcp"
"github.com/luxfi/mcp/governance"
)
// pingSurface is a tiny, chain-free Surface contributing ONE tool. It exists to prove the
// transport is domain-agnostic: it composes the real governance Surface with an unrelated
// surface and dispatches to each. (Decomplect proof: the transport knows nothing about any
// domain — a domain is just a []Tool.)
type pingSurface struct{}
func (pingSurface) Tools() []mcp.Tool {
return []mcp.Tool{{
Name: "ping",
Description: "returns pong",
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Read: func(_ context.Context, _ map[string]interface{}) (interface{}, *mcp.ChainObservation, error) {
return map[string]interface{}{"pong": true}, nil, nil
},
}}
}
// TestTransportComposesMultipleSurfaces proves the ONE transport serves TWO independent
// surfaces at once: tools/list is the UNION of both surfaces' tools, and tools/call
// dispatches to the correct surface. This is the composition guarantee — the transport is
// generic over surfaces, not bound to governance.
func TestTransportComposesMultipleSurfaces(t *testing.T) {
addr := common.HexToAddress("0x000000000000000000000000000000000000dEaD")
// The governance Surface registers its 8 tools regardless of the caller (Tools() and the
// composition assertions below do not read the chain), so a non-dialing fake caller is
// enough to construct it here.
gov, err := governance.NewWithCaller(noopCaller{}, governance.Config{
AIParams: addr,
AIGovernor: addr,
AIThoughtRegistry: addr,
AIReputation: addr,
})
if err != nil {
t.Fatalf("build governance surface: %v", err)
}
srv, err := mcp.NewServer(gov, pingSurface{})
if err != nil {
t.Fatalf("compose surfaces: %v", err)
}
// tools/list is the union: 8 governance tools + 1 ping = 9, all distinct.
got := make([]string, 0, len(srv.Tools()))
for _, tl := range srv.Tools() {
got = append(got, tl.Name)
}
sort.Strings(got)
want := []string{
"chain_state", "operator_reputation", "param_history", "param_value",
"pending_operations", "ping", "quorum_status", "receipt_lookup", "thought_status",
}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("composed tools/list has %d tools, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("composed tool[%d]=%q, want %q (full: %v)", i, got[i], want[i], got)
}
}
// Dispatch to the NON-governance surface's tool proves the transport routes by name
// across surfaces (no chain needed for ping).
res, err := srv.CallTool(context.Background(), "ping", nil)
if err != nil {
t.Fatalf("dispatch ping: %v", err)
}
m, ok := res.(map[string]interface{})
if !ok || m["pong"] != true {
t.Fatalf("ping result=%v, want {pong:true}", res)
}
// Dispatching a governance tool by name also resolves through the SAME transport. We use
// a tool whose error path is reached without a live chain read returning bad bytes:
// param_value with a malformed argument fails at argument validation (boundary), proving
// the governance tool was dispatched (not "unknown tool").
_, err = srv.CallTool(context.Background(), "param_value", map[string]interface{}{})
if err == nil {
t.Fatal("expected param_value to fail on missing args (proving it dispatched), got nil")
}
if err.Error() == `mcp: unknown tool "param_value"` {
t.Fatalf("param_value was not dispatched through the composed transport: %v", err)
}
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package evmread is the SOLE EVM read adapter for the governance MCP stack: it is
// the one and only package that imports go-ethereum (luxfi/geth). It exposes a
// minimal, READ-ONLY chain surface (Caller) and the calldata pack/eth_call/unpack
// helpers a domain uses to read view functions — and nothing that can sign or submit
// a transaction.
//
// Decomplecting (Rich Hickey): the dependency a read tool actually has is "a thing
// that can read the chain", not "a URL" and not "go-ethereum". This package names
// exactly that thing (Caller) and the read verbs over it (Contract.Call,
// CallStruct), so a domain package (governance) can compose chain reads WITHOUT
// importing geth at all. The read-only guarantee is anchored here: Caller carries no
// Send/SignTx/Transactor method, so no consumer of this package has a calldata path
// that could change chain state.
package evmread
import (
"context"
"fmt"
"math/big"
"strings"
"sync/atomic"
"github.com/luxfi/geth/accounts/abi"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
gethereum "github.com/luxfi/geth"
)
// Caller is the minimal, READ-ONLY chain surface the governance reads need. Both the
// production *ethclient.Client and an in-process simulated/test backend's Client
// satisfy it. It deliberately exposes NO transaction-sending method: there is no
// Send/SendTransaction here, so no caller of this package can submit a tx through it.
// (The read-only gate in luxfi/mcp asserts this method set is EXACTLY these four.)
type Caller interface {
CallContract(ctx context.Context, call gethereum.CallMsg, blockNumber *big.Int) ([]byte, error)
ChainID(ctx context.Context) (*big.Int, error)
BlockNumber(ctx context.Context) (uint64, error)
HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error)
}
// Contract pairs a parsed ABI with the address it is deployed at. A read tool packs a
// method's calldata, sends it via Caller.CallContract (eth_call), and unpacks the
// return. There is exactly one chain-access verb here (CallContract), which is the
// read-only eth_call — no path packs or sends a transaction.
type Contract struct {
abi abi.ABI
addr common.Address
}
// NewContract parses the JSON ABI and binds it to addr. The ABI should declare only
// the VIEW functions the reads touch; this package never packs a state-mutating
// method, so there is no calldata path that could change chain state.
func NewContract(jsonABI string, addr common.Address) (*Contract, error) {
parsed, err := abi.JSON(strings.NewReader(jsonABI))
if err != nil {
return nil, fmt.Errorf("evmread: parse ABI: %w", err)
}
return &Contract{abi: parsed, addr: addr}, nil
}
// Call packs `method`(args...), executes it as a read-only eth_call against the bound
// address at the latest block, and returns the decoded output values.
func (c *Contract) Call(ctx context.Context, ec Caller, method string, args ...interface{}) ([]interface{}, error) {
return c.CallAt(ctx, ec, nil, method, args...)
}
// CallAt is Call pinned to a specific block (nil = latest). Pinning lets a tool that
// issues SEVERAL reads which must agree (e.g. a quorum tally reading verdicts and then
// each operator's bond) take them all at ONE block, so a state change between the reads
// — a bond withdraw racing the tally — cannot produce an inconsistent snapshot. An
// in-process test backend may ignore the block arg (it has only latest state); the
// production *ethclient.Client honors it.
func (c *Contract) CallAt(ctx context.Context, ec Caller, block *big.Int, method string, args ...interface{}) ([]interface{}, error) {
m, ok := c.abi.Methods[method]
if !ok {
return nil, fmt.Errorf("evmread: unknown method %q", method)
}
in, err := c.abi.Pack(method, args...)
if err != nil {
return nil, fmt.Errorf("evmread: pack %s: %w", method, err)
}
out, err := ec.CallContract(ctx, gethereum.CallMsg{To: &c.addr, Data: in}, block)
if err != nil {
return nil, fmt.Errorf("evmread: eth_call %s @ %s: %w", method, c.addr.Hex(), err)
}
vals, err := m.Outputs.Unpack(out)
if err != nil {
return nil, fmt.Errorf("evmread: unpack %s: %w", method, err)
}
return vals, nil
}
// CallStruct packs `method`(args...), runs the read-only eth_call, and unpacks a SINGLE
// struct (or slice-of-struct) return into a value of type T, mapping the Solidity tuple
// field-for-field by position.
//
// geth's abi.UnpackIntoInterface special-cases a single output: it treats the
// destination as a wrapper struct and writes the unpacked value into its FIRST field
// (Arguments.copyAtomic). So we hand it a one-field wrapper whose field is T; geth then
// field-copies the tuple into it by index — which is exactly why T's field order MUST
// mirror the Solidity struct.
func CallStruct[T any](ctx context.Context, c *Contract, ec Caller, method string, args ...interface{}) (T, error) {
return CallStructAt[T](ctx, c, ec, nil, method, args...)
}
// CallStructAt is CallStruct pinned to a specific block (nil = latest); see CallAt for
// why pinning matters. A quorum tally uses it to read getThought / getVerdicts at the
// same block it reads the bonds, for a consistent settle-equivalent snapshot.
func CallStructAt[T any](ctx context.Context, c *Contract, ec Caller, block *big.Int, method string, args ...interface{}) (T, error) {
var wrap struct{ V T }
in, err := c.abi.Pack(method, args...)
if err != nil {
return wrap.V, fmt.Errorf("evmread: pack %s: %w", method, err)
}
out, err := ec.CallContract(ctx, gethereum.CallMsg{To: &c.addr, Data: in}, block)
if err != nil {
return wrap.V, fmt.Errorf("evmread: eth_call %s @ %s: %w", method, c.addr.Hex(), err)
}
if err := c.abi.UnpackIntoInterface(&wrap, method, out); err != nil {
return wrap.V, fmt.Errorf("evmread: unpack %s: %w", method, err)
}
return wrap.V, nil
}
// Bounded wraps a read-only Caller and fails after `max` CallContract calls, bounding
// the eth_call fan-out of a SINGLE logical request so one request cannot amplify into an
// unbounded burst of upstream calls. It forwards the non-amplifying reads
// (ChainID/BlockNumber/HeaderByNumber) unchanged. max <= 0 disables the ceiling.
//
// Composing over Caller (not extending it) keeps the ceiling a pure wrapper: it adds a
// counter on the one amplifying verb and is otherwise transparent. A fresh Bounded per
// request gives each request an independent budget.
type Bounded struct {
ec Caller
max int
calls atomic.Int64
}
// NewBounded returns a Caller that allows at most `max` CallContract calls before
// failing the (max+1)th. max <= 0 disables the ceiling (unbounded passthrough).
func NewBounded(ec Caller, max int) *Bounded {
return &Bounded{ec: ec, max: max}
}
// Calls reports how many CallContract calls have been made through this wrapper.
func (b *Bounded) Calls() int64 { return b.calls.Load() }
func (b *Bounded) CallContract(ctx context.Context, call gethereum.CallMsg, block *big.Int) ([]byte, error) {
if b.max > 0 {
if n := b.calls.Add(1); n > int64(b.max) {
return nil, fmt.Errorf("evmread: per-request eth_call ceiling exceeded (%d) — narrow the query (smaller limit/range)", b.max)
}
}
return b.ec.CallContract(ctx, call, block)
}
func (b *Bounded) ChainID(ctx context.Context) (*big.Int, error) { return b.ec.ChainID(ctx) }
func (b *Bounded) BlockNumber(ctx context.Context) (uint64, error) { return b.ec.BlockNumber(ctx) }
func (b *Bounded) HeaderByNumber(ctx context.Context, n *big.Int) (*types.Header, error) {
return b.ec.HeaderByNumber(ctx, n)
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package evmread
import (
"context"
"math/big"
"strings"
"sync/atomic"
"testing"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
)
// countingCaller records CallContract invocations and returns a fixed payload. It
// implements the four read methods of Caller and nothing else — the read-only surface.
type countingCaller struct {
calls atomic.Int64
ret []byte
}
func (c *countingCaller) CallContract(_ context.Context, _ gethereum.CallMsg, _ *big.Int) ([]byte, error) {
c.calls.Add(1)
return c.ret, nil
}
func (c *countingCaller) ChainID(context.Context) (*big.Int, error) { return big.NewInt(1), nil }
func (c *countingCaller) BlockNumber(context.Context) (uint64, error) { return 1, nil }
func (c *countingCaller) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) {
return &types.Header{Number: big.NewInt(1)}, nil
}
// TestNewContractRejectsBadABI proves NewContract surfaces a parse error for malformed
// ABI JSON rather than returning a half-built contract.
func TestNewContractRejectsBadABI(t *testing.T) {
if _, err := NewContract("this is not json", common.Address{}); err == nil {
t.Fatal("NewContract accepted malformed ABI JSON")
}
// A well-formed ABI binds without error.
const goodABI = `[{"type":"function","stateMutability":"view","name":"x","inputs":[],"outputs":[{"name":"","type":"uint256"}]}]`
if _, err := NewContract(goodABI, common.Address{}); err != nil {
t.Fatalf("NewContract rejected valid ABI: %v", err)
}
}
// TestBoundedEnforcesCeiling proves Bounded allows exactly `max` CallContract calls then
// fails the (max+1)th with a ceiling error, while passing the non-amplifying reads
// through unchanged.
func TestBoundedEnforcesCeiling(t *testing.T) {
const max = 4
cc := &countingCaller{ret: common.LeftPadBytes(big.NewInt(7).Bytes(), 32)}
b := NewBounded(cc, max)
for i := 0; i < max; i++ {
if _, err := b.CallContract(context.Background(), gethereum.CallMsg{To: &common.Address{}}, nil); err != nil {
t.Fatalf("call %d under the ceiling errored: %v", i, err)
}
}
// The (max+1)th call must fail with a ceiling error.
_, err := b.CallContract(context.Background(), gethereum.CallMsg{To: &common.Address{}}, nil)
if err == nil {
t.Fatal("expected a ceiling error on the (max+1)th call, got nil")
}
if !strings.Contains(err.Error(), "ceiling") {
t.Fatalf("error %q is not the ceiling error", err.Error())
}
// The underlying caller was hit exactly `max` times (the over-limit call short-circuits).
if got := cc.calls.Load(); got != int64(max) {
t.Fatalf("underlying caller hit %d times, want %d (over-limit call must not reach it)", got, max)
}
if got := b.Calls(); got != int64(max+1) {
t.Fatalf("Bounded.Calls()=%d, want %d (counts the rejected attempt)", got, max+1)
}
// Non-amplifying reads are forwarded regardless of the ceiling.
if _, err := b.ChainID(context.Background()); err != nil {
t.Fatalf("ChainID passthrough errored: %v", err)
}
if _, err := b.BlockNumber(context.Background()); err != nil {
t.Fatalf("BlockNumber passthrough errored: %v", err)
}
if _, err := b.HeaderByNumber(context.Background(), nil); err != nil {
t.Fatalf("HeaderByNumber passthrough errored: %v", err)
}
}
// TestBoundedZeroMaxDisablesCeiling proves max <= 0 is unbounded passthrough.
func TestBoundedZeroMaxDisablesCeiling(t *testing.T) {
cc := &countingCaller{ret: common.LeftPadBytes(big.NewInt(1).Bytes(), 32)}
b := NewBounded(cc, 0)
for i := 0; i < 1000; i++ {
if _, err := b.CallContract(context.Background(), gethereum.CallMsg{To: &common.Address{}}, nil); err != nil {
t.Fatalf("unbounded call %d errored: %v", i, err)
}
}
if got := cc.calls.Load(); got != 1000 {
t.Fatalf("unbounded caller hit %d times, want 1000", got)
}
}
// TestCallStructUnpacksSingleReturn proves CallStruct decodes a single uint256 return
// into a one-field path via the wrapper-struct mechanism geth requires.
func TestCallStructUnpacksSingleReturn(t *testing.T) {
const oneUint = `[{"type":"function","stateMutability":"view","name":"v","inputs":[],"outputs":[{"name":"","type":"uint256"}]}]`
c, err := NewContract(oneUint, common.Address{})
if err != nil {
t.Fatalf("NewContract: %v", err)
}
cc := &countingCaller{ret: common.LeftPadBytes(big.NewInt(42).Bytes(), 32)}
got, err := CallStruct[*big.Int](context.Background(), c, cc, "v")
if err != nil {
t.Fatalf("CallStruct: %v", err)
}
if got.Cmp(big.NewInt(42)) != 0 {
t.Fatalf("CallStruct returned %s, want 42", got)
}
}
+24 -102
View File
@@ -4,42 +4,29 @@
package governance
import (
"context"
"fmt"
"math/big"
"strings"
"github.com/luxfi/geth/accounts/abi"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/mcp/evmread"
)
// This file holds the minimal, hand-written ABI for the four governance contracts
// and a single read-only call helper. Only the VIEW functions and the structs the
// eight read tools touch are declared — the package never packs a state-mutating
// method, so it has no calldata path that could change chain state. The struct
// tuple component order/types below mirror the Solidity sources EXACTLY
// (AIParams.sol, AIGovernor.sol, IAIGovernor.sol, AIThoughtRegistry.sol,
// AIReputation.sol); the parity tests assert that.
// EthCaller is the minimal, READ-ONLY chain surface this server needs. Both the
// production *ethclient.Client (dialed in New) and the in-process simulated test
// backend's Client satisfy it. It deliberately exposes NO transaction-sending
// method: there is no Send/SendTransaction here, so no caller of this package can
// submit a tx through it. (Decomplecting, Hickey: the dependency is "a thing that
// can read the chain", not "a URL" — which is also what makes the tests injectable.)
type EthCaller interface {
CallContract(ctx context.Context, call gethereum.CallMsg, blockNumber *big.Int) ([]byte, error)
ChainID(ctx context.Context) (*big.Int, error)
BlockNumber(ctx context.Context) (uint64, error)
HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error)
}
// This file holds the minimal, hand-written ABI for the four governance contracts.
// Only the VIEW functions and the structs the eight read tools touch are declared —
// the package never packs a state-mutating method, so it has no calldata path that
// could change chain state. The struct tuple component order/types below mirror the
// Solidity sources EXACTLY (AIParams.sol, AIGovernor.sol, IAIGovernor.sol,
// AIThoughtRegistry.sol, AIReputation.sol); the parity tests assert that.
//
// The chain-read mechanics (Caller, Contract.Call, CallStruct, the per-request call
// ceiling) live in github.com/luxfi/mcp/evmread — the SOLE geth-importing adapter.
// This package contributes only the domain ABIs and structs and composes them over
// evmread; it never imports geth's transactor/signing surface.
// ----------------------------------------------------------------------------
// Solidity-mirror structs. Field ORDER and Go types match the .sol tuple layout
// exactly so abi.UnpackIntoInterface decodes a returned struct field-for-field.
// (common.Address and *big.Int are read-only value types — not write-capable.)
// ----------------------------------------------------------------------------
// Round mirrors AIParams.Round (AIParams.sol struct order).
@@ -231,83 +218,18 @@ const aiReputationABI = `[` +
`{"type":"function","stateMutability":"view","name":"agreementRateBps","inputs":[{"name":"operator","type":"address"}],"outputs":[{"name":"","type":"uint32"}]}` +
`]`
// boundABI pairs a parsed ABI with the address it is deployed at. A read tool
// packs a method's calldata, sends it via EthCaller.CallContract (eth_call), and
// unpacks the return. There is exactly one chain-access verb here (CallContract),
// which is the read-only eth_call — no path packs or sends a transaction.
type boundABI struct {
abi abi.ABI
addr common.Address
}
func newBoundABI(jsonABI string, addr common.Address) (*boundABI, error) {
parsed, err := abi.JSON(strings.NewReader(jsonABI))
if err != nil {
return nil, fmt.Errorf("mcp: parse ABI: %w", err)
// readParamsContract / etc. bind the ABI strings to addresses via evmread. Kept as a
// tiny constructor set so newServer reads exactly like before but through the adapter.
func bindContracts(cfg Config) (params, governor, registry, rep *evmread.Contract, err error) {
if params, err = evmread.NewContract(aiParamsABI, cfg.AIParams); err != nil {
return
}
return &boundABI{abi: parsed, addr: addr}, nil
}
// call packs `method`(args...), executes it as a read-only eth_call against the
// bound address at the latest block, and returns the decoded output values.
func (b *boundABI) call(ctx context.Context, ec EthCaller, method string, args ...interface{}) ([]interface{}, error) {
return b.callAt(ctx, ec, nil, method, args...)
}
// callAt is `call` pinned to a specific block (nil = latest). Pinning lets a tool
// that issues SEVERAL reads which must agree (e.g. quorum_status reading verdicts and
// then each operator's bond) take them all at ONE block, so a state change between the
// reads — a bond withdraw racing the tally — cannot produce an inconsistent snapshot.
// The in-process test backend ignores the block arg (it has only latest state); the
// production *ethclient.Client honors it.
func (b *boundABI) callAt(ctx context.Context, ec EthCaller, block *big.Int, method string, args ...interface{}) ([]interface{}, error) {
m, ok := b.abi.Methods[method]
if !ok {
return nil, fmt.Errorf("mcp: unknown method %q", method)
if governor, err = evmread.NewContract(aiGovernorABI, cfg.AIGovernor); err != nil {
return
}
in, err := b.abi.Pack(method, args...)
if err != nil {
return nil, fmt.Errorf("mcp: pack %s: %w", method, err)
if registry, err = evmread.NewContract(aiThoughtRegistryABI, cfg.AIThoughtRegistry); err != nil {
return
}
out, err := ec.CallContract(ctx, gethereum.CallMsg{To: &b.addr, Data: in}, block)
if err != nil {
return nil, fmt.Errorf("mcp: eth_call %s @ %s: %w", method, b.addr.Hex(), err)
}
vals, err := m.Outputs.Unpack(out)
if err != nil {
return nil, fmt.Errorf("mcp: unpack %s: %w", method, err)
}
return vals, nil
}
// callStruct packs `method`(args...), runs the read-only eth_call, and unpacks a
// SINGLE struct (or slice-of-struct) return into a value of type T, mapping the
// Solidity tuple field-for-field by position.
//
// geth's abi.UnpackIntoInterface special-cases a single output: it treats the
// destination as a wrapper struct and writes the unpacked value into its FIRST field
// (Arguments.copyAtomic). So we hand it a one-field wrapper whose field is T; geth
// then field-copies the tuple into it by index — which is exactly why T's field order
// MUST mirror the Solidity struct (it does; the parity tests assert it).
func callStruct[T any](ctx context.Context, b *boundABI, ec EthCaller, method string, args ...interface{}) (T, error) {
return callStructAt[T](ctx, b, ec, nil, method, args...)
}
// callStructAt is `callStruct` pinned to a specific block (nil = latest); see callAt
// for why pinning matters. quorum_status uses it to read getThought / getVerdicts at
// the same block it reads the bonds, for a consistent settle-equivalent snapshot.
func callStructAt[T any](ctx context.Context, b *boundABI, ec EthCaller, block *big.Int, method string, args ...interface{}) (T, error) {
var wrap struct{ V T }
in, err := b.abi.Pack(method, args...)
if err != nil {
return wrap.V, fmt.Errorf("mcp: pack %s: %w", method, err)
}
out, err := ec.CallContract(ctx, gethereum.CallMsg{To: &b.addr, Data: in}, block)
if err != nil {
return wrap.V, fmt.Errorf("mcp: eth_call %s @ %s: %w", method, b.addr.Hex(), err)
}
if err := b.abi.UnpackIntoInterface(&wrap, method, out); err != nil {
return wrap.V, fmt.Errorf("mcp: unpack %s: %w", method, err)
}
return wrap.V, nil
rep, err = evmread.NewContract(aiReputationABI, cfg.AIReputation)
return
}
+47 -207
View File
@@ -4,41 +4,27 @@
package governance
import (
"bufio"
"context"
"math/big"
"strings"
"sync/atomic"
"testing"
"time"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/mcp"
)
// ----------------------------------------------------------------------------
// Test EthCaller stubs (read-only; they implement only the four read methods, so
// they can never grow a write path — same surface the production client exposes).
// Test evmread.Caller stub (read-only; it implements only the four read methods, so it
// can never grow a write path — same surface the production client exposes).
// ----------------------------------------------------------------------------
// blockingCaller's CallContract blocks until the call's context is cancelled, modeling a
// hung upstream RPC. It returns the context error so the per-call timeout surfaces.
type blockingCaller struct{}
func (blockingCaller) CallContract(ctx context.Context, _ gethereum.CallMsg, _ *big.Int) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (blockingCaller) ChainID(context.Context) (*big.Int, error) { return big.NewInt(1), nil }
func (blockingCaller) BlockNumber(context.Context) (uint64, error) { return 1, nil }
func (blockingCaller) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) {
return &types.Header{Number: big.NewInt(1), Time: 1_700_000_000}, nil
}
// countingThoughtCaller returns a minimal still-Open thought for every getThought call
// and counts CallContract invocations. taskCount is large so pending_operations wants to
// scan a big window — letting us prove the per-request eth_call ceiling bites first.
// countingThoughtCaller returns a minimal still-Open thought for every getThought call and
// counts CallContract invocations. taskCount is large so pending_operations wants to scan a
// big window — letting us prove the per-request eth_call ceiling bites first.
type countingThoughtCaller struct {
calls atomic.Int64
taskCount uint64
@@ -48,8 +34,8 @@ func (c *countingThoughtCaller) CallContract(_ context.Context, call gethereum.C
n := c.calls.Add(1)
// We don't decode the selector; we answer based on output shape the caller expects.
// pending_operations issues taskCount() (one uint256) then getThought() (a Thought
// tuple) repeatedly. Distinguish by returning a uint256 for the FIRST call and a
// Thought tuple thereafter — the unpacker only needs well-formed bytes.
// tuple) repeatedly. Distinguish by returning a uint256 for the FIRST call and a Thought
// tuple thereafter — the unpacker only needs well-formed bytes.
if n == 1 {
return encodeUint256(new(big.Int).SetUint64(c.taskCount)), nil
}
@@ -61,179 +47,53 @@ func (c *countingThoughtCaller) HeaderByNumber(context.Context, *big.Int) (*type
return &types.Header{Number: new(big.Int).SetUint64(c.taskCount), Time: 1_700_000_000}, nil
}
// stubServer builds a Server over a custom EthCaller with non-zero contract addresses
// (so newServer accepts the config) and small test budgets.
func stubServer(t *testing.T, ec EthCaller) *Server {
// stubSurface builds a governance Surface over a custom evmread.Caller with non-zero
// contract addresses (so newSurface accepts the config).
func stubSurface(t *testing.T, ec EthCaller) *Surface {
t.Helper()
addr := common.HexToAddress("0x000000000000000000000000000000000000dEaD")
srv, err := NewWithCaller(ec, Config{
g, err := NewWithCaller(ec, Config{
AIParams: addr,
AIGovernor: addr,
AIThoughtRegistry: addr,
AIReputation: addr,
})
if err != nil {
t.Fatalf("stub server: %v", err)
t.Fatalf("stub surface: %v", err)
}
return srv
return g
}
// TestPerCallTimeoutDoesNotWedgeServer is the HIGH-3 test: a hung upstream RPC must not
// wedge the stdio loop. With a tiny call budget, a tools/call that blocks on a never-
// returning CallContract returns a timeout (isError) AND the server still answers the
// NEXT request on the same stream.
func TestPerCallTimeoutDoesNotWedgeServer(t *testing.T) {
srv := stubServer(t, blockingCaller{})
srv.callTimeout = 100 * time.Millisecond // shrink so the test is fast
// First request hits the blocking caller (param_value issues an eth_call -> blocks
// until the per-call deadline). Second request is chain_state, which the stub answers
// without an eth_call, proving the loop survived.
in := strings.Join([]string{
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"param_value","arguments":{"modelSpecHash":"0x` + strings.Repeat("00", 32) + `","knobKey":"x"}}}`,
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chain_state","arguments":{}}}`,
}, "\n") + "\n"
start := time.Now()
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(in), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
elapsed := time.Since(start)
resps := decodeLines(t, out.String())
if len(resps) != 2 {
t.Fatalf("want 2 responses, got %d:\n%s", len(resps), out.String())
}
// 1) The blocked call returns a tool error (isError=true) mentioning a deadline.
r1 := resps[0]["result"].(map[string]interface{})
if r1["isError"] != true {
t.Fatalf("blocked call should be isError, got: %v", resps[0])
}
txt := r1["content"].([]interface{})[0].(map[string]interface{})["text"].(string)
if !strings.Contains(strings.ToLower(txt), "deadline") && !strings.Contains(strings.ToLower(txt), "context") {
t.Fatalf("blocked call error %q does not look like a timeout", txt)
}
// 2) The SECOND request was answered — the server is still responsive.
r2 := resps[1]["result"].(map[string]interface{})
if _, ok := r2["content"]; !ok {
t.Fatalf("second request not answered (server wedged?): %v", resps[1])
}
// Sanity: total time is bounded by the budget, not infinite.
if elapsed > 5*time.Second {
t.Fatalf("Serve took %v — the timeout did not bound the hung call", elapsed)
}
t.Logf("hung call bounded by per-call timeout; server stayed responsive (elapsed %v)", elapsed)
}
// TestPerRequestCallCeiling is the MEDIUM-4 test: one tools/call cannot issue an
// unbounded number of eth_calls. With a small ceiling and a chain that would otherwise
// invite a deep scan, pending_operations bails with a ceiling error rather than hammering
// the upstream thousands of times.
// TestPerRequestCallCeiling is the MEDIUM-4 test: one tools/call cannot issue an unbounded
// number of eth_calls. The ceiling lives in the governance Surface (it knows its read
// patterns). With a small ceiling and a chain that would otherwise invite a deep scan,
// pending_operations bails with a ceiling error rather than hammering the upstream thousands
// of times.
func TestPerRequestCallCeiling(t *testing.T) {
caller := &countingThoughtCaller{taskCount: 100000}
srv := stubServer(t, caller)
srv.maxCallsPerRequest = 8 // tiny ceiling for the test
g := stubSurface(t, caller)
g.maxCallsPerRequest = 8 // tiny ceiling for the test
srv, err := mcp.NewServer(g)
if err != nil {
t.Fatalf("new server: %v", err)
}
// pending_operations with a large limit would normally scan many tasks. The ceiling
// must stop it well before taskCount.
_, err := srv.CallTool(context.Background(), toolPendingOperations, map[string]interface{}{"limit": 200})
// pending_operations with a large limit would normally scan many tasks. The ceiling must
// stop it well before taskCount.
_, err = srv.CallTool(context.Background(), toolPendingOperations, map[string]interface{}{"limit": 200})
if err == nil {
t.Fatal("expected a per-request ceiling error, got nil")
}
if !strings.Contains(err.Error(), "ceiling") {
t.Fatalf("error %q is not the ceiling error", err.Error())
}
// The wrapper allows exactly `max` calls then fails the (max+1)th, so total calls is
// bounded at max+1 — proving we did NOT run away to taskCount.
if got := caller.calls.Load(); got > int64(srv.maxCallsPerRequest+1) {
t.Fatalf("issued %d eth_calls, ceiling was %d — runaway not stopped", got, srv.maxCallsPerRequest)
}
t.Logf("call ceiling stopped the scan at %d eth_calls (max %d)", caller.calls.Load(), srv.maxCallsPerRequest)
}
// TestBoundedCallerForwardsUnderLimit proves the bounded caller is transparent below the
// ceiling (it must not break normal operation).
func TestBoundedCallerForwardsUnderLimit(t *testing.T) {
caller := &countingThoughtCaller{taskCount: 3}
bc := newBoundedCaller(caller, 100)
for i := 0; i < 5; i++ {
if _, err := bc.CallContract(context.Background(), gethereum.CallMsg{To: &common.Address{}}, nil); err != nil {
t.Fatalf("call %d under the ceiling errored: %v", i, err)
}
}
if got := bc.calls.Load(); got != 5 {
t.Fatalf("bounded caller counted %d, want 5", got)
}
}
// TestOversizedLineIsRejectedAndLoopSurvives is the HIGH-2 test: a request line larger
// than maxLineBytes must be rejected as a parse error WITHOUT buffering it whole, and the
// stdio loop must survive to answer the next (well-formed) request.
func TestOversizedLineIsRejectedAndLoopSurvives(t *testing.T) {
srv := stubServer(t, blockingCaller{}) // chain_state needs no eth_call here
// One oversized line (a JSON string padded past the cap) then a valid chain_state.
huge := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"chain_state","arguments":{"pad":"` +
strings.Repeat("A", maxLineBytes+1024) + `"}}}`
valid := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chain_state","arguments":{}}}`
in := huge + "\n" + valid + "\n"
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(in), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
resps := decodeLines(t, out.String())
if len(resps) != 2 {
t.Fatalf("want 2 responses (oversized parse error + valid result), got %d", len(resps))
}
// 1) Oversized line -> a JSON-RPC parse error (id null since we never parsed it).
if resps[0]["error"] == nil {
t.Fatalf("oversized line should yield a parse error, got: %v", resps[0])
}
em := resps[0]["error"].(map[string]interface{})
if int(em["code"].(float64)) != codeParseError {
t.Fatalf("oversized line error code=%v, want %d", em["code"], codeParseError)
}
// 2) The following valid request was still served.
if _, ok := resps[1]["result"]; !ok {
t.Fatalf("loop did not survive the oversized line: %v", resps[1])
}
t.Logf("oversized line rejected as parse error; loop survived and served the next request")
}
// TestReadLimitedLineDrainsAndResyncs unit-tests the bounded reader directly: an
// oversized line is reported tooLong with no buffered bytes, and the NEXT ReadByte starts
// on the following line (the reader drained to the newline).
func TestReadLimitedLineDrainsAndResyncs(t *testing.T) {
const max = 16
data := strings.Repeat("X", max*4) + "\n" + "ok\n"
r := bufio.NewReader(strings.NewReader(data))
line, tooLong, err := readLimitedLine(r, max)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !tooLong {
t.Fatal("expected tooLong=true for the long line")
}
if len(line) != 0 {
t.Fatalf("oversized line should return no buffered bytes, got %d", len(line))
}
// Next read returns the SECOND line intact.
line2, tooLong2, err := readLimitedLine(r, max)
if err != nil {
t.Fatalf("unexpected err on line 2: %v", err)
}
if tooLong2 {
t.Fatal("second line is short; tooLong must be false")
}
if strings.TrimSpace(string(line2)) != "ok" {
t.Fatalf("after draining, next line=%q, want %q", strings.TrimSpace(string(line2)), "ok")
// The wrapper allows exactly `max` calls then fails the (max+1)th, so total CallContract
// calls is bounded at max+1 — proving we did NOT run away to taskCount. (ChainID/
// BlockNumber/HeaderByNumber are not counted; only CallContract amplifies.)
if got := caller.calls.Load(); got > int64(g.maxCallsPerRequest+1) {
t.Fatalf("issued %d eth_calls, ceiling was %d — runaway not stopped", got, g.maxCallsPerRequest)
}
t.Logf("call ceiling stopped the scan at %d eth_calls (max %d)", caller.calls.Load(), g.maxCallsPerRequest)
}
// TestArgInputLengthCapped is the LOW test: an absurdly long string/integer argument is
@@ -267,26 +127,6 @@ func TestArgLimitClampedToMax(t *testing.T) {
}
}
// TestDispatchRecoversPanic is the LOW test: a handler panic becomes one isError tool
// result, not a whole-server crash. We register a panicking handler on a stub server.
func TestDispatchRecoversPanic(t *testing.T) {
srv := stubServer(t, blockingCaller{})
srv.tools["boom"] = func(_ *Server, _ context.Context, _ map[string]interface{}) (interface{}, error) {
panic("kaboom")
}
_, err := srv.CallTool(context.Background(), "boom", nil)
if err == nil {
t.Fatal("expected a recovered-panic error, got nil")
}
if !strings.Contains(err.Error(), "panicked") {
t.Fatalf("error %q is not the recovered-panic error", err.Error())
}
// And the server is still usable afterwards.
if _, err := srv.CallTool(context.Background(), toolChainState, nil); err != nil {
t.Fatalf("server unusable after recovered panic: %v", err)
}
}
// ----------------------------------------------------------------------------
// helpers to synthesize ABI-encoded return values for the counting stub
// ----------------------------------------------------------------------------
@@ -296,20 +136,20 @@ func encodeUint256(v *big.Int) []byte {
return common.LeftPadBytes(v.Bytes(), 32)
}
// encodeOpenThought returns a minimally-valid ABI encoding of the AIGovernor Thought
// tuple with Status=Open. The tuple has two dynamic fields (knobKey string at the end is
// the only string; everything else is static within the head), so we hand-assemble the
// head + the string tail. We only need the unpacker to succeed and Status to read Open.
// encodeOpenThought returns a minimally-valid ABI encoding of the AIGovernor Thought tuple
// with Status=Open. The tuple has two dynamic fields (knobKey string at the end is the only
// string; everything else is static within the head), so we hand-assemble the head + the
// string tail. We only need the unpacker to succeed and Status to read Open.
func encodeOpenThought() []byte {
// The Thought tuple is a single dynamic struct return, so the outer return is one
// offset word pointing at the tuple, then the tuple's own head, then its dynamic tail
// (knobKey). Build it explicitly.
// The Thought tuple is a single dynamic struct return, so the outer return is one offset
// word pointing at the tuple, then the tuple's own head, then its dynamic tail (knobKey).
// Build it explicitly.
zero32 := make([]byte, 32)
word := func(n uint64) []byte { return common.LeftPadBytes(new(big.Int).SetUint64(n).Bytes(), 32) }
// Tuple field layout (mirrors abi.go Thought / thoughtTuple), all padded to 32 bytes
// in the tuple head; knobKey is dynamic so its head slot is an offset into the tuple.
// Fields in order:
// Tuple field layout (mirrors abi.go Thought / thoughtTuple), all padded to 32 bytes in
// the tuple head; knobKey is dynamic so its head slot is an offset into the tuple. Fields
// in order:
// 0 modelSpecHash bytes32
// 1 promptHash bytes32
// 2 evidenceHash bytes32
@@ -353,8 +193,8 @@ func encodeOpenThought() []byte {
appendWord(word(0)) // 16 commitDeadline
appendWord(word(0)) // 17 revealDeadline
// knobKey tail: offset is relative to the START of the tuple head. Empty string =
// length 0 (one zero word).
// knobKey tail: offset is relative to the START of the tuple head. Empty string = length
// 0 (one zero word).
tupleHeadLen := uint64(len(head))
copy(head[knobOffsetIdx:knobOffsetIdx+32], word(tupleHeadLen))
tail := word(0) // string length 0
+16 -3
View File
@@ -46,6 +46,8 @@ import (
"github.com/luxfi/geth/params"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/mcp"
)
// artifact is the subset of a foundry build artifact we load.
@@ -370,14 +372,25 @@ func (e *chainEnv) deployWithCtor(a abi.ABI, code []byte, args ...interface{}) c
return e.c.deploy(append(append([]byte{}, code...), packed...))
}
// mcpServer builds an MCP Server reading THIS chain through the EthCaller.
func (e *chainEnv) mcpServer() *Server {
srv, err := NewWithCaller(e.c, Config{
// mcpSurface builds the governance Surface reading THIS chain through the EthCaller.
func (e *chainEnv) mcpSurface() *Surface {
g, err := NewWithCaller(e.c, Config{
AIParams: e.params.addr,
AIGovernor: e.governor.addr,
AIThoughtRegistry: e.registry.addr,
AIReputation: e.rep.addr,
})
if err != nil {
e.t.Fatalf("new governance surface: %v", err)
}
return g
}
// mcpServer wraps the governance Surface in the shared mcp transport, the way production
// does (mcp.Serve(ctx, in, out, governance.New(cfg))). Returns the *mcp.Server whose
// CallTool/Serve/Tools the tests exercise.
func (e *chainEnv) mcpServer() *mcp.Server {
srv, err := mcp.NewServer(e.mcpSurface())
if err != nil {
e.t.Fatalf("new mcp server: %v", err)
}
+16 -20
View File
@@ -10,11 +10,13 @@ import (
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/mcp"
)
// callTool runs a read tool and returns its result as a map (all tool results are
// JSON objects). Fails the test on tool error or wrong shape.
func callTool(t *testing.T, srv *Server, name string, args map[string]interface{}) map[string]interface{} {
func callTool(t *testing.T, srv *mcp.Server, name string, args map[string]interface{}) map[string]interface{} {
t.Helper()
res, err := srv.CallTool(context.Background(), name, args)
if err != nil {
@@ -249,22 +251,23 @@ func TestMCPThoughtStatusMatchesAIGovernor(t *testing.T) {
func TestOperatorVerdictBindsMCPObservedState(t *testing.T) {
ops := genKeys(t, 3)
_, env := newEVMChain(t, ops)
srv := env.mcpServer()
g := env.mcpSurface()
_, spec, knobKey := driveSettledRound(t, env, ops, big.NewInt(333))
// 1. The operator reads the decided knob via MCP and pins it into an observation.
value, decided, err := srv.readParamValue(context.Background(), spec, knobKey)
// 1. The operator reads the decided knob via the governance surface and pins it into
// an observation built by the shared mcp package.
value, decided, err := g.readParamValue(context.Background(), env.c, spec, knobKey)
if err != nil {
t.Fatalf("readParamValue: %v", err)
}
obs1, err := newObservation(context.Background(), env.c, toolParamValue, []ObservedFact{
obs1, err := mcp.NewObservation(context.Background(), env.c, toolParamValue, []mcp.ObservedFact{
{Key: "knobKey", Value: knobKey},
{Key: "value", Value: value.String()},
{Key: "decided", Value: boolStr(decided)},
})
if err != nil {
t.Fatalf("newObservation: %v", err)
t.Fatalf("NewObservation: %v", err)
}
boundHash := obs1.Hash()
@@ -277,18 +280,18 @@ func TestOperatorVerdictBindsMCPObservedState(t *testing.T) {
// 3. A checker RE-READS the same chain facts (no new block has been added) and
// re-derives the observation hash; it must equal what the verdict bound.
value2, decided2, err := srv.readParamValue(context.Background(), spec, knobKey)
value2, decided2, err := g.readParamValue(context.Background(), env.c, spec, knobKey)
if err != nil {
t.Fatalf("re-read: %v", err)
}
obs2, err := newObservation(context.Background(), env.c, toolParamValue, []ObservedFact{
obs2, err := mcp.NewObservation(context.Background(), env.c, toolParamValue, []mcp.ObservedFact{
// Deliberately a different append order to prove canonicalization sorts it.
{Key: "value", Value: value2.String()},
{Key: "decided", Value: boolStr(decided2)},
{Key: "knobKey", Value: knobKey},
})
if err != nil {
t.Fatalf("newObservation re-read: %v", err)
t.Fatalf("NewObservation re-read: %v", err)
}
rederived := obs2.Hash()
@@ -302,7 +305,7 @@ func TestOperatorVerdictBindsMCPObservedState(t *testing.T) {
}
// And a verdict claiming a DIFFERENT value must NOT match (binding is meaningful).
tampered, _ := newObservation(context.Background(), env.c, toolParamValue, []ObservedFact{
tampered, _ := mcp.NewObservation(context.Background(), env.c, toolParamValue, []mcp.ObservedFact{
{Key: "knobKey", Value: knobKey},
{Key: "value", Value: "999999"}, // lie about the value
{Key: "decided", Value: boolStr(decided)},
@@ -320,9 +323,9 @@ func TestStaleMCPObservationRejectedOrFlagged(t *testing.T) {
_, env := newEVMChain(t, ops)
// Observation at the current head.
obs, err := newObservation(context.Background(), env.c, toolChainState, nil)
obs, err := mcp.NewObservation(context.Background(), env.c, toolChainState, nil)
if err != nil {
t.Fatalf("newObservation: %v", err)
t.Fatalf("NewObservation: %v", err)
}
// Immediately, with zero tolerance, it is fresh.
@@ -354,7 +357,7 @@ func TestStaleMCPObservationRejectedOrFlagged(t *testing.T) {
// Reorg detection: forge an observation whose recorded blockHash is wrong for its
// height. Verify must reject it even within the age window.
bad := &ChainObservation{
bad := &mcp.ChainObservation{
ChainID: obs.ChainID,
BlockNumber: obs.BlockNumber,
BlockHash: common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
@@ -383,13 +386,6 @@ type mockVerdict struct {
obsBound common.Hash
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
// toUint8 coerces a JSON-decoded numeric (uint8 from the tool, or float64 if it
// round-tripped through JSON) to uint8.
func toUint8(t *testing.T, v interface{}) uint8 {
+5 -60
View File
@@ -8,7 +8,7 @@ import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/mcp"
)
// TestPendingOperationsAnnotatesDeadlineAndTruncation is the MEDIUM-5 test. It proves:
@@ -109,9 +109,9 @@ func TestObservationVerifyRejectsChainMismatch(t *testing.T) {
ops := genKeys(t, 1)
_, env := newEVMChain(t, ops)
obs, err := newObservation(context.Background(), env.c, toolChainState, nil)
obs, err := mcp.NewObservation(context.Background(), env.c, toolChainState, nil)
if err != nil {
t.Fatalf("newObservation: %v", err)
t.Fatalf("NewObservation: %v", err)
}
// Sanity: it verifies on its own chain.
if fresh, err := obs.Verify(context.Background(), env.c, 0); err != nil || !fresh {
@@ -119,7 +119,7 @@ func TestObservationVerifyRejectsChainMismatch(t *testing.T) {
}
// Forge a wrong chain id on the observation; Verify must reject as a chain mismatch.
wrong := &ChainObservation{
wrong := &mcp.ChainObservation{
ChainID: new(big.Int).Add(obs.ChainID, big.NewInt(1)),
BlockNumber: obs.BlockNumber,
BlockHash: obs.BlockHash,
@@ -136,63 +136,8 @@ func TestObservationVerifyRejectsChainMismatch(t *testing.T) {
t.Logf("chain mismatch correctly rejected: %v", err)
// A nil chainId is also a mismatch (cannot be trusted).
nilCID := &ChainObservation{BlockNumber: obs.BlockNumber, BlockHash: obs.BlockHash, Tool: toolChainState}
nilCID := &mcp.ChainObservation{BlockNumber: obs.BlockNumber, BlockHash: obs.BlockHash, Tool: toolChainState}
if fresh, _ := nilCID.Verify(context.Background(), env.c, 1000); fresh {
t.Fatal("observation with nil chainId passed Verify")
}
}
// TestObservationDedupsDuplicateKeys is the NIT test: an observation whose Reads name the
// SAME key twice must canonicalize deterministically (last-writer-wins), so two parties
// cannot produce divergent hashes from a dup-key set, and the dup collapses to one entry.
func TestObservationDedupsDuplicateKeys(t *testing.T) {
mk := func(reads []ObservedFact) *ChainObservation {
return &ChainObservation{
ChainID: big.NewInt(7),
BlockNumber: 42,
BlockHash: common.HexToHash("0x01"),
Timestamp: 1000,
Tool: toolParamValue,
Reads: reads,
}
}
// Two sets that differ only in the ORDER of a duplicated "value" key. Last writer
// ("final") must win in BOTH, so the canonical bytes and hash are identical.
a := mk([]ObservedFact{
{Key: "knobKey", Value: "temp"},
{Key: "value", Value: "first"},
{Key: "value", Value: "final"},
})
b := mk([]ObservedFact{
{Key: "value", Value: "first"},
{Key: "knobKey", Value: "temp"},
{Key: "value", Value: "final"},
})
if string(a.Canonical()) != string(b.Canonical()) {
t.Fatalf("dup-key observations not canonical-equal:\n a=%s\n b=%s", a.Canonical(), b.Canonical())
}
if a.Hash() != b.Hash() {
t.Fatalf("dup-key observations hashed differently: %s vs %s", a.Hash().Hex(), b.Hash().Hex())
}
// The duplicate must collapse to a single "value" entry equal to the last writer.
c := mk([]ObservedFact{
{Key: "value", Value: "first"},
{Key: "value", Value: "final"},
})
c.sortReads()
count := 0
var kept string
for _, r := range c.Reads {
if r.Key == "value" {
count++
kept = r.Value
}
}
if count != 1 {
t.Fatalf("duplicate key not collapsed: %d 'value' entries remain", count)
}
if kept != "final" {
t.Fatalf("last-writer-wins broken: kept %q, want final", kept)
}
}
+95 -438
View File
@@ -1,83 +1,54 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mcp is a node-side, READ-ONLY governance MCP (Model Context Protocol)
// server for the Lux AIVM (A-Chain) governance stack — the "sensory layer" an
// operator-LLM uses to query chain facts during deliberation.
// Package governance is a Surface of eight READ-ONLY governance tools for the Lux AIVM
// (A-Chain) governance stack — the chain-fact queries an operator-LLM uses during
// deliberation. It contributes []mcp.Tool to the shared, domain-agnostic transport in
// github.com/luxfi/mcp; it does the chain reads through github.com/luxfi/mcp/evmread.
//
// READ-ONLY BY CONSTRUCTION. v1 exposes only the eight read tools below. The
// package holds no private key and links no signing or transaction-submission
// primitive: every chain access goes through EthCaller.CallContract (the read-only
// eth_call) or the header/chainId/blockNumber readers. There is no SendTransaction,
// no keyed transactor, no eth_sendRawTransaction anywhere in this package. Verdicts
// are produced and submitted by the operator through the normal AIGovernor tx path,
// NOT through this server. (TestMCPReadOnlyToolsCannotSubmitTx asserts this by
// scanning the package source for forbidden write tokens and by checking that
// tools/list returns exactly the eight read tools.)
// READ-ONLY BY CONSTRUCTION. This package holds no private key and links no signing or
// transaction-submission primitive: every chain access goes through evmread.Caller (the
// read-only eth_call) or the header/chainId/blockNumber readers. There is no
// SendTransaction, no keyed transactor, no eth_sendRawTransaction anywhere in this
// package — and it imports no go-ethereum transactor surface at all (the read-only gate
// in luxfi/mcp scans the whole module to assert this; evmread is the SOLE geth importer).
// Verdicts are produced and submitted by the operator through the normal AIGovernor tx
// path, NOT through this server.
//
// Orthogonal split (Rich Hickey): MCP READS the chain. AIGovernor settles votes.
// AIParams stores knobs. AIThoughtRegistry records receipts. AIReputation scores
// operators. This package depends on NONE of the write paths — it links only the
// read surface (EthCaller) and the hand-written view-function ABIs in abi.go. The
// EVM-call discipline mirrors chains/dexvm/registry/rpcverify: dial an EVM RPC,
// call view functions, keep JSON-RPC/consensus write deps out of the path.
//
// Transport is JSON-RPC 2.0 over stdio (newline-delimited): Serve reads requests
// from an io.Reader and writes responses to an io.Writer. Methods: initialize,
// tools/list, tools/call. Standard library only — no external MCP SDK.
// Orthogonal split (Rich Hickey): mcp READS over stdio. evmread READS the chain.
// governance KNOWS the contracts. AIGovernor settles votes; AIParams stores knobs;
// AIThoughtRegistry records receipts; AIReputation scores operators — this package
// depends on NONE of their write paths.
package governance
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"strings"
"sync/atomic"
"time"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/ethclient"
"github.com/luxfi/mcp"
"github.com/luxfi/mcp/evmread"
)
// ServerName / ServerVersion are returned in the initialize handshake.
const (
ServerName = "aivm-gov-mcp"
ServerVersion = "1.0.0"
// ProtocolVersion is the MCP protocol revision this server speaks.
ProtocolVersion = "2024-11-05"
)
// EthCaller is the read-only chain surface this package consumes. It is an alias for
// evmread.Caller so the production *ethclient.Client and the in-process test backend both
// satisfy it without this package naming geth's transactor surface.
type EthCaller = evmread.Caller
// Availability/safety bounds. These cap what a single stdio line can cost so one
// client (or one wedged upstream RPC) cannot exhaust the server.
const (
// maxLineBytes bounds one JSON-RPC request line. MCP requests are tiny (a tool name
// + a few scalar args), so 1 MiB is generous; a longer line is rejected as a parse
// error WITHOUT buffering it whole (the reader stops at the cap and drains the rest).
maxLineBytes = 1 << 20 // 1 MiB
// defaultMaxCallsPerRequest caps the eth_calls one tools/call may issue, so a single line
// cannot amplify into thousands of upstream calls. param_history at the limit cap (256
// rounds x 2 calls + count ~= 513) and pending_operations both stay well under this; it is
// the hard backstop, not the normal path. The ceiling is THIS package's concern (it knows
// its read patterns); the transport bounds time and crashes, not call count.
const defaultMaxCallsPerRequest = 1024
// defaultCallTimeout bounds one tools/call dispatch end-to-end (reaching every
// eth_call). It mirrors the per-request budget in chains/dexvm/registry/rpcverify:
// long enough for a healthy RPC, short enough that one hung call cannot wedge the
// stdio loop (the next request is still served once the budget elapses).
defaultCallTimeout = 20 * time.Second
// defaultMaxCallsPerRequest caps the eth_calls one tools/call may issue, so a single
// line cannot amplify into thousands of upstream calls. param_history at the limit
// cap (256 rounds x 2 calls + count ~= 513) and pending_operations both stay well
// under this; it is the hard backstop, not the normal path.
defaultMaxCallsPerRequest = 1024
)
// Config holds the EVM RPC endpoint and the four deployed governance contract
// addresses. AIParams, AIGovernor are required (most tools need them);
// AIThoughtRegistry and AIReputation are required for receipt_lookup and the
// reputation reads respectively. All four are validated in New.
// Config holds the EVM RPC endpoint and the four deployed governance contract addresses.
// AIParams, AIGovernor are required (most tools need them); AIThoughtRegistry and
// AIReputation are required for receipt_lookup and the reputation reads respectively. All
// four are validated in New.
type Config struct {
// EVMRPC is the governance EVM chain's RPC URL — an L1/L2/L3 EVM endpoint or a
// …/ext/bc/C/rpc. Dialed read-only via ethclient.DialContext.
@@ -89,412 +60,98 @@ type Config struct {
AIReputation common.Address
}
// Server is the read-only governance MCP server. It owns a read-only EthCaller and
// the four bound view-ABIs. It exposes NO method that can sign or submit a tx.
type Server struct {
ec EthCaller
// Surface is the read-only governance tool set. It owns a read-only evmread.Caller and the
// four bound view-contracts, and yields the eight tools via Tools(). It exposes NO method
// that can sign or submit a tx.
type Surface struct {
ec evmread.Caller
params *boundABI
governor *boundABI
registry *boundABI
rep *boundABI
params *evmread.Contract
governor *evmread.Contract
registry *evmread.Contract
rep *evmread.Contract
tools map[string]toolHandler
descs []Tool
// callTimeout bounds one tools/call dispatch; maxCallsPerRequest caps its eth_calls.
// Defaulted in newServer; overridable in-package (tests shrink them).
callTimeout time.Duration
// maxCallsPerRequest caps a single tool's eth_call fan-out (the per-request ceiling).
// Defaulted in newSurface; overridable in-package (tests shrink it).
maxCallsPerRequest int
}
// toolHandler runs one read tool: bound as a method expression on *Server, it
// receives the server, the context, and the decoded args, and returns a
// JSON-serializable result (or an error). It may issue read-only eth_calls via the
// server's EthCaller; it can never send a transaction.
type toolHandler func(s *Server, ctx context.Context, args map[string]interface{}) (interface{}, error)
// Ensure the concrete Surface satisfies the transport's Surface contract.
var _ mcp.Surface = (*Surface)(nil)
// New dials the EVM RPC (read-only) and binds the four contract view-ABIs. The
// dialed *ethclient.Client satisfies EthCaller; the production server reads the
// live chain through it. Returns an error if any address is zero or the dial fails.
func New(ctx context.Context, cfg Config) (*Server, error) {
// New dials the EVM RPC (read-only), binds the four contract view-ABIs, and returns the
// governance tool Surface. The dialed *ethclient.Client satisfies evmread.Caller; the
// production server reads the live chain through it. Returns an error if any address is
// zero or the dial fails.
func New(ctx context.Context, cfg Config) (mcp.Surface, error) {
return NewWithDial(ctx, cfg)
}
// NewWithDial is New returning the concrete *Surface (for callers that need it). Most
// callers use New and treat it as an mcp.Surface.
func NewWithDial(ctx context.Context, cfg Config) (*Surface, error) {
if cfg.EVMRPC == "" {
return nil, errors.New("mcp: empty EVMRPC")
}
if (cfg.AIParams == common.Address{}) {
return nil, errors.New("mcp: AIParams address is zero")
}
if (cfg.AIGovernor == common.Address{}) {
return nil, errors.New("mcp: AIGovernor address is zero")
}
if (cfg.AIThoughtRegistry == common.Address{}) {
return nil, errors.New("mcp: AIThoughtRegistry address is zero")
}
if (cfg.AIReputation == common.Address{}) {
return nil, errors.New("mcp: AIReputation address is zero")
if err := validateAddrs(cfg); err != nil {
return nil, err
}
ec, err := ethclient.DialContext(ctx, cfg.EVMRPC)
if err != nil {
return nil, fmt.Errorf("mcp: dial EVM RPC %s: %w", cfg.EVMRPC, err)
}
return newServer(ec, cfg)
return newSurface(ec, cfg)
}
// NewWithCaller builds a Server over an already-constructed read-only EthCaller and
// the contract addresses (no dial). This is the seam the in-process tests use to
// inject the simulated backend's Client; it carries no signing capability because
// EthCaller has none. Production callers use New.
func NewWithCaller(ec EthCaller, cfg Config) (*Server, error) {
// NewWithCaller builds a Surface over an already-constructed read-only evmread.Caller and
// the contract addresses (no dial). This is the seam the in-process tests use to inject
// the simulated backend's Client; it carries no signing capability because evmread.Caller
// has none. Production callers use New.
func NewWithCaller(ec evmread.Caller, cfg Config) (*Surface, error) {
if ec == nil {
return nil, errors.New("mcp: nil EthCaller")
}
return newServer(ec, cfg)
if err := validateAddrs(cfg); err != nil {
return nil, err
}
return newSurface(ec, cfg)
}
func newServer(ec EthCaller, cfg Config) (*Server, error) {
params, err := newBoundABI(aiParamsABI, cfg.AIParams)
func validateAddrs(cfg Config) error {
if (cfg.AIParams == common.Address{}) {
return errors.New("mcp: AIParams address is zero")
}
if (cfg.AIGovernor == common.Address{}) {
return errors.New("mcp: AIGovernor address is zero")
}
if (cfg.AIThoughtRegistry == common.Address{}) {
return errors.New("mcp: AIThoughtRegistry address is zero")
}
if (cfg.AIReputation == common.Address{}) {
return errors.New("mcp: AIReputation address is zero")
}
return nil
}
func newSurface(ec evmread.Caller, cfg Config) (*Surface, error) {
params, governor, registry, rep, err := bindContracts(cfg)
if err != nil {
return nil, err
}
governor, err := newBoundABI(aiGovernorABI, cfg.AIGovernor)
if err != nil {
return nil, err
}
registry, err := newBoundABI(aiThoughtRegistryABI, cfg.AIThoughtRegistry)
if err != nil {
return nil, err
}
rep, err := newBoundABI(aiReputationABI, cfg.AIReputation)
if err != nil {
return nil, err
}
s := &Server{
return &Surface{
ec: ec,
params: params,
governor: governor,
registry: registry,
rep: rep,
callTimeout: defaultCallTimeout,
maxCallsPerRequest: defaultMaxCallsPerRequest,
}
s.tools, s.descs = registerTools()
return s, nil
}, nil
}
// boundedCaller wraps a read-only EthCaller and fails after `max` CallContract calls,
// bounding the eth_call fan-out of a SINGLE tools/call so one request cannot amplify
// into an unbounded burst of upstream calls. It forwards the non-amplifying reads
// (ChainID/BlockNumber/HeaderByNumber) unchanged. max <= 0 disables the ceiling.
type boundedCaller struct {
ec EthCaller
max int
calls atomic.Int64
}
func newBoundedCaller(ec EthCaller, max int) *boundedCaller {
return &boundedCaller{ec: ec, max: max}
}
func (b *boundedCaller) CallContract(ctx context.Context, call gethereum.CallMsg, block *big.Int) ([]byte, error) {
if b.max > 0 {
if n := b.calls.Add(1); n > int64(b.max) {
return nil, fmt.Errorf("mcp: per-request eth_call ceiling exceeded (%d) — narrow the query (smaller limit/range)", b.max)
}
}
return b.ec.CallContract(ctx, call, block)
}
func (b *boundedCaller) ChainID(ctx context.Context) (*big.Int, error) { return b.ec.ChainID(ctx) }
func (b *boundedCaller) BlockNumber(ctx context.Context) (uint64, error) {
return b.ec.BlockNumber(ctx)
}
func (b *boundedCaller) HeaderByNumber(ctx context.Context, n *big.Int) (*types.Header, error) {
return b.ec.HeaderByNumber(ctx, n)
}
// withBoundedCaller returns a shallow copy of s whose EthCaller is wrapped with a
// fresh per-request call ceiling. The copy shares the bound ABIs and tool map (all
// immutable after construction); only the read seam is swapped. This keeps each
// request's eth_call budget independent without mutating the shared server.
func (s *Server) withBoundedCaller() *Server {
cp := *s
cp.ec = newBoundedCaller(s.ec, s.maxCallsPerRequest)
return &cp
}
// ----------------------------------------------------------------------------
// JSON-RPC 2.0 envelope (stdio). Mirrors the shapes in zap/mcp/bridge.go.
// ----------------------------------------------------------------------------
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *rpcErr `json:"error,omitempty"`
}
type rpcErr struct {
Code int `json:"code"`
Message string `json:"message"`
}
// JSON-RPC error codes (subset of the spec used here).
const (
codeParseError = -32700
codeInvalidReq = -32600
codeMethodNotFnd = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
// Tool is the MCP tool descriptor (same shape as zap/mcp.Tool: ID, Name,
// Description, InputSchema with the `inputSchema` JSON key).
type Tool struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
// content is one MCP tool-result content block. Tool results are returned as a
// single text block carrying the JSON-encoded result value.
type content struct {
Type string `json:"type"`
Text string `json:"text"`
}
// Serve runs the stdio JSON-RPC loop: it reads newline-delimited JSON-RPC requests
// from `in` and writes responses to `out` until EOF or ctx cancellation. Each request
// is dispatched to initialize / tools/list / tools/call. Notifications (no id, e.g.
// notifications/initialized) get no response, per the JSON-RPC spec.
func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
if ctx.Err() != nil {
return ctx.Err()
}
line, tooLong, err := readLimitedLine(r, maxLineBytes)
if tooLong {
// The request line exceeded the cap; we did NOT buffer it whole (the rest of
// the line was drained). Reply with a parse error and keep serving — one
// oversized line cannot OOM the server or kill the loop.
if werr := writeJSONLine(w, errResp(nil, codeParseError, "request line exceeds maximum length")); werr != nil {
return werr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
} else if len(line) > 0 {
trimmed := strings.TrimSpace(string(line))
if trimmed != "" {
if resp := s.handle(ctx, []byte(trimmed)); resp != nil {
if werr := writeJSONLine(w, resp); werr != nil {
return werr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
// readLimitedLine reads one newline-terminated line but never buffers more than `max`
// bytes. If the line is longer than `max`, it returns tooLong=true with the partial
// bytes discarded (it drains the rest of the line so the NEXT read starts on a fresh
// line), so an adversarial multi-megabyte line cannot grow memory without bound. The
// returned error mirrors bufio.Reader.ReadBytes (io.EOF at stream end, possibly with a
// final unterminated line). A trailing '\n' is included in the returned slice when the
// line fit, matching the prior ReadBytes('\n') contract for the parse path.
func readLimitedLine(r *bufio.Reader, max int) (line []byte, tooLong bool, err error) {
buf := make([]byte, 0, 256)
for {
var c byte
c, err = r.ReadByte()
if err != nil {
return buf, false, err
}
if c == '\n' {
buf = append(buf, c)
return buf, false, nil
}
if len(buf) >= max {
// Over the cap: stop buffering and drain to end-of-line (or EOF) so the loop
// resynchronizes on the next line instead of mid-line.
for {
d, derr := r.ReadByte()
if derr != nil {
return nil, true, derr
}
if d == '\n' {
return nil, true, nil
}
}
}
buf = append(buf, c)
}
}
// handle parses one request line and routes it. Returns the response to write, or
// nil for notifications (requests without an id).
func (s *Server) handle(ctx context.Context, line []byte) *rpcResponse {
var req rpcRequest
if err := json.Unmarshal(line, &req); err != nil {
return errResp(nil, codeParseError, "parse error: "+err.Error())
}
// A request with no id is a notification — process side effects (none here) and
// return nothing.
isNotification := len(req.ID) == 0 || string(req.ID) == "null"
switch req.Method {
case "initialize":
if isNotification {
return nil
}
return okResp(req.ID, s.initializeResult())
case "notifications/initialized", "initialized":
return nil
case "tools/list":
if isNotification {
return nil
}
return okResp(req.ID, map[string]interface{}{"tools": s.descs})
case "tools/call":
if isNotification {
return nil
}
return s.handleToolsCall(ctx, req.ID, req.Params)
default:
if isNotification {
return nil
}
return errResp(req.ID, codeMethodNotFnd, "method not found: "+req.Method)
}
}
func (s *Server) initializeResult() map[string]interface{} {
return map[string]interface{}{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": ServerName,
"version": ServerVersion,
},
}
}
// toolCallParams is the MCP tools/call params shape.
type toolCallParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
func (s *Server) handleToolsCall(ctx context.Context, id json.RawMessage, raw json.RawMessage) *rpcResponse {
var p toolCallParams
if err := json.Unmarshal(raw, &p); err != nil {
return errResp(id, codeInvalidParams, "invalid params: "+err.Error())
}
if _, ok := s.tools[p.Name]; !ok {
return errResp(id, codeMethodNotFnd, "unknown tool: "+p.Name)
}
result, err := s.dispatch(ctx, p.Name, p.Arguments)
if err != nil {
// Tool errors (incl. timeout, call-ceiling, and recovered panics) are returned
// as a successful MCP response with isError=true so the client model sees the
// failure text (per MCP tool-result convention), not as a JSON-RPC protocol
// error, and the stdio loop keeps serving the next request.
return okResp(id, map[string]interface{}{
"content": []content{{Type: "text", Text: err.Error()}},
"isError": true,
})
}
encoded, merr := json.Marshal(result)
if merr != nil {
return errResp(id, codeInternalError, "encode result: "+merr.Error())
}
return okResp(id, map[string]interface{}{
"content": []content{{Type: "text", Text: string(encoded)}},
})
}
// dispatch runs ONE tool with the request-scoped guards: a per-call timeout (so a hung
// upstream RPC cannot wedge the server), a per-request eth_call ceiling (so one line
// cannot amplify into thousands of calls), and a panic recover (so a single bad call
// becomes one error, never a whole-server crash). The handler reads only — these guards
// add no write capability. Used by both the stdio path and CallTool, so the invariants
// hold no matter how a tool is invoked.
func (s *Server) dispatch(parent context.Context, name string, args map[string]interface{}) (result interface{}, err error) {
h, ok := s.tools[name]
if !ok {
return nil, fmt.Errorf("mcp: unknown tool %q", name)
}
if args == nil {
args = map[string]interface{}{}
}
ctx := parent
var cancel context.CancelFunc
if s.callTimeout > 0 {
ctx, cancel = context.WithTimeout(parent, s.callTimeout)
defer cancel()
}
// Fresh per-request call ceiling on a shallow server copy (shared immutable ABIs).
scoped := s.withBoundedCaller()
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("mcp: tool %q panicked: %v", name, r)
result = nil
}
}()
return h(scoped, ctx, args)
}
// CallTool runs a read tool directly (bypassing the stdio envelope) and returns the
// decoded result. Exposed for in-process callers and tests; it is read-only like
// every tool. Unknown tool names return an error.
func (s *Server) CallTool(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
if _, ok := s.tools[name]; !ok {
return nil, fmt.Errorf("mcp: unknown tool %q", name)
}
return s.dispatch(ctx, name, args)
}
// Tools returns the descriptors for the registered read tools (the tools/list
// surface). The slice is the canonical v1 surface — exactly eight read tools.
func (s *Server) Tools() []Tool { return s.descs }
func okResp(id json.RawMessage, result interface{}) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: result}
}
func errResp(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcErr{Code: code, Message: msg}}
}
func writeJSONLine(w io.Writer, v interface{}) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.Write(b)
return err
// bounded returns a fresh per-request read ceiling over the surface's caller. A NEW
// wrapper per dispatch gives each tool call an independent eth_call budget without
// mutating shared state. The ceiling lives in evmread (a pure wrapper over the read
// verb); this package only chooses the limit and resets it by constructing a new wrapper.
func (g *Surface) bounded() evmread.Caller {
return evmread.NewBounded(g.ec, g.maxCallsPerRequest)
}
+27 -7
View File
@@ -10,6 +10,8 @@ import (
"io"
"strings"
"testing"
"github.com/luxfi/mcp"
)
// TestStdioServeRoundTrip exercises the ACTUAL JSON-RPC 2.0 stdio loop (Serve),
@@ -48,11 +50,11 @@ func TestStdioServeRoundTrip(t *testing.T) {
}
result := init["result"].(map[string]interface{})
si := result["serverInfo"].(map[string]interface{})
if si["name"] != ServerName {
t.Fatalf("serverInfo.name=%v, want %s", si["name"], ServerName)
if si["name"] != mcp.ServerName {
t.Fatalf("serverInfo.name=%v, want %s", si["name"], mcp.ServerName)
}
if result["protocolVersion"] != ProtocolVersion {
t.Fatalf("protocolVersion=%v, want %s", result["protocolVersion"], ProtocolVersion)
if result["protocolVersion"] != mcp.ProtocolVersion {
t.Fatalf("protocolVersion=%v, want %s", result["protocolVersion"], mcp.ProtocolVersion)
}
// 2) tools/list: exactly 8 tools.
@@ -72,7 +74,9 @@ func TestStdioServeRoundTrip(t *testing.T) {
}
}
// 3) tools/call(chain_state): content[0].text is a JSON object with chainId etc.
// 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 {
@@ -82,10 +86,14 @@ func TestStdioServeRoundTrip(t *testing.T) {
if block["type"] != "text" {
t.Fatalf("content type=%v, want text", block["type"])
}
var state map[string]interface{}
if err := json.Unmarshal([]byte(block["text"].(string)), &state); err != nil {
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)
@@ -96,6 +104,18 @@ func TestStdioServeRoundTrip(t *testing.T) {
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
+276 -193
View File
@@ -9,11 +9,15 @@ import (
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/mcp"
"github.com/luxfi/mcp/evmread"
)
// The eight read tools — the entire v1 surface. Each reads chain facts via the
// server's read-only EthCaller (eth_call / header reads) and returns a
// JSON-serializable value. NONE writes; there is no tx-submitting path here.
// The eight read tools — the entire v1 surface. Each reads chain facts via a per-request
// bounded evmread.Caller (eth_call / header reads) and returns a JSON-serializable value
// PLUS a ChainObservation built from the exact reads it performed. NONE writes; there is
// no tx-submitting path here.
const (
toolChainState = "chain_state"
toolParamValue = "param_value"
@@ -33,10 +37,10 @@ const (
statusFailed uint8 = 3
)
// Vote values (IAIGovernor.Vote): invalid=0, yes=1, no=2, abstain=3, delay=4,
// unsafe=5. A settling group whose winning vote is anything other than Yes is NOT an
// approval — quorum_status surfaces the winning vote so an operator-LLM never reads a
// No/Unsafe/Delay quorum as a go-ahead.
// Vote values (IAIGovernor.Vote): invalid=0, yes=1, no=2, abstain=3, delay=4, unsafe=5.
// A settling group whose winning vote is anything other than Yes is NOT an approval —
// quorum_status surfaces the winning vote so an operator-LLM never reads a No/Unsafe/Delay
// quorum as a go-ahead.
const (
voteInvalid uint8 = 0
voteYes uint8 = 1
@@ -46,8 +50,8 @@ const (
voteUnsafe uint8 = 5
)
// voteLabel maps a Vote value to its IAIGovernor.Vote name (so a winning bucket reads
// as "Yes"/"No"/… and a reader cannot mistake a non-Yes quorum for an approval).
// voteLabel maps a Vote value to its IAIGovernor.Vote name (so a winning bucket reads as
// "Yes"/"No"/… and a reader cannot mistake a non-Yes quorum for an approval).
func voteLabel(v uint8) string {
switch v {
case voteYes:
@@ -73,14 +77,35 @@ const defaultLimit = 16
// backstop against amplification; this only sizes the look-back window.
const pendingScanFloor = int64(64)
// registerTools builds the handler map and the descriptor list. The two are kept
// in lockstep so tools/list always matches what tools/call can dispatch.
func registerTools() (map[string]toolHandler, []Tool) {
descs := []Tool{
// Tools yields the eight read tools as mcp.Tool values. Each Read closure wraps a FRESH
// per-request bounded caller, runs the tool body, and stamps a ChainObservation from the
// facts that body returned — so every tool result is independently verifiable. The
// transport indexes these by Name; this is governance's entire contribution.
func (g *Surface) Tools() []mcp.Tool {
// run executes a body against a fresh per-request bounded caller and builds the
// observation from the facts it returned (closing MED-8: every tool returns a
// ChainObservation of its exact reads). The ceiling lives in the bounded caller.
run := func(name string, body func(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error)) func(context.Context, map[string]interface{}) (interface{}, *mcp.ChainObservation, error) {
return func(ctx context.Context, args map[string]interface{}) (interface{}, *mcp.ChainObservation, error) {
ec := g.bounded()
val, reads, err := body(ctx, ec, args)
if err != nil {
return nil, nil, err
}
obs, err := mcp.NewObservation(ctx, ec, name, reads)
if err != nil {
return nil, nil, err
}
return val, obs, nil
}
}
return []mcp.Tool{
{
Name: toolChainState,
Description: "Current chain head: block number, chain id, latest block hash and timestamp.",
InputSchema: objSchema(nil, nil),
Read: run(toolChainState, g.toolChainState),
},
{
Name: toolParamValue,
@@ -89,6 +114,7 @@ func registerTools() (map[string]toolHandler, []Tool) {
"modelSpecHash": strSchema("bytes32 model spec hash, 0x-hex"),
"knobKey": strSchema("knob key string"),
}, []string{"modelSpecHash", "knobKey"}),
Read: run(toolParamValue, g.toolParamValue),
},
{
Name: toolParamHistory,
@@ -97,6 +123,7 @@ func registerTools() (map[string]toolHandler, []Tool) {
"limit": intSchema("max rounds to return (default 16)"),
"fromRound": intSchema("highest round id to start from (default roundCount-1)"),
}, nil),
Read: run(toolParamHistory, g.toolParamHistory),
},
{
Name: toolThoughtStatus,
@@ -104,6 +131,7 @@ func registerTools() (map[string]toolHandler, []Tool) {
InputSchema: objSchema(map[string]interface{}{
"taskId": intSchema("task id"),
}, []string{"taskId"}),
Read: run(toolThoughtStatus, g.toolThoughtStatus),
},
{
Name: toolReceiptLookup,
@@ -111,6 +139,7 @@ func registerTools() (map[string]toolHandler, []Tool) {
InputSchema: objSchema(map[string]interface{}{
"receiptId": strSchema("bytes32 receipt id, 0x-hex"),
}, []string{"receiptId"}),
Read: run(toolReceiptLookup, g.toolReceiptLookup),
},
{
Name: toolQuorumStatus,
@@ -124,6 +153,7 @@ func registerTools() (map[string]toolHandler, []Tool) {
InputSchema: objSchema(map[string]interface{}{
"taskId": intSchema("task id"),
}, []string{"taskId"}),
Read: run(toolQuorumStatus, g.toolQuorumStatus),
},
{
Name: toolOperatorReputation,
@@ -131,6 +161,7 @@ func registerTools() (map[string]toolHandler, []Tool) {
InputSchema: objSchema(map[string]interface{}{
"operator": strSchema("operator address, 0x-hex"),
}, []string{"operator"}),
Read: run(toolOperatorReputation, g.toolOperatorReputation),
},
{
Name: toolPendingOperations,
@@ -143,72 +174,75 @@ func registerTools() (map[string]toolHandler, []Tool) {
InputSchema: objSchema(map[string]interface{}{
"limit": intSchema("max open thoughts to return (default 16)"),
}, nil),
Read: run(toolPendingOperations, g.toolPendingOperations),
},
}
handlers := map[string]toolHandler{
toolChainState: (*Server).toolChainState,
toolParamValue: (*Server).toolParamValue,
toolParamHistory: (*Server).toolParamHistory,
toolThoughtStatus: (*Server).toolThoughtStatus,
toolReceiptLookup: (*Server).toolReceiptLookup,
toolQuorumStatus: (*Server).toolQuorumStatus,
toolOperatorReputation: (*Server).toolOperatorReputation,
toolPendingOperations: (*Server).toolPendingOperations,
}
return handlers, descs
}
// ----------------------------------------------------------------------------
// 1. chain_state
// ----------------------------------------------------------------------------
func (s *Server) toolChainState(ctx context.Context, _ map[string]interface{}) (interface{}, error) {
chainID, err := s.ec.ChainID(ctx)
func (g *Surface) toolChainState(ctx context.Context, ec evmread.Caller, _ map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
chainID, err := ec.ChainID(ctx)
if err != nil {
return nil, fmt.Errorf("chain_state: chainID: %w", err)
return nil, nil, fmt.Errorf("chain_state: chainID: %w", err)
}
bn, err := s.ec.BlockNumber(ctx)
bn, err := ec.BlockNumber(ctx)
if err != nil {
return nil, fmt.Errorf("chain_state: blockNumber: %w", err)
return nil, nil, fmt.Errorf("chain_state: blockNumber: %w", err)
}
hdr, err := s.ec.HeaderByNumber(ctx, nil)
hdr, err := ec.HeaderByNumber(ctx, nil)
if err != nil {
return nil, fmt.Errorf("chain_state: header: %w", err)
return nil, nil, fmt.Errorf("chain_state: header: %w", err)
}
return map[string]interface{}{
val := map[string]interface{}{
"chainId": chainID.String(),
"blockNumber": bn,
"blockHash": hdr.Hash().Hex(),
"timestamp": hdr.Time,
}, nil
}
reads := []mcp.ObservedFact{
{Key: "chainId", Value: chainID.String()},
{Key: "blockNumber", Value: fmt.Sprintf("%d", bn)},
{Key: "blockHash", Value: hdr.Hash().Hex()},
{Key: "timestamp", Value: fmt.Sprintf("%d", hdr.Time)},
}
return val, reads, nil
}
// ----------------------------------------------------------------------------
// 2. param_value
// ----------------------------------------------------------------------------
func (s *Server) toolParamValue(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolParamValue(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
spec, err := argBytes32(args, "modelSpecHash")
if err != nil {
return nil, err
return nil, nil, err
}
key, err := argString(args, "knobKey")
if err != nil {
return nil, err
return nil, nil, err
}
value, decided, err := s.readParamValue(ctx, spec, key)
value, decided, err := g.readParamValue(ctx, ec, spec, key)
if err != nil {
return nil, err
return nil, nil, err
}
return map[string]interface{}{
val := map[string]interface{}{
"value": value.String(),
"decided": decided,
}, nil
}
reads := []mcp.ObservedFact{
{Key: "knobKey", Value: key},
{Key: "value", Value: value.String()},
{Key: "decided", Value: boolStr(decided)},
}
return val, reads, nil
}
// readParamValue is the shared AIParams.valueOf read (used by the tool and tests).
func (s *Server) readParamValue(ctx context.Context, spec [32]byte, key string) (*big.Int, bool, error) {
out, err := s.params.call(ctx, s.ec, "valueOf", spec, key)
func (g *Surface) readParamValue(ctx context.Context, ec evmread.Caller, spec [32]byte, key string) (*big.Int, bool, error) {
out, err := g.params.Call(ctx, ec, "valueOf", spec, key)
if err != nil {
return nil, false, err
}
@@ -230,24 +264,29 @@ func (s *Server) readParamValue(ctx context.Context, spec [32]byte, key string)
// 3. param_history
// ----------------------------------------------------------------------------
func (s *Server) toolParamHistory(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolParamHistory(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
limit := argLimit(args, defaultLimit)
count, err := s.readRoundCount(ctx)
count, err := g.readRoundCount(ctx, ec)
if err != nil {
return nil, err
return nil, nil, err
}
rounds, err := s.readParamHistory(ctx, count, argFromRound(args, count), limit)
rounds, err := g.readParamHistory(ctx, ec, count, argFromRound(args, count), limit)
if err != nil {
return nil, err
return nil, nil, err
}
return map[string]interface{}{
val := map[string]interface{}{
"roundCount": count.String(),
"rounds": rounds,
}, nil
}
reads := []mcp.ObservedFact{
{Key: "roundCount", Value: count.String()},
{Key: "roundsReturned", Value: fmt.Sprintf("%d", len(rounds))},
}
return val, reads, nil
}
func (s *Server) readRoundCount(ctx context.Context) (*big.Int, error) {
out, err := s.params.call(ctx, s.ec, "roundCount")
func (g *Surface) readRoundCount(ctx context.Context, ec evmread.Caller) (*big.Int, error) {
out, err := g.params.Call(ctx, ec, "roundCount")
if err != nil {
return nil, err
}
@@ -259,9 +298,9 @@ func (s *Server) readRoundCount(ctx context.Context) (*big.Int, error) {
}
// readParamHistory walks rounds DESCENDING from `from` (inclusive), capped at limit,
// returning each round's fields and its proposals. `from` defaults to count-1 when
// not supplied by the caller; rounds beyond count-1 are clamped.
func (s *Server) readParamHistory(ctx context.Context, count, from *big.Int, limit int) ([]interface{}, error) {
// returning each round's fields and its proposals. `from` defaults to count-1 when not
// supplied by the caller; rounds beyond count-1 are clamped.
func (g *Surface) readParamHistory(ctx context.Context, ec evmread.Caller, count, from *big.Int, limit int) ([]interface{}, error) {
out := []interface{}{}
if count.Sign() == 0 {
return out, nil
@@ -272,11 +311,11 @@ func (s *Server) readParamHistory(ctx context.Context, count, from *big.Int, lim
start = last
}
for i := new(big.Int).Set(start); i.Sign() >= 0 && len(out) < limit; i.Sub(i, big.NewInt(1)) {
round, err := s.readRound(ctx, i)
round, err := g.readRound(ctx, ec, i)
if err != nil {
return nil, err
}
proposals, err := s.readProposals(ctx, i)
proposals, err := g.readProposals(ctx, ec, i)
if err != nil {
return nil, err
}
@@ -289,53 +328,51 @@ func (s *Server) readParamHistory(ctx context.Context, count, from *big.Int, lim
return out, nil
}
func (s *Server) readRound(ctx context.Context, roundID *big.Int) (*Round, error) {
r, err := callStruct[Round](ctx, s.params, s.ec, "getRound", roundID)
func (g *Surface) readRound(ctx context.Context, ec evmread.Caller, roundID *big.Int) (*Round, error) {
r, err := evmread.CallStruct[Round](ctx, g.params, ec, "getRound", roundID)
if err != nil {
return nil, err
}
return &r, nil
}
func (s *Server) readProposals(ctx context.Context, roundID *big.Int) ([]Proposal, error) {
return callStruct[[]Proposal](ctx, s.params, s.ec, "getProposals", roundID)
func (g *Surface) readProposals(ctx context.Context, ec evmread.Caller, roundID *big.Int) ([]Proposal, error) {
return evmread.CallStruct[[]Proposal](ctx, g.params, ec, "getProposals", roundID)
}
// ----------------------------------------------------------------------------
// 4. thought_status
// ----------------------------------------------------------------------------
func (s *Server) toolThoughtStatus(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolThoughtStatus(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
taskID, err := argUint256(args, "taskId")
if err != nil {
return nil, err
return nil, nil, err
}
t, err := s.readThought(ctx, taskID)
t, err := g.readThoughtAt(ctx, ec, nil, taskID)
if err != nil {
return nil, err
return nil, nil, err
}
count, err := s.readTaskCount(ctx)
count, err := g.readTaskCountAt(ctx, ec, nil)
if err != nil {
return nil, err
return nil, nil, err
}
res := thoughtJSON(t)
res["status"] = derivedStatus(t.Status)
res["taskCount"] = count.String()
return res, nil
}
func (s *Server) readThought(ctx context.Context, taskID *big.Int) (*Thought, error) {
return s.readThoughtAt(ctx, nil, taskID)
}
func (s *Server) readTaskCount(ctx context.Context) (*big.Int, error) {
return s.readTaskCountAt(ctx, nil)
reads := []mcp.ObservedFact{
{Key: "taskId", Value: taskID.String()},
{Key: "status", Value: derivedStatus(t.Status)},
{Key: "canonicalVote", Value: fmt.Sprintf("%d", t.CanonicalVote)},
{Key: "taskCount", Value: count.String()},
}
return res, reads, nil
}
// derivedStatus maps the on-chain Status to the operator-facing label. The chain's
// settle() moves a task Open->Settled on quorum or Open->Failed on no-quorum, so the
// derived label is: Open while accepting verdicts, Settled on quorum, NoQuorum on a
// Failed settle (the task ran but no group reached threshold). None = nonexistent.
// derived label is: Open while accepting verdicts, Settled on quorum, NoQuorum on a Failed
// settle (the task ran but no group reached threshold). None = nonexistent.
func derivedStatus(status uint8) string {
switch status {
case statusOpen:
@@ -353,86 +390,91 @@ func derivedStatus(status uint8) string {
// 5. receipt_lookup
// ----------------------------------------------------------------------------
func (s *Server) toolReceiptLookup(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolReceiptLookup(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
id, err := argBytes32(args, "receiptId")
if err != nil {
return nil, err
return nil, nil, err
}
existsOut, err := s.registry.call(ctx, s.ec, "exists", id)
existsOut, err := g.registry.Call(ctx, ec, "exists", id)
if err != nil {
return nil, err
return nil, nil, err
}
exists, ok := existsOut[0].(bool)
if !ok {
return nil, fmt.Errorf("receipt_lookup: exists not bool")
return nil, nil, fmt.Errorf("receipt_lookup: exists not bool")
}
countOut, err := s.registry.call(ctx, s.ec, "receiptCount")
countOut, err := g.registry.Call(ctx, ec, "receiptCount")
if err != nil {
return nil, err
return nil, nil, err
}
count, ok := countOut[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("receipt_lookup: receiptCount not *big.Int")
return nil, nil, fmt.Errorf("receipt_lookup: receiptCount not *big.Int")
}
res := map[string]interface{}{
"exists": exists,
"receiptCount": count.String(),
}
if exists {
rc, err := callStruct[ThoughtReceipt](ctx, s.registry, s.ec, "getReceipt", id)
rc, err := evmread.CallStruct[ThoughtReceipt](ctx, g.registry, ec, "getReceipt", id)
if err != nil {
return nil, err
return nil, nil, err
}
res["receipt"] = receiptJSON(&rc)
}
return res, nil
reads := []mcp.ObservedFact{
{Key: "receiptId", Value: common.BytesToHash(id[:]).Hex()},
{Key: "exists", Value: boolStr(exists)},
{Key: "receiptCount", Value: count.String()},
}
return res, reads, nil
}
// ----------------------------------------------------------------------------
// 6. quorum_status
// ----------------------------------------------------------------------------
func (s *Server) toolQuorumStatus(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolQuorumStatus(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
taskID, err := argUint256(args, "taskId")
if err != nil {
return nil, err
return nil, nil, err
}
// Pin every read of this tally to ONE block. settle() decides quorum from the
// operators bonded AT settle time; if we read the verdicts at block N but the bonds
// at a later head, an operator that withdrew in between could be counted (or not)
// operators bonded AT settle time; if we read the verdicts at block N but the bonds at
// a later head, an operator that withdrew in between could be counted (or not)
// inconsistently. Reading thought, verdicts and bonds all at `block` gives a single
// settle-equivalent snapshot and closes that withdraw race.
block, err := s.observedBlock(ctx)
block, err := g.observedBlock(ctx, ec)
if err != nil {
return nil, err
return nil, nil, err
}
now, err := s.blockTimestamp(ctx, block)
now, err := g.blockTimestamp(ctx, ec, block)
if err != nil {
return nil, err
return nil, nil, err
}
t, err := s.readThoughtAt(ctx, block, taskID)
t, err := g.readThoughtAt(ctx, ec, block, taskID)
if err != nil {
return nil, err
return nil, nil, err
}
verdicts, err := s.readVerdictsAt(ctx, block, taskID)
verdicts, err := g.readVerdictsAt(ctx, ec, block, taskID)
if err != nil {
return nil, err
return nil, nil, err
}
minBond, err := s.readMinBond(ctx, block)
minBond, err := g.readMinBond(ctx, ec, block)
if err != nil {
return nil, err
return nil, nil, err
}
bonded, err := s.readBondedSet(ctx, block, verdicts, minBond)
bonded, err := g.readBondedSet(ctx, ec, block, verdicts, minBond)
if err != nil {
return nil, err
return nil, nil, err
}
tally := tallyQuorum(verdicts, t.Threshold, bonded)
// settle()'s liveness gate is `block.timestamp < deadline -> revert` (AIGovernor.sol
// settle, the only gate — there is no full-committee early exit in the code path).
// So a quorum is only ACTUALLY settleable once the deadline has passed.
// settle, the only gate — there is no full-committee early exit in the code path). So a
// quorum is only ACTUALLY settleable once the deadline has passed.
deadlinePassed := now >= t.Deadline
return map[string]interface{}{
val := map[string]interface{}{
"verdicts": verdictsJSON(verdicts),
"threshold": t.Threshold,
"deadline": t.Deadline,
@@ -452,15 +494,25 @@ func (s *Server) toolQuorumStatus(ctx context.Context, args map[string]interface
"verdictsCounted": tally.counted,
"droppedUnbonded": len(verdicts) - tally.counted,
"observedBlock": block.String(),
}, nil
}
reads := []mcp.ObservedFact{
{Key: "taskId", Value: taskID.String()},
{Key: "observedBlock", Value: block.String()},
{Key: "quorumReached", Value: boolStr(tally.quorumReached)},
{Key: "winningVote", Value: fmt.Sprintf("%d", tally.winningVote)},
{Key: "verdictsCounted", Value: fmt.Sprintf("%d", tally.counted)},
{Key: "verdictsTotal", Value: fmt.Sprintf("%d", len(verdicts))},
{Key: "settleable", Value: boolStr(tally.quorumReached && deadlinePassed)},
}
return val, reads, nil
}
func (s *Server) readVerdictsAt(ctx context.Context, block, taskID *big.Int) ([]Verdict, error) {
return callStructAt[[]Verdict](ctx, s.governor, s.ec, block, "getVerdicts", taskID)
func (g *Surface) readVerdictsAt(ctx context.Context, ec evmread.Caller, block, taskID *big.Int) ([]Verdict, error) {
return evmread.CallStructAt[[]Verdict](ctx, g.governor, ec, block, "getVerdicts", taskID)
}
func (s *Server) readThoughtAt(ctx context.Context, block, taskID *big.Int) (*Thought, error) {
t, err := callStructAt[Thought](ctx, s.governor, s.ec, block, "getThought", taskID)
func (g *Surface) readThoughtAt(ctx context.Context, ec evmread.Caller, block, taskID *big.Int) (*Thought, error) {
t, err := evmread.CallStructAt[Thought](ctx, g.governor, ec, block, "getThought", taskID)
if err != nil {
return nil, err
}
@@ -469,8 +521,8 @@ func (s *Server) readThoughtAt(ctx context.Context, block, taskID *big.Int) (*Th
// readMinBond reads AIGovernor.minBond() at `block` — the bonded-eligibility floor
// settle() applies (see _bonded).
func (s *Server) readMinBond(ctx context.Context, block *big.Int) (*big.Int, error) {
out, err := s.governor.callAt(ctx, s.ec, block, "minBond")
func (g *Surface) readMinBond(ctx context.Context, ec evmread.Caller, block *big.Int) (*big.Int, error) {
out, err := g.governor.CallAt(ctx, ec, block, "minBond")
if err != nil {
return nil, err
}
@@ -481,19 +533,19 @@ func (s *Server) readMinBond(ctx context.Context, block *big.Int) (*big.Int, err
return mb, nil
}
// readBondedSet returns the set of verdict operators that are BONDED at `block` under
// the contract's exact settle predicate: AIGovernor._bonded(who) == (bondOf(who) != 0
// && bondOf(who) >= minBond). It deliberately does NOT consider the deregister flag — a
// readBondedSet returns the set of verdict operators that are BONDED at `block` under the
// contract's exact settle predicate: AIGovernor._bonded(who) == (bondOf(who) != 0 &&
// bondOf(who) >= minBond). It deliberately does NOT consider the deregister flag — a
// deregistered-but-still-bonded operator's verdict still counts at settle, and only a
// fully-withdrawn (bond == 0) operator is dropped. Each distinct operator is read once.
func (s *Server) readBondedSet(ctx context.Context, block *big.Int, verdicts []Verdict, minBond *big.Int) (map[common.Address]bool, error) {
func (g *Surface) readBondedSet(ctx context.Context, ec evmread.Caller, block *big.Int, verdicts []Verdict, minBond *big.Int) (map[common.Address]bool, error) {
bonded := make(map[common.Address]bool, len(verdicts))
for i := range verdicts {
op := verdicts[i].Operator
if _, seen := bonded[op]; seen {
continue
}
out, err := s.governor.callAt(ctx, s.ec, block, "bondOf", op)
out, err := g.governor.CallAt(ctx, ec, block, "bondOf", op)
if err != nil {
return nil, err
}
@@ -509,18 +561,18 @@ func (s *Server) readBondedSet(ctx context.Context, block *big.Int, verdicts []V
// observedBlock pins the block this tool's reads are taken at: the current head number.
// Reading it once and threading it through every call gives a consistent snapshot.
func (s *Server) observedBlock(ctx context.Context) (*big.Int, error) {
bn, err := s.ec.BlockNumber(ctx)
func (g *Surface) observedBlock(ctx context.Context, ec evmread.Caller) (*big.Int, error) {
bn, err := ec.BlockNumber(ctx)
if err != nil {
return nil, fmt.Errorf("mcp: observed block number: %w", err)
}
return new(big.Int).SetUint64(bn), nil
}
// blockTimestamp returns the timestamp of `block` (the same point the reads reflect),
// used to evaluate settle()'s deadline gate against the observed state, not a later head.
func (s *Server) blockTimestamp(ctx context.Context, block *big.Int) (uint64, error) {
hdr, err := s.ec.HeaderByNumber(ctx, block)
// blockTimestamp returns the timestamp of `block` (the same point the reads reflect), used
// to evaluate settle()'s deadline gate against the observed state, not a later head.
func (g *Surface) blockTimestamp(ctx context.Context, ec evmread.Caller, block *big.Int) (uint64, error) {
hdr, err := ec.HeaderByNumber(ctx, block)
if err != nil {
return 0, fmt.Errorf("mcp: observed block header: %w", err)
}
@@ -537,24 +589,23 @@ type quorumTally struct {
quorumReached bool
}
// tallyQuorum reproduces AIGovernor.settle()'s tally EXACTLY: it considers ONLY
// verdicts whose operator is bonded at the observed block (bonded[op] true), groups
// them by the consensus key (vote, confidenceBucket) — the same _consensusKey settle
// uses — tracks the largest group, and reports quorumReached = (largest group >=
// threshold). A verdict from a withdrawn (unbonded) operator is DROPPED, just as settle
// drops it, so MCP cannot report quorumReached=true for a quorum the chain will settle
// Failed. winningVote/winningBucket name the best group so a non-Yes quorum is legible.
// votesFor / votesAgainst are the Yes / No counts among the BONDED set. Pure projection
// of on-chain reads — it submits nothing.
// tallyQuorum reproduces AIGovernor.settle()'s tally EXACTLY: it considers ONLY verdicts
// whose operator is bonded at the observed block (bonded[op] true), groups them by the
// consensus key (vote, confidenceBucket) — the same _consensusKey settle uses — tracks the
// largest group, and reports quorumReached = (largest group >= threshold). A verdict from a
// withdrawn (unbonded) operator is DROPPED, just as settle drops it, so MCP cannot report
// quorumReached=true for a quorum the chain will settle Failed. winningVote/winningBucket
// name the best group so a non-Yes quorum is legible. votesFor / votesAgainst are the Yes /
// No counts among the BONDED set. Pure projection of on-chain reads — it submits nothing.
//
// Structural invariant (load-bearing): AIGovernor sets threshold = n/2 + 1, so AT MOST
// ONE (vote,bucket) group can ever reach quorum — two groups clearing threshold would
// need 2*threshold <= n, i.e. n+2 <= n, impossible. A withdrawal can therefore only
// DESTROY a quorum, never transfer it to a different vote-group. The first-seen strict-`>`
// tie-break below thus only governs the cosmetic winningVote/winningBucket on the
// no-quorum path (settle records Invalid there). If the threshold formula in
// AIGovernor.sol ever drops below n/2+1, this single-winner guarantee breaks and the
// tie-break parity with settle() becomes quorum-affecting — re-audit HIGH-1 then.
// Structural invariant (load-bearing): AIGovernor sets threshold = n/2 + 1, so AT MOST ONE
// (vote,bucket) group can ever reach quorum — two groups clearing threshold would need
// 2*threshold <= n, i.e. n+2 <= n, impossible. A withdrawal can therefore only DESTROY a
// quorum, never transfer it to a different vote-group. The first-seen strict-`>` tie-break
// below thus only governs the cosmetic winningVote/winningBucket on the no-quorum path
// (settle records Invalid there). If the threshold formula in AIGovernor.sol ever drops
// below n/2+1, this single-winner guarantee breaks and the tie-break parity with settle()
// becomes quorum-affecting — re-audit HIGH-1 then.
func tallyQuorum(verdicts []Verdict, threshold uint8, bonded map[common.Address]bool) quorumTally {
type key struct {
vote uint8
@@ -562,8 +613,8 @@ func tallyQuorum(verdicts []Verdict, threshold uint8, bonded map[common.Address]
}
groups := map[key]int{}
var t quorumTally
// Iterate in verdict order so ties resolve to the FIRST-seen group, matching
// settle()'s submitter-order scan (it keeps the earliest key at a given bestCount).
// Iterate in verdict order so ties resolve to the FIRST-seen group, matching settle()'s
// submitter-order scan (it keeps the earliest key at a given bestCount).
var bestKey key
haveBest := false
for i := range verdicts {
@@ -598,101 +649,117 @@ func tallyQuorum(verdicts []Verdict, threshold uint8, bonded map[common.Address]
// 7. operator_reputation
// ----------------------------------------------------------------------------
func (s *Server) toolOperatorReputation(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolOperatorReputation(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
op, err := argAddress(args, "operator")
if err != nil {
return nil, err
return nil, nil, err
}
isOpOut, err := s.governor.call(ctx, s.ec, "isOperator", op)
isOpOut, err := g.governor.Call(ctx, ec, "isOperator", op)
if err != nil {
return nil, err
return nil, nil, err
}
isOp, ok := isOpOut[0].(bool)
if !ok {
return nil, fmt.Errorf("operator_reputation: isOperator not bool")
return nil, nil, fmt.Errorf("operator_reputation: isOperator not bool")
}
bondOut, err := s.governor.call(ctx, s.ec, "bondOf", op)
bondOut, err := g.governor.Call(ctx, ec, "bondOf", op)
if err != nil {
return nil, err
return nil, nil, err
}
bond, ok := bondOut[0].(*big.Int)
if !ok {
return nil, fmt.Errorf("operator_reputation: bondOf not *big.Int")
return nil, nil, fmt.Errorf("operator_reputation: bondOf not *big.Int")
}
weightOut, err := s.rep.call(ctx, s.ec, "weightOf", op)
weightOut, err := g.rep.Call(ctx, ec, "weightOf", op)
if err != nil {
return nil, err
return nil, nil, err
}
weight, ok := weightOut[0].(uint32)
if !ok {
return nil, fmt.Errorf("operator_reputation: weightOf not uint32")
return nil, nil, fmt.Errorf("operator_reputation: weightOf not uint32")
}
rateOut, err := s.rep.call(ctx, s.ec, "agreementRateBps", op)
rateOut, err := g.rep.Call(ctx, ec, "agreementRateBps", op)
if err != nil {
return nil, err
return nil, nil, err
}
rate, ok := rateOut[0].(uint32)
if !ok {
return nil, fmt.Errorf("operator_reputation: agreementRateBps not uint32")
return nil, nil, fmt.Errorf("operator_reputation: agreementRateBps not uint32")
}
rep, err := callStruct[Rep](ctx, s.rep, s.ec, "repOf", op)
rep, err := evmread.CallStruct[Rep](ctx, g.rep, ec, "repOf", op)
if err != nil {
return nil, err
return nil, nil, err
}
return map[string]interface{}{
val := map[string]interface{}{
"isOperator": isOp,
"bond": bond.String(),
"weight": weight,
"agreementRateBps": rate,
"rep": repJSON(&rep),
}, nil
}
reads := []mcp.ObservedFact{
{Key: "operator", Value: op.Hex()},
{Key: "isOperator", Value: boolStr(isOp)},
{Key: "bond", Value: bond.String()},
{Key: "weight", Value: fmt.Sprintf("%d", weight)},
{Key: "agreementRateBps", Value: fmt.Sprintf("%d", rate)},
}
return val, reads, nil
}
// ----------------------------------------------------------------------------
// 8. pending_operations
// ----------------------------------------------------------------------------
func (s *Server) toolPendingOperations(ctx context.Context, args map[string]interface{}) (interface{}, error) {
func (g *Surface) toolPendingOperations(ctx context.Context, ec evmread.Caller, args map[string]interface{}) (interface{}, []mcp.ObservedFact, error) {
limit := argLimit(args, defaultLimit)
// Pin reads to one block so taskCount, each thought, and the "now" used for the
// deadlinePassed flag all reflect the same chain point.
block, err := s.observedBlock(ctx)
block, err := g.observedBlock(ctx, ec)
if err != nil {
return nil, err
return nil, nil, err
}
now, err := s.blockTimestamp(ctx, block)
now, err := g.blockTimestamp(ctx, ec, block)
if err != nil {
return nil, err
return nil, nil, err
}
count, err := s.readTaskCountAt(ctx, block)
count, err := g.readTaskCountAt(ctx, ec, block)
if err != nil {
return nil, err
return nil, nil, err
}
open, truncated, scannedFrom, err := s.readPendingOperations(ctx, block, count, limit, now)
open, truncated, scannedFrom, err := g.readPendingOperations(ctx, ec, block, count, limit, now)
if err != nil {
return nil, err
return nil, nil, err
}
return map[string]interface{}{
val := map[string]interface{}{
"taskCount": count.String(),
"pending": open,
// truncated is true when OLDER tasks below the scanned window were NOT inspected,
// so the caller knows this list may be incomplete (a deep still-Open task can
// hide below the tail window). scannedFrom..(taskCount-1) is the range looked at.
// truncated is true when OLDER tasks below the scanned window were NOT inspected, so
// the caller knows this list may be incomplete (a deep still-Open task can hide below
// the tail window). scannedFrom..(taskCount-1) is the range looked at.
"truncated": truncated,
"scannedFrom": scannedFrom.String(),
"observedBlock": block.String(),
}, nil
}
reads := []mcp.ObservedFact{
{Key: "observedBlock", Value: block.String()},
{Key: "taskCount", Value: count.String()},
{Key: "pendingReturned", Value: fmt.Sprintf("%d", len(open))},
{Key: "truncated", Value: boolStr(truncated)},
{Key: "scannedFrom", Value: scannedFrom.String()},
}
return val, reads, nil
}
// readPendingOperations scans the tail of taskCount descending and returns thoughts
// still in Open status, capped at limit. (Open means the task is accepting verdicts or
// awaiting settle; the chain only leaves Open via settle.) Each entry is annotated with
// deadlinePassed = now >= deadline, so the caller sees that a still-"Open" task whose
// voting window has CLOSED is settle-ready even though settle was never called. Returns
// truncated=true when the scan stopped above task 0 (older tasks were not inspected),
// and the lowest task id scanned. The bounded window plus the per-request eth_call
// ceiling keep a huge taskCount from issuing an unbounded scan.
func (s *Server) readPendingOperations(ctx context.Context, block, count *big.Int, limit int, now uint64) ([]interface{}, bool, *big.Int, error) {
// readPendingOperations scans the tail of taskCount descending and returns thoughts still
// in Open status, capped at limit. (Open means the task is accepting verdicts or awaiting
// settle; the chain only leaves Open via settle.) Each entry is annotated with
// deadlinePassed = now >= deadline, so the caller sees that a still-"Open" task whose voting
// window has CLOSED is settle-ready even though settle was never called. Returns
// truncated=true when the scan stopped above task 0 (older tasks were not inspected), and
// the lowest task id scanned. The bounded window plus the per-request eth_call ceiling keep
// a huge taskCount from issuing an unbounded scan.
func (g *Surface) readPendingOperations(ctx context.Context, ec evmread.Caller, block, count *big.Int, limit int, now uint64) ([]interface{}, bool, *big.Int, error) {
out := []interface{}{}
last := new(big.Int).Sub(count, big.NewInt(1))
if count.Sign() == 0 {
@@ -706,13 +773,13 @@ func (s *Server) readPendingOperations(ctx context.Context, block, count *big.In
if floor.Sign() < 0 {
floor = big.NewInt(0)
}
// lowestInspected tracks the smallest task id we actually read; truncated is then
// simply "did we stop before reaching task 0". This is honest whether the loop ended
// on the limit or on the window floor.
// lowestInspected tracks the smallest task id we actually read; truncated is then simply
// "did we stop before reaching task 0". This is honest whether the loop ended on the
// limit or on the window floor.
lowestInspected := new(big.Int).Set(last)
for i := new(big.Int).Set(last); i.Cmp(floor) >= 0 && len(out) < limit; i.Sub(i, big.NewInt(1)) {
lowestInspected.Set(i)
t, err := s.readThoughtAt(ctx, block, i)
t, err := g.readThoughtAt(ctx, ec, block, i)
if err != nil {
return nil, false, nil, err
}
@@ -722,8 +789,8 @@ func (s *Server) readPendingOperations(ctx context.Context, block, count *big.In
res := thoughtJSON(t)
res["taskId"] = new(big.Int).Set(i).String()
res["status"] = derivedStatus(t.Status)
// A still-Open task past its deadline is settle-ready but un-settled — flag it so
// the LLM does not read "Open" as "still accepting / outcome not yet fixed".
// A still-Open task past its deadline is settle-ready but un-settled — flag it so the
// LLM does not read "Open" as "still accepting / outcome not yet fixed".
res["deadlinePassed"] = now >= t.Deadline
out = append(out, res)
}
@@ -731,8 +798,8 @@ func (s *Server) readPendingOperations(ctx context.Context, block, count *big.In
return out, truncated, lowestInspected, nil
}
func (s *Server) readTaskCountAt(ctx context.Context, block *big.Int) (*big.Int, error) {
out, err := s.governor.callAt(ctx, s.ec, block, "taskCount")
func (g *Surface) readTaskCountAt(ctx context.Context, ec evmread.Caller, block *big.Int) (*big.Int, error) {
out, err := g.governor.CallAt(ctx, ec, block, "taskCount")
if err != nil {
return nil, err
}
@@ -845,3 +912,19 @@ func repJSON(r *Rep) map[string]interface{} {
func hexBytes32(b [32]byte) string {
return common.BytesToHash(b[:]).Hex()
}
// bigString renders a *big.Int as a decimal string, treating nil as "0".
func bigString(v *big.Int) string {
if v == nil {
return "0"
}
return v.String()
}
// boolStr renders a bool as the canonical "true"/"false" string used in observation facts.
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
+9 -5
View File
@@ -1,7 +1,7 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
package mcp
import (
"context"
@@ -11,6 +11,8 @@ import (
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/mcp/evmread"
)
// ChainObservation is the verifiable record of WHAT the chain said and WHEN.
@@ -52,10 +54,12 @@ type ObservedFact struct {
Value string `json:"value"`
}
// newObservation builds an observation by reading the current chain head once and
// NewObservation builds an observation by reading the current chain head once and
// stamping it with the supplied reads (which the caller has already canonicalized).
// The reads are sorted by key so the observation is order-independent.
func newObservation(ctx context.Context, ec EthCaller, tool string, reads []ObservedFact) (*ChainObservation, error) {
// The reads are sorted by key so the observation is order-independent. A domain tool
// (e.g. governance) calls this with the exact facts it returned, so its result can be
// bound into a verdict and re-verified later.
func NewObservation(ctx context.Context, ec evmread.Caller, tool string, reads []ObservedFact) (*ChainObservation, error) {
chainID, err := ec.ChainID(ctx)
if err != nil {
return nil, fmt.Errorf("mcp: observation chainID: %w", err)
@@ -169,7 +173,7 @@ func (o *ChainObservation) Hash() common.Hash {
// safe to act on. A read error is surfaced as a non-nil error with fresh=false.
//
// maxAgeBlocks == 0 means "must be the exact head block" (zero tolerance).
func (o *ChainObservation) Verify(ctx context.Context, ec EthCaller, maxAgeBlocks uint64) (bool, error) {
func (o *ChainObservation) Verify(ctx context.Context, ec evmread.Caller, maxAgeBlocks uint64) (bool, error) {
// Chain identity: the observation must be checked against the SAME chain it was taken
// on. A mismatch means we are pointed at a different network entirely (e.g. testnet vs
// mainnet), so block numbers/hashes are not comparable and the observation is invalid
+67
View File
@@ -0,0 +1,67 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mcp
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// TestObservationDedupsDuplicateKeys is the NIT test: an observation whose Reads name the
// SAME key twice must canonicalize deterministically (last-writer-wins), so two parties
// cannot produce divergent hashes from a dup-key set, and the dup collapses to one entry.
// White-box (package mcp) because it asserts the unexported sortReads collapse directly.
func TestObservationDedupsDuplicateKeys(t *testing.T) {
mk := func(reads []ObservedFact) *ChainObservation {
return &ChainObservation{
ChainID: big.NewInt(7),
BlockNumber: 42,
BlockHash: common.HexToHash("0x01"),
Timestamp: 1000,
Tool: "param_value",
Reads: reads,
}
}
// Two sets that differ only in the ORDER of a duplicated "value" key. Last writer
// ("final") must win in BOTH, so the canonical bytes and hash are identical.
a := mk([]ObservedFact{
{Key: "knobKey", Value: "temp"},
{Key: "value", Value: "first"},
{Key: "value", Value: "final"},
})
b := mk([]ObservedFact{
{Key: "value", Value: "first"},
{Key: "knobKey", Value: "temp"},
{Key: "value", Value: "final"},
})
if string(a.Canonical()) != string(b.Canonical()) {
t.Fatalf("dup-key observations not canonical-equal:\n a=%s\n b=%s", a.Canonical(), b.Canonical())
}
if a.Hash() != b.Hash() {
t.Fatalf("dup-key observations hashed differently: %s vs %s", a.Hash().Hex(), b.Hash().Hex())
}
// The duplicate must collapse to a single "value" entry equal to the last writer.
c := mk([]ObservedFact{
{Key: "value", Value: "first"},
{Key: "value", Value: "final"},
})
c.sortReads()
count := 0
var kept string
for _, r := range c.Reads {
if r.Key == "value" {
count++
kept = r.Value
}
}
if count != 1 {
t.Fatalf("duplicate key not collapsed: %d 'value' entries remain", count)
}
if kept != "final" {
t.Fatalf("last-writer-wins broken: kept %q, want final", kept)
}
}
+168 -92
View File
@@ -1,36 +1,47 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package governance
package mcp_test
import (
"context"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"math/big"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/mcp"
"github.com/luxfi/mcp/evmread"
"github.com/luxfi/mcp/governance"
)
// TestMCPReadOnlyToolsCannotSubmitTx is the read-only acceptance gate. It proves two
// things:
// TestModuleIsReadOnly is the read-only acceptance gate for the WHOLE shared module
// (root mcp + evmread + governance + cmd). It proves five things:
//
// 1. The package's OWN code (non-test .go files) REFERENCES no transaction-signing
// or transaction-submitting symbol. The check parses each file with go/parser and
// inspects the AST — selector expressions (pkg.Symbol), identifiers, and imports —
// so prose in comments/strings that merely NAMES these tokens (as this package's
// docs do, to explain the invariant) is correctly ignored. Only real code use
// fails the test.
// 2. tools/list exposes EXACTLY the eight read tools — no more.
// 1. The module's non-test code REFERENCES no transaction-signing or transaction-
// submitting symbol (AST scan — prose in comments/strings is ignored).
// 2. The ROOT go-ethereum package (github.com/luxfi/geth) is imported by exactly ONE
// package: the evmread adapter. Nothing else may import it.
// 3. NO package imports accounts/abi/bind, geth/crypto, geth/rpc, or reflect.
// 4. evmread.Caller's method set is EXACTLY the four read verbs — a positive assertion
// stronger than the denylist (a new write method fails here even if unnamed on a list).
// 5. tools/list exposes EXACTLY the eight governance read tools — no more.
//
// The scan is scoped to non-test files because the test harness legitimately signs
// and sends transactions to set up chain state; the invariant is about the LIBRARY
// the operator runs, not the test scaffolding.
func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
// The scan is scoped to non-test files because the test harness legitimately signs and
// sends transactions to set up chain state; the invariant is about the LIBRARY the
// operator runs, not the test scaffolding.
func TestModuleIsReadOnly(t *testing.T) {
// ALWAYS-forbidden symbols — these names have no legitimate read-only use, so any
// reference (qualified or bare) is a failure.
alwaysForbidden := map[string]bool{
@@ -42,10 +53,10 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
"DeployContract": true, // sends a creation tx
"PrivateKey": true, // ecdsa.PrivateKey / *.PrivateKey field
}
// CONTEXT-forbidden: a method/func named like a signing op (e.g. "Sign",
// "GenerateKey") is forbidden ONLY when qualified by a crypto/tx package. This
// avoids false positives on legitimate, unrelated methods such as
// (*big.Int).Sign(), which reports a number's sign and is read-only.
// CONTEXT-forbidden: a method/func named like a signing op (e.g. "Sign", "GenerateKey")
// is forbidden ONLY when qualified by a crypto/tx package. This avoids false positives on
// legitimate, unrelated methods such as (*big.Int).Sign(), which reports a number's sign
// and is read-only.
signingNames := map[string]bool{
"Sign": true,
"SignTx": true,
@@ -59,22 +70,23 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
"bind": true, // accounts/abi/bind
"types": true, // types.SignTx
}
// Forbidden imports — packages that exist to sign or send txs.
// Forbidden imports — packages that exist to sign or send txs (plus reflect, which could
// call a write method past the static denylist).
forbiddenImports := map[string]bool{
"github.com/luxfi/geth/accounts/abi/bind": true, // transactors / DeployContract
"github.com/luxfi/geth/crypto": true, // signing primitives
"github.com/luxfi/geth/rpc": true, // rpc.Client.CallContext can send raw tx
"reflect": true, // reflect.MethodByName could call a write method past the static denylist
}
// Forbidden RPC method strings — even passed as a literal to a generic RPC caller,
// these are write methods; a read-only server must never name them.
// Forbidden RPC method strings — even passed as a literal to a generic RPC caller, these
// are write methods; a read-only server must never name them.
forbiddenRPCStrings := map[string]bool{
"eth_sendTransaction": true,
"eth_sendRawTransaction": true,
}
// Substring markers: any literal CONTAINING one of these is a write-path smell,
// which defeats string-concatenation evasion ("eth_send"+"RawTransaction") and the
// bare method verbs ("SendTransaction") that reflect.MethodByName would consume.
// Substring markers: any literal CONTAINING one of these is a write-path smell, which
// defeats string-concatenation evasion ("eth_send"+"RawTransaction") and the bare method
// verbs ("SendTransaction") that reflect.MethodByName would consume.
forbiddenRPCStringFragments := map[string]bool{
"eth_send": true,
"SendTransaction": true,
@@ -82,8 +94,9 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
}
fset := token.NewFileSet()
// Walk the WHOLE package tree (this dir AND subpackages such as cmd/aivm-gov-mcp),
// so a write path added in the command binary cannot slip past a top-dir-only scan.
// Walk the WHOLE module tree from the module root (this package's dir): root mcp +
// evmread + governance + cmd. A write path added anywhere cannot slip past a single-dir
// scan.
var files []string
if err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -103,37 +116,52 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
files = append(files, path)
return nil
}); err != nil {
t.Fatalf("walk package tree: %v", err)
t.Fatalf("walk module tree: %v", err)
}
scanned := 0
sawCmd := false
sawEvmread := false
sawGovernance := false
gethImporters := map[string]bool{} // package dir -> imports root geth
for _, path := range files {
if sp := filepath.ToSlash(path); strings.Contains(sp, "cmd/") {
sp := filepath.ToSlash(path)
if strings.Contains(sp, "cmd/") {
sawCmd = true
}
if strings.Contains(sp, "evmread/") {
sawEvmread = true
}
if strings.Contains(sp, "governance/") {
sawGovernance = true
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
// Parse WITHOUT comments so prose can never trip the check; we walk the AST,
// which contains only real code nodes (plus string literals, checked explicitly).
// Parse WITHOUT comments so prose can never trip the check; we walk the AST, which
// contains only real code nodes (plus string literals, checked explicitly).
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
scanned++
pkgDir := filepath.Dir(sp)
// Imports.
for _, imp := range f.Imports {
ip := strings.Trim(imp.Path.Value, `"`)
// observation.go imports github.com/luxfi/crypto ONLY for Keccak256 (a pure
// hash, not signing). That import path is NOT in forbiddenImports, so it is
// allowed; we additionally assert below that the only crypto symbol it uses
// is the hash.
// observation.go imports github.com/luxfi/crypto ONLY for Keccak256 (a pure hash,
// not signing). That import path is NOT in forbiddenImports, so it is allowed; we
// additionally assert below that the only crypto symbol it uses is the hash.
if forbiddenImports[ip] {
t.Errorf("FORBIDDEN import %q in %s (a signing/tx-send package)", ip, path)
}
// The ONE allowed importer of the ROOT go-ethereum package is evmread.
if ip == "github.com/luxfi/geth" {
gethImporters[pkgDir] = true
}
}
// Selector, identifier, and string-literal references in real code.
@@ -141,37 +169,37 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
switch x := n.(type) {
case *ast.SelectorExpr:
if alwaysForbidden[x.Sel.Name] {
t.Errorf("FORBIDDEN write-path symbol %q referenced in %s — the MCP package must be read-only",
t.Errorf("FORBIDDEN write-path symbol %q referenced in %s — the module must be read-only",
selString(x), path)
}
// A signing-named method is forbidden only under a crypto/tx package.
if signingNames[x.Sel.Name] {
if pkg, ok := x.X.(*ast.Ident); ok && signingPkgs[pkg.Name] {
t.Errorf("FORBIDDEN signing call %q in %s — the MCP package must not sign",
t.Errorf("FORBIDDEN signing call %q in %s — the module must not sign",
selString(x), path)
}
}
case *ast.Ident:
// Bare identifiers (e.g. a local named PrivateKey). Only the
// always-forbidden names are checked bare; signing-named methods are
// only meaningful when package-qualified (handled above).
// Bare identifiers (e.g. a local named PrivateKey). Only the always-forbidden
// names are checked bare; signing-named methods are only meaningful when
// package-qualified (handled above).
if alwaysForbidden[x.Name] {
t.Errorf("FORBIDDEN write-path identifier %q in %s", x.Name, path)
}
case *ast.BasicLit:
// Belt-and-suspenders: catch a write JSON-RPC method name passed as a
// string literal to any generic caller, even if the caller package were
// allowed. The package legitimately uses no raw RPC method strings.
// Substring (not exact) match so a FRAGMENTED literal — e.g.
// "eth_send" + "RawTransaction" — is still caught on each fragment.
// Belt-and-suspenders: catch a write JSON-RPC method name passed as a string
// literal to any generic caller, even if the caller package were allowed. The
// module legitimately uses no raw RPC method strings. Substring (not exact)
// match so a FRAGMENTED literal — e.g. "eth_send" + "RawTransaction" — is still
// caught on each fragment.
if x.Kind == token.STRING {
if lit, lerr := unquote(x.Value); lerr == nil {
if forbiddenRPCStrings[lit] {
t.Errorf("FORBIDDEN write RPC method string %q in %s — read-only server must not name it", lit, path)
t.Errorf("FORBIDDEN write RPC method string %q in %s — read-only module must not name it", lit, path)
}
for marker := range forbiddenRPCStringFragments {
if strings.Contains(lit, marker) {
t.Errorf("FORBIDDEN write-path string fragment %q (in %q) in %s — read-only server must not name it", marker, lit, path)
t.Errorf("FORBIDDEN write-path string fragment %q (in %q) in %s — read-only module must not name it", marker, lit, path)
}
}
}
@@ -189,20 +217,43 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
t.Fatal("scanned 0 source files — scan is broken")
}
if !sawCmd {
t.Fatal("scan never visited the cmd/ subpackage — the recursive walk is broken (a write path could hide there)")
t.Fatal("scan never visited cmd/ — the recursive walk is broken (a write path could hide there)")
}
t.Logf("AST-scanned %d non-test source files (incl. cmd/): no write-path symbol referenced", scanned)
if !sawEvmread {
t.Fatal("scan never visited evmread/ — the recursive walk is broken")
}
if !sawGovernance {
t.Fatal("scan never visited governance/ — the recursive walk is broken")
}
t.Logf("AST-scanned %d non-test source files (root + evmread + governance + cmd): no write-path symbol referenced", scanned)
// POSITIVE assertion: the ONLY chain dependency is EthCaller, and its method set is
// EXACTLY the four read methods — no Send*/sign verb. This is stronger than the
// denylist: a new write method on the chain interface fails here even if its NAME is
// not on any forbidden list.
assertEthCallerIsReadOnly(t, fset)
// The ROOT go-ethereum package must be imported by EXACTLY the evmread adapter.
if len(gethImporters) != 1 || !gethImporters["evmread"] {
t.Errorf("root github.com/luxfi/geth must be imported ONLY by evmread; importers=%v", keysOf(gethImporters))
} else {
t.Logf("root github.com/luxfi/geth imported ONLY by evmread (the sole adapter): %v", keysOf(gethImporters))
}
// tools/list surface: exactly the eight read tools, by name.
keys := genKeys(t, 1)
_, env := newEVMChain(t, keys)
srv := env.mcpServer()
// POSITIVE assertion: the ONLY chain dependency is evmread.Caller, and its method set is
// EXACTLY the four read methods — no Send*/sign verb.
assertCallerIsReadOnly(t, fset)
// tools/list surface: exactly the eight read tools, by name. Tools() returns the static
// descriptors regardless of the caller, so a non-dialing fake caller suffices here.
addr := common.HexToAddress("0x000000000000000000000000000000000000dEaD")
g, err := governance.NewWithCaller(noopCaller{}, governance.Config{
AIParams: addr,
AIGovernor: addr,
AIThoughtRegistry: addr,
AIReputation: addr,
})
if err != nil {
t.Fatalf("build governance surface: %v", err)
}
srv, err := mcp.NewServer(g)
if err != nil {
t.Fatalf("build server: %v", err)
}
got := make([]string, 0, len(srv.Tools()))
for _, tl := range srv.Tools() {
@@ -232,12 +283,11 @@ func TestMCPReadOnlyToolsCannotSubmitTx(t *testing.T) {
}
}
// assertCryptoHashOnly verifies that the only github.com/luxfi/crypto symbol used in
// `f` is Keccak256 (a pure hash). Any other crypto.* selector (e.g. crypto.Sign)
// fails — proving observation.go's crypto dependency is hashing, never signing.
// assertCryptoHashOnly verifies that the only github.com/luxfi/crypto symbol used in `f`
// is Keccak256 (a pure hash). Any other crypto.* selector (e.g. crypto.Sign) fails —
// proving observation.go's crypto dependency is hashing, never signing.
func assertCryptoHashOnly(t *testing.T, f *ast.File, name string) {
t.Helper()
// Find the local name bound to the crypto import (usually "crypto").
alias := ""
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == "github.com/luxfi/crypto" {
@@ -267,34 +317,22 @@ func assertCryptoHashOnly(t *testing.T, f *ast.File, name string) {
})
}
// selString renders a selector expression like "pkg.Symbol" for error messages.
func selString(s *ast.SelectorExpr) string {
if id, ok := s.X.(*ast.Ident); ok {
return id.Name + "." + s.Sel.Name
}
return s.Sel.Name
}
// unquote strips Go string-literal quoting (handles both "..." and `...`).
func unquote(lit string) (string, error) {
return strconv.Unquote(lit)
}
// assertEthCallerIsReadOnly parses abi.go, finds the EthCaller interface, and asserts
// its method set is EXACTLY the four read methods. This is the positive complement to
// the denylist: it FAILS if any method is added or renamed (e.g. a SendTransaction
// slipped onto the chain seam), so the read-only surface cannot silently grow. The four
// methods — CallContract (read-only eth_call), ChainID, BlockNumber, HeaderByNumber —
// are all read verbs with no transaction-submitting capability.
func assertEthCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
// assertCallerIsReadOnly parses the evmread adapter, finds the Caller interface, and
// asserts its method set is EXACTLY the four read methods. FAILS if any method is added or
// renamed (e.g. a SendTransaction slipped onto the chain seam), so the read-only surface
// cannot silently grow. The four methods — CallContract (read-only eth_call), ChainID,
// BlockNumber, HeaderByNumber — are all read verbs with no transaction-submitting
// capability.
func assertCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
t.Helper()
src, err := os.ReadFile("abi.go")
const path = "evmread/evmread.go"
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read abi.go: %v", err)
t.Fatalf("read %s: %v", path, err)
}
f, err := parser.ParseFile(fset, "abi.go", src, 0)
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
t.Fatalf("parse abi.go: %v", err)
t.Fatalf("parse %s: %v", path, err)
}
want := map[string]bool{
@@ -308,18 +346,18 @@ func assertEthCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
found := false
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok || ts.Name.Name != "EthCaller" {
if !ok || ts.Name.Name != "Caller" {
return true
}
iface, ok := ts.Type.(*ast.InterfaceType)
if !ok {
t.Fatalf("EthCaller is not an interface type")
t.Fatalf("Caller is not an interface type")
}
found = true
for _, m := range iface.Methods.List {
// Embedded interfaces (no Names) would widen the surface invisibly — reject.
if len(m.Names) == 0 {
t.Errorf("EthCaller embeds another interface — read-only surface must be explicit, not embedded")
t.Errorf("evmread.Caller embeds another interface — read-only surface must be explicit, not embedded")
continue
}
for _, nm := range m.Names {
@@ -329,7 +367,7 @@ func assertEthCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
return false
})
if !found {
t.Fatal("EthCaller interface not found in abi.go — the read-only chain seam is the invariant's anchor")
t.Fatal("evmread.Caller interface not found — the read-only chain seam is the invariant's anchor")
}
sort.Strings(methods)
@@ -337,13 +375,51 @@ func assertEthCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
for _, m := range methods {
got[m] = true
if !want[m] {
t.Errorf("EthCaller exposes unexpected method %q — only read methods are allowed on the chain seam", m)
t.Errorf("evmread.Caller exposes unexpected method %q — only read methods are allowed on the chain seam", m)
}
}
for m := range want {
if !got[m] {
t.Errorf("EthCaller is missing expected read method %q", m)
t.Errorf("evmread.Caller is missing expected read method %q", m)
}
}
t.Logf("EthCaller method set verified read-only: %v", methods)
t.Logf("evmread.Caller method set verified read-only: %v", methods)
}
// selString renders a selector expression like "pkg.Symbol" for error messages.
func selString(s *ast.SelectorExpr) string {
if id, ok := s.X.(*ast.Ident); ok {
return id.Name + "." + s.Sel.Name
}
return s.Sel.Name
}
// unquote strips Go string-literal quoting (handles both "..." and `...`).
func unquote(lit string) (string, error) {
return strconv.Unquote(lit)
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// noopCaller is a non-dialing evmread.Caller for the tools/list assertion (Tools() never
// touches the chain, so these methods are never called). It exists only to satisfy the
// read-only seam without a network dial.
type noopCaller struct{}
func (noopCaller) CallContract(_ context.Context, _ gethereum.CallMsg, _ *big.Int) ([]byte, error) {
return nil, nil
}
func (noopCaller) ChainID(context.Context) (*big.Int, error) { return big.NewInt(1), nil }
func (noopCaller) BlockNumber(context.Context) (uint64, error) { return 0, nil }
func (noopCaller) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) {
return nil, nil
}
var _ evmread.Caller = noopCaller{}
+458
View File
@@ -0,0 +1,458 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package mcp is the domain-agnostic, READ-ONLY MCP (Model Context Protocol) transport
// for the Lux governance stack — the "sensory layer" an operator-LLM uses to query
// chain facts during deliberation.
//
// One transport, many surfaces. This package owns the JSON-RPC 2.0 over stdio loop, the
// tool dispatch guards (per-call timeout, panic recover, request line cap), and the
// ChainObservation value every read returns. It knows NOTHING about any specific chain
// contract: a domain contributes a Surface — a []Tool — and Serve indexes and dispatches
// them. (Rich Hickey decomplecting: the transport is "run named read tools over stdio";
// a tool is a VALUE carrying its own Read closure; the chain-reading mechanics live in
// github.com/luxfi/mcp/evmread; a domain like governance/ supplies only the tools.)
//
// READ-ONLY BY CONSTRUCTION. The transport links no signing or transaction-submission
// primitive. The only chain access a tool can do is through evmread.Caller (the
// read-only eth_call plus header/chainId/blockNumber readers); there is no
// SendTransaction, no keyed transactor, no eth_sendRawTransaction anywhere in this
// module. The read-only gate (readonly_test.go) scans the WHOLE module to assert this,
// and asserts evmread.Caller's method set is exactly the four read verbs.
//
// Transport is JSON-RPC 2.0 over stdio (newline-delimited): Serve reads requests from an
// io.Reader and writes responses to an io.Writer. Methods: initialize, tools/list,
// tools/call. Standard library only — no external MCP SDK.
package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
)
// ServerName / ServerVersion are returned in the initialize handshake.
const (
ServerName = "lux-mcp"
ServerVersion = "1.0.0"
// ProtocolVersion is the MCP protocol revision this server speaks.
ProtocolVersion = "2024-11-05"
)
// Availability/safety bounds. These cap what a single stdio line can cost so one
// client (or one wedged upstream RPC) cannot exhaust the server.
const (
// maxLineBytes bounds one JSON-RPC request line. MCP requests are tiny (a tool name
// + a few scalar args), so 1 MiB is generous; a longer line is rejected as a parse
// error WITHOUT buffering it whole (the reader stops at the cap and drains the rest).
maxLineBytes = 1 << 20 // 1 MiB
// defaultCallTimeout bounds one tools/call dispatch end-to-end (reaching every chain
// read). Long enough for a healthy RPC, short enough that one hung call cannot wedge
// the stdio loop (the next request is still served once the budget elapses).
defaultCallTimeout = 20 * time.Second
)
// Tool is one read tool: a VALUE carrying its descriptor AND its handler. Collapsing the
// old parallel name->schema + name->handler maps into a single Tool value is the core
// decomplect — a tool is a thing, not two coordinated table entries.
//
// Read runs the tool: it receives the request context (already bounded by the per-call
// timeout) and the decoded args, and returns (value, observation, error). value is any
// JSON-serializable result; observation is the verifiable ChainObservation of the exact
// reads the tool performed (may be nil for a tool that observes nothing); error is a
// tool-level failure surfaced to the caller. Read may issue read-only chain calls; it can
// never send a transaction (the only chain seam it is given is evmread.Caller, captured
// by the domain in the closure — the transport never hands it a write capability).
type Tool struct {
Name string
Description string
InputSchema map[string]interface{}
Read func(ctx context.Context, args map[string]interface{}) (interface{}, *ChainObservation, error)
}
// Surface is a domain's contribution to the transport: a set of read tools. governance/
// is one Surface; a composition test supplies another. Serve runs any number of them
// behind the one transport.
type Surface interface {
Tools() []Tool
}
// Server is the read-only MCP transport over one or more Surfaces. It owns only the tool
// index and the dispatch budgets; it has no chain dependency of its own (each tool
// captures its own read seam). It exposes NO method that can sign or submit a tx.
type Server struct {
tools map[string]Tool
descs []Tool
// callTimeout bounds one tools/call dispatch. Defaulted in newServer; overridable
// in-package (tests shrink it).
callTimeout time.Duration
}
// Serve runs the stdio JSON-RPC loop over the given surfaces until EOF or ctx
// cancellation. It is the ONE transport entry point: it builds a name->Tool index across
// ALL surfaces (erroring on a duplicate tool name) and dispatches each request to
// initialize / tools/list / tools/call.
func Serve(ctx context.Context, in io.Reader, out io.Writer, surfaces ...Surface) error {
s, err := newServer(surfaces...)
if err != nil {
return err
}
return s.Serve(ctx, in, out)
}
// Serve runs the stdio JSON-RPC loop on an already-built Server (the seam for callers
// that need to construct the server first, e.g. to observe Tools() or tune budgets). The
// package-level Serve builds a Server and calls this.
func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error {
return s.serve(ctx, in, out)
}
// NewServer builds the transport over the given surfaces without starting the stdio loop.
// It is the seam in-process callers and tests use: CallTool dispatches a single tool with
// the same guards Serve applies. Returns an error on a duplicate tool name across
// surfaces.
func NewServer(surfaces ...Surface) (*Server, error) {
return newServer(surfaces...)
}
func newServer(surfaces ...Surface) (*Server, error) {
s := &Server{
tools: map[string]Tool{},
callTimeout: defaultCallTimeout,
}
for _, sf := range surfaces {
for _, t := range sf.Tools() {
if strings.TrimSpace(t.Name) == "" {
return nil, errors.New("mcp: surface registered a tool with an empty name")
}
if t.Read == nil {
return nil, fmt.Errorf("mcp: tool %q has a nil Read handler", t.Name)
}
if _, dup := s.tools[t.Name]; dup {
return nil, fmt.Errorf("mcp: duplicate tool name %q across surfaces", t.Name)
}
s.tools[t.Name] = t
s.descs = append(s.descs, t)
}
}
return s, nil
}
// ----------------------------------------------------------------------------
// JSON-RPC 2.0 envelope (stdio).
// ----------------------------------------------------------------------------
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *rpcErr `json:"error,omitempty"`
}
type rpcErr struct {
Code int `json:"code"`
Message string `json:"message"`
}
// JSON-RPC error codes (subset of the spec used here).
const (
codeParseError = -32700
codeInvalidReq = -32600
codeMethodNotFnd = -32601
codeInvalidParams = -32602
codeInternalError = -32603
)
// toolDescriptor is the wire shape of a tool in tools/list (the MCP `inputSchema` key).
// It is projected from a Tool value so the handler closure is never serialized.
type toolDescriptor struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
// content is one MCP tool-result content block. Tool results are returned as a single
// text block carrying the JSON-encoded result value (and, when present, the observation).
type content struct {
Type string `json:"type"`
Text string `json:"text"`
}
// serve runs the stdio JSON-RPC loop: it reads newline-delimited JSON-RPC requests from
// `in` and writes responses to `out` until EOF or ctx cancellation. Notifications (no id)
// get no response, per the JSON-RPC spec.
func (s *Server) serve(ctx context.Context, in io.Reader, out io.Writer) error {
r := bufio.NewReader(in)
w := bufio.NewWriter(out)
defer w.Flush()
for {
if ctx.Err() != nil {
return ctx.Err()
}
line, tooLong, err := readLimitedLine(r, maxLineBytes)
if tooLong {
// The request line exceeded the cap; we did NOT buffer it whole (the rest of
// the line was drained). Reply with a parse error and keep serving — one
// oversized line cannot OOM the server or kill the loop.
if werr := writeJSONLine(w, errResp(nil, codeParseError, "request line exceeds maximum length")); werr != nil {
return werr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
} else if len(line) > 0 {
trimmed := strings.TrimSpace(string(line))
if trimmed != "" {
if resp := s.handle(ctx, []byte(trimmed)); resp != nil {
if werr := writeJSONLine(w, resp); werr != nil {
return werr
}
if ferr := w.Flush(); ferr != nil {
return ferr
}
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
// readLimitedLine reads one newline-terminated line but never buffers more than `max`
// bytes. If the line is longer than `max`, it returns tooLong=true with the partial
// bytes discarded (it drains the rest of the line so the NEXT read starts on a fresh
// line), so an adversarial multi-megabyte line cannot grow memory without bound. The
// returned error mirrors bufio.Reader.ReadBytes (io.EOF at stream end, possibly with a
// final unterminated line). A trailing '\n' is included in the returned slice when the
// line fit, matching the prior ReadBytes('\n') contract for the parse path.
func readLimitedLine(r *bufio.Reader, max int) (line []byte, tooLong bool, err error) {
buf := make([]byte, 0, 256)
for {
var c byte
c, err = r.ReadByte()
if err != nil {
return buf, false, err
}
if c == '\n' {
buf = append(buf, c)
return buf, false, nil
}
if len(buf) >= max {
// Over the cap: stop buffering and drain to end-of-line (or EOF) so the loop
// resynchronizes on the next line instead of mid-line.
for {
d, derr := r.ReadByte()
if derr != nil {
return nil, true, derr
}
if d == '\n' {
return nil, true, nil
}
}
}
buf = append(buf, c)
}
}
// handle parses one request line and routes it. Returns the response to write, or nil
// for notifications (requests without an id).
func (s *Server) handle(ctx context.Context, line []byte) *rpcResponse {
var req rpcRequest
if err := json.Unmarshal(line, &req); err != nil {
return errResp(nil, codeParseError, "parse error: "+err.Error())
}
// A request with no id is a notification — process side effects (none here) and
// return nothing.
isNotification := len(req.ID) == 0 || string(req.ID) == "null"
switch req.Method {
case "initialize":
if isNotification {
return nil
}
return okResp(req.ID, s.initializeResult())
case "notifications/initialized", "initialized":
return nil
case "tools/list":
if isNotification {
return nil
}
return okResp(req.ID, map[string]interface{}{"tools": s.toolDescriptors()})
case "tools/call":
if isNotification {
return nil
}
return s.handleToolsCall(ctx, req.ID, req.Params)
default:
if isNotification {
return nil
}
return errResp(req.ID, codeMethodNotFnd, "method not found: "+req.Method)
}
}
func (s *Server) initializeResult() map[string]interface{} {
return map[string]interface{}{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": ServerName,
"version": ServerVersion,
},
}
}
// toolDescriptors projects the registered tools to their wire descriptors (name +
// description + inputSchema), stripping the handler closures.
func (s *Server) toolDescriptors() []toolDescriptor {
out := make([]toolDescriptor, 0, len(s.descs))
for _, t := range s.descs {
out = append(out, toolDescriptor{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
}
return out
}
// toolCallParams is the MCP tools/call params shape.
type toolCallParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
func (s *Server) handleToolsCall(ctx context.Context, id json.RawMessage, raw json.RawMessage) *rpcResponse {
var p toolCallParams
if err := json.Unmarshal(raw, &p); err != nil {
return errResp(id, codeInvalidParams, "invalid params: "+err.Error())
}
if _, ok := s.tools[p.Name]; !ok {
return errResp(id, codeMethodNotFnd, "unknown tool: "+p.Name)
}
result, obs, err := s.dispatch(ctx, p.Name, p.Arguments)
if err != nil {
// Tool errors (incl. timeout and recovered panics) are returned as a successful
// MCP response with isError=true so the client model sees the failure text (per
// MCP tool-result convention), not as a JSON-RPC protocol error, and the stdio
// loop keeps serving the next request.
return okResp(id, map[string]interface{}{
"content": []content{{Type: "text", Text: err.Error()}},
"isError": true,
})
}
// Embed the value AND (when present) the verifiable observation in the tool-result
// content, so a caller can read the value and independently re-derive/verify the
// observation hash against the chain state it claims.
payload := map[string]interface{}{"value": result}
if obs != nil {
payload["observation"] = observationView(obs)
}
encoded, merr := json.Marshal(payload)
if merr != nil {
return errResp(id, codeInternalError, "encode result: "+merr.Error())
}
return okResp(id, map[string]interface{}{
"content": []content{{Type: "text", Text: string(encoded)}},
})
}
// observationView is the JSON shape of an observation embedded in a tool result: the
// block context, the tool name, the sorted/deduped reads, and the binding hash. A caller
// re-derives the hash from these reads via NewObservation/Hash to check the binding.
func observationView(o *ChainObservation) map[string]interface{} {
return map[string]interface{}{
"chainId": bigString(o.ChainID),
"blockNumber": o.BlockNumber,
"blockHash": o.BlockHash.Hex(),
"timestamp": o.Timestamp,
"tool": o.Tool,
"reads": o.Reads,
"hash": o.Hash().Hex(),
}
}
// dispatch runs ONE tool with the transport guards: a per-call timeout (so a hung
// upstream RPC cannot wedge the server) and a panic recover (so a single bad call becomes
// one error, never a whole-server crash). The handler reads only — these guards add no
// write capability. Domain-specific limits (e.g. a per-request eth_call ceiling) live in
// the domain's Read closure, not here, so the transport stays chain-agnostic. Used by
// both the stdio path and CallTool, so the invariants hold no matter how a tool is
// invoked.
func (s *Server) dispatch(parent context.Context, name string, args map[string]interface{}) (result interface{}, obs *ChainObservation, err error) {
t, ok := s.tools[name]
if !ok {
return nil, nil, fmt.Errorf("mcp: unknown tool %q", name)
}
if args == nil {
args = map[string]interface{}{}
}
ctx := parent
var cancel context.CancelFunc
if s.callTimeout > 0 {
ctx, cancel = context.WithTimeout(parent, s.callTimeout)
defer cancel()
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("mcp: tool %q panicked: %v", name, r)
result = nil
obs = nil
}
}()
return t.Read(ctx, args)
}
// CallTool runs a read tool directly (bypassing the stdio envelope) and returns the
// decoded result VALUE. Exposed for in-process callers and tests; it is read-only like
// every tool. The observation is dropped here (in-process callers that want it can build
// it from the same reads); CallToolObserved returns both. Unknown tool names error.
func (s *Server) CallTool(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
v, _, err := s.dispatch(ctx, name, args)
return v, err
}
// CallToolObserved runs a read tool and returns BOTH its value and the observation it
// produced (nil when the tool observes nothing). Exposed for callers that bind the
// observation into a verdict.
func (s *Server) CallToolObserved(ctx context.Context, name string, args map[string]interface{}) (interface{}, *ChainObservation, error) {
return s.dispatch(ctx, name, args)
}
// Tools returns the descriptors for the registered read tools (the tools/list surface).
func (s *Server) Tools() []Tool { return s.descs }
func okResp(id json.RawMessage, result interface{}) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: result}
}
func errResp(id json.RawMessage, code int, msg string) *rpcResponse {
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcErr{Code: code, Message: msg}}
}
func writeJSONLine(w io.Writer, v interface{}) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.Write(b)
return err
}
+247
View File
@@ -0,0 +1,247 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mcp
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
)
// fakeSurface is a self-contained Surface for the transport tests: it carries an explicit
// tool list so the transport can be exercised WITHOUT any chain dependency (the transport
// is domain-agnostic — that is the whole point of the decomplect).
type fakeSurface struct{ tools []Tool }
func (f fakeSurface) Tools() []Tool { return f.tools }
// okTool returns a fixed value and no observation — the minimal well-behaved tool.
func okTool(name string) Tool {
return Tool{
Name: name,
Description: name,
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Read: func(_ context.Context, _ map[string]interface{}) (interface{}, *ChainObservation, error) {
return map[string]interface{}{"ok": true, "tool": name}, nil, nil
},
}
}
// blockingTool blocks until the dispatch context is cancelled (modeling a hung upstream
// RPC), then returns the context error — so the per-call timeout guard surfaces.
func blockingTool(name string) Tool {
return Tool{
Name: name,
Description: name,
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Read: func(ctx context.Context, _ map[string]interface{}) (interface{}, *ChainObservation, error) {
<-ctx.Done()
return nil, nil, ctx.Err()
},
}
}
// panicTool panics — the dispatch recover must turn it into one error, not a crash.
func panicTool(name string) Tool {
return Tool{
Name: name,
Description: name,
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Read: func(_ context.Context, _ map[string]interface{}) (interface{}, *ChainObservation, error) {
panic("kaboom")
},
}
}
// TestPerCallTimeoutDoesNotWedgeServer is the HIGH-3 test: a hung tool must not wedge the
// stdio loop. With a tiny call budget, a tools/call that blocks returns a timeout (isError)
// AND the server still answers the NEXT request on the same stream.
func TestPerCallTimeoutDoesNotWedgeServer(t *testing.T) {
srv, err := NewServer(fakeSurface{tools: []Tool{blockingTool("blocks"), okTool("quick")}})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
srv.callTimeout = 100 * time.Millisecond // shrink so the test is fast (white-box)
in := strings.Join([]string{
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blocks","arguments":{}}}`,
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"quick","arguments":{}}}`,
}, "\n") + "\n"
start := time.Now()
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(in), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
elapsed := time.Since(start)
resps := decodeLines(t, out.String())
if len(resps) != 2 {
t.Fatalf("want 2 responses, got %d:\n%s", len(resps), out.String())
}
// 1) The blocked call returns a tool error (isError=true) mentioning a deadline.
r1 := resps[0]["result"].(map[string]interface{})
if r1["isError"] != true {
t.Fatalf("blocked call should be isError, got: %v", resps[0])
}
txt := r1["content"].([]interface{})[0].(map[string]interface{})["text"].(string)
if !strings.Contains(strings.ToLower(txt), "deadline") && !strings.Contains(strings.ToLower(txt), "context") {
t.Fatalf("blocked call error %q does not look like a timeout", txt)
}
// 2) The SECOND request was answered — the server is still responsive.
r2 := resps[1]["result"].(map[string]interface{})
if _, ok := r2["content"]; !ok {
t.Fatalf("second request not answered (server wedged?): %v", resps[1])
}
// Sanity: total time is bounded by the budget, not infinite.
if elapsed > 5*time.Second {
t.Fatalf("Serve took %v — the timeout did not bound the hung call", elapsed)
}
t.Logf("hung call bounded by per-call timeout; server stayed responsive (elapsed %v)", elapsed)
}
// TestDispatchRecoversPanic is the LOW test: a handler panic becomes one isError tool
// result, not a whole-server crash.
func TestDispatchRecoversPanic(t *testing.T) {
srv, err := NewServer(fakeSurface{tools: []Tool{panicTool("boom"), okTool("fine")}})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
_, err = srv.CallTool(context.Background(), "boom", nil)
if err == nil {
t.Fatal("expected a recovered-panic error, got nil")
}
if !strings.Contains(err.Error(), "panicked") {
t.Fatalf("error %q is not the recovered-panic error", err.Error())
}
// And the server is still usable afterwards.
if _, err := srv.CallTool(context.Background(), "fine", nil); err != nil {
t.Fatalf("server unusable after recovered panic: %v", err)
}
}
// TestOversizedLineIsRejectedAndLoopSurvives is the HIGH-2 test: a request line larger than
// maxLineBytes must be rejected as a parse error WITHOUT buffering it whole, and the stdio
// loop must survive to answer the next (well-formed) request.
func TestOversizedLineIsRejectedAndLoopSurvives(t *testing.T) {
srv, err := NewServer(fakeSurface{tools: []Tool{okTool("quick")}})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
// One oversized line (a JSON string padded past the cap) then a valid call.
huge := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"quick","arguments":{"pad":"` +
strings.Repeat("A", maxLineBytes+1024) + `"}}}`
valid := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"quick","arguments":{}}}`
in := huge + "\n" + valid + "\n"
var out strings.Builder
if err := srv.Serve(context.Background(), strings.NewReader(in), &out); err != nil {
t.Fatalf("Serve: %v", err)
}
resps := decodeLines(t, out.String())
if len(resps) != 2 {
t.Fatalf("want 2 responses (oversized parse error + valid result), got %d", len(resps))
}
// 1) Oversized line -> a JSON-RPC parse error (id null since we never parsed it).
if resps[0]["error"] == nil {
t.Fatalf("oversized line should yield a parse error, got: %v", resps[0])
}
em := resps[0]["error"].(map[string]interface{})
if int(em["code"].(float64)) != codeParseError {
t.Fatalf("oversized line error code=%v, want %d", em["code"], codeParseError)
}
// 2) The following valid request was still served.
if _, ok := resps[1]["result"]; !ok {
t.Fatalf("loop did not survive the oversized line: %v", resps[1])
}
t.Logf("oversized line rejected as parse error; loop survived and served the next request")
}
// TestReadLimitedLineDrainsAndResyncs unit-tests the bounded reader directly: an oversized
// line is reported tooLong with no buffered bytes, and the NEXT ReadByte starts on the
// following line (the reader drained to the newline).
func TestReadLimitedLineDrainsAndResyncs(t *testing.T) {
const max = 16
data := strings.Repeat("X", max*4) + "\n" + "ok\n"
r := bufio.NewReader(strings.NewReader(data))
line, tooLong, err := readLimitedLine(r, max)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !tooLong {
t.Fatal("expected tooLong=true for the long line")
}
if len(line) != 0 {
t.Fatalf("oversized line should return no buffered bytes, got %d", len(line))
}
// Next read returns the SECOND line intact.
line2, tooLong2, err := readLimitedLine(r, max)
if err != nil {
t.Fatalf("unexpected err on line 2: %v", err)
}
if tooLong2 {
t.Fatal("second line is short; tooLong must be false")
}
if strings.TrimSpace(string(line2)) != "ok" {
t.Fatalf("after draining, next line=%q, want %q", strings.TrimSpace(string(line2)), "ok")
}
}
// TestDuplicateToolNameRejected proves the transport refuses two surfaces (or one surface)
// that declare the same tool name — the index must be unambiguous.
func TestDuplicateToolNameRejected(t *testing.T) {
a := fakeSurface{tools: []Tool{okTool("dup")}}
b := fakeSurface{tools: []Tool{okTool("dup")}}
if _, err := NewServer(a, b); err == nil {
t.Fatal("expected a duplicate-tool-name error across surfaces, got nil")
}
}
func TestEmptyToolNameRejected(t *testing.T) {
for _, name := range []string{"", " ", "\t"} {
if _, err := NewServer(fakeSurface{tools: []Tool{okTool(name)}}); err == nil {
t.Fatalf("expected an empty-tool-name error for %q, got nil", name)
}
}
}
func TestNilReadHandlerRejected(t *testing.T) {
bad := Tool{Name: "noread", Description: "noread", InputSchema: map[string]interface{}{"type": "object"}}
if _, err := NewServer(fakeSurface{tools: []Tool{bad}}); err == nil {
t.Fatal("expected a nil-Read-handler error, got nil")
}
}
// 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 tl := strings.TrimSpace(line); tl != "" {
var m map[string]interface{}
if jerr := json.Unmarshal([]byte(tl), &m); jerr != nil {
t.Fatalf("bad response line: %s", tl)
}
out = append(out, m)
}
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read line: %v", err)
}
}
return out
}