mirror of
https://github.com/luxfi/mcp.git
synced 2026-07-26 23:59:51 +00:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user