From 16d14ae243139f80130a2b8c4a570993dbd62283 Mon Sep 17 00:00:00 2001 From: Anil Kumar <150643132+anil-rome@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:03:26 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20contract=20layer=20?= =?UTF-8?q?=E2=80=94=20rome=20call=20/=20deploy=20/=20send=20(actions=20mo?= =?UTF-8?q?del)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the read-only core to support ACTIONS: capabilities gain kind (read|action) + single-verb dispatch. Actions are CLI-only, key-gated (ROME_EVM_KEY from env only, never a flag/logged/through MCP), and NEVER registered as MCP tools — a tested security invariant. New commands: - rome call [args] read via eth_call (read; CLI+MCP) - rome deploy [args] deploy w/ Rome gas quirks (action) - rome send [args] write via submitRomeTx (action) Funded works-gate GREEN on Hadrian: deploy Store → send set(42) → call value()==42 (deploy 0x3b04c637…, txs on-chain). 39/39 tests, MCP smoke confirms actions excluded. --- .gitignore | 1 + src/cli.ts | 21 +++---- src/core/actions.ts | 121 +++++++++++++++++++++++++++++++++++++++ src/core/capabilities.ts | 83 +++++++++++++++++++++++++-- src/core/eip1193.ts | 38 ++++++++++++ src/core/keys.ts | 21 +++++++ src/mcp.ts | 7 ++- test/actions.test.ts | 93 ++++++++++++++++++++++++++++++ test/alignment.test.ts | 19 +++--- test/cli.test.ts | 11 ++++ 10 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 src/core/actions.ts create mode 100644 src/core/eip1193.ts create mode 100644 src/core/keys.ts create mode 100644 test/actions.test.ts diff --git a/.gitignore b/.gitignore index 0a93c16..b89653d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .DS_Store .env .env.* +.secrets/ diff --git a/src/cli.ts b/src/cli.ts index f8adbbf..1ee7170 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,25 +1,26 @@ -import { CAPABILITIES, findCapability } from "./core/capabilities.js"; +import { CAPABILITIES, resolveCli } from "./core/capabilities.js"; import { defaultDeps } from "./core/deps.js"; const VERSION = "0.1.0"; -/** Every `group command` the CLI dispatches — derived from CAPABILITIES (aligns with the MCP tools). */ +/** Every CLI invocation path the CLI dispatches ("facts chain", "deploy", …). */ export function cliCommandTable(): string[] { - return CAPABILITIES.map((c) => `${c.group} ${c.command}`); + return CAPABILITIES.map((c) => c.cliPath); } function helpText(): string { const lines = [ "rome — Rome Protocol dev CLI + MCP server", "", - "Usage: rome [args]", - " rome mcp start the MCP server (stdio, read-only)", + "Usage: rome [args] (e.g. rome facts chain hadrian · rome deploy …)", + " rome mcp start the MCP server (stdio, read-only)", "", "Commands:", ]; for (const c of CAPABILITIES) { const a = c.args.map((x) => (x.required ? `<${x.name}>` : `[${x.name}]`)).join(" "); - lines.push(` rome ${c.group} ${c.command}${a ? " " + a : ""}\n ${c.summary}`); + const tag = c.kind === "action" ? " [needs ROME_EVM_KEY]" : ""; + lines.push(` rome ${c.cliPath}${a ? " " + a : ""}${tag}\n ${c.summary}`); } return lines.join("\n"); } @@ -44,13 +45,13 @@ export async function main(argv: string[]): Promise { return; } - const [group, command, ...rest] = args; - const cap = findCapability(group, command); - if (!cap) { - console.error(`Unknown command: rome ${[group, command].filter(Boolean).join(" ")}`); + const resolved = resolveCli(args); + if (!resolved) { + console.error(`Unknown command: rome ${args.slice(0, 2).join(" ")}`); console.error("\n" + helpText()); return 1; } + const { cap, rest } = resolved; const argObj: Record = {}; cap.args.forEach((spec, i) => { diff --git a/src/core/actions.ts b/src/core/actions.ts new file mode 100644 index 0000000..f60f8c5 --- /dev/null +++ b/src/core/actions.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import { + createPublicClient, + createWalletClient, + http, + encodeFunctionData, + decodeFunctionResult, + parseAbiItem, + type AbiFunction, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { submitRomeTx } from "@rome-protocol/sdk"; +import { getChainFacts } from "./facts.js"; +import { requireEvmKey } from "./keys.js"; +import { eip1193FromAccount } from "./eip1193.js"; +import { defaultDeps, type Deps } from "./deps.js"; + +/** JSON can't serialize BigInt — stringify them (viem returns BigInt for uint outputs). */ +function jsonSafe(v: unknown): unknown { + if (typeof v === "bigint") return v.toString(); + if (Array.isArray(v)) return v.map(jsonSafe); + if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, jsonSafe(x)])); + return v; +} + +/** Parse a human function signature ("balanceOf(address) view returns (uint256)") to an AbiFunction. */ +export function parseFn(signature: string): AbiFunction { + const src = signature.trim().startsWith("function") ? signature.trim() : `function ${signature.trim()}`; + const item = parseAbiItem(src) as AbiFunction; + if (item.type !== "function") throw new Error(`Not a function signature: "${signature}"`); + return item; +} + +/** Coerce comma-separated string args to viem-typed values per the ABI input types. */ +export function coerceArgs(inputs: readonly { type: string }[], raw?: string): unknown[] { + if (!raw) return []; + const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0); + return parts.map((v, i) => { + const t = inputs[i]?.type ?? ""; + if (/^u?int\d*$/.test(t)) return BigInt(v); + if (t === "bool") return v === "true"; + return v; // address / bytes / string pass through + }); +} + +function loadArtifact(path: string): { abi: unknown[]; bytecode: `0x${string}` } { + const j = JSON.parse(readFileSync(path, "utf8")) as Record; + const abi = j.abi as unknown[] | undefined; + let bc: unknown = j.bytecode ?? (j.evm as Record | undefined)?.bytecode; + if (bc && typeof bc === "object") bc = (bc as Record).object; // Foundry/solc {bytecode:{object}} + if (!abi || typeof bc !== "string" || bc.length < 2) { + throw new Error(`Artifact must contain { abi, bytecode }: ${path}`); + } + const bytecode = (bc.startsWith("0x") ? bc : `0x${bc}`) as `0x${string}`; + return { abi, bytecode }; +} + +/** Read a contract via eth_call. No key. */ +export async function callContract( + chain: string | number, + address: string, + signature: string, + argsCsv: string | undefined, + _deps: Deps = defaultDeps, +) { + const c = getChainFacts(chain); + const fn = parseFn(signature); + const data = encodeFunctionData({ abi: [fn], functionName: fn.name, args: coerceArgs(fn.inputs, argsCsv) }); + const pub = createPublicClient({ transport: http(c.rpcUrl) }); + const { data: ret } = await pub.call({ to: address as `0x${string}`, data }); + const decoded = + fn.outputs.length && ret && ret !== "0x" ? decodeFunctionResult({ abi: [fn], functionName: fn.name, data: ret }) : ret ?? "0x"; + return { chainId: c.chainId, address, function: fn.name, result: jsonSafe(decoded) }; +} + +/** Deploy a compiled contract (artifact = {abi, bytecode}) with Rome's gas quirks. Needs a key. */ +export async function deployContract( + chain: string | number, + artifactPath: string, + argsCsv: string | undefined, + _deps: Deps = defaultDeps, +) { + const c = getChainFacts(chain); + const key = requireEvmKey(); + const { abi, bytecode } = loadArtifact(artifactPath); + const account = privateKeyToAccount(key); + const wallet = createWalletClient({ account, transport: http(c.rpcUrl) }); + const pub = createPublicClient({ transport: http(c.rpcUrl) }); + const gp = await pub.getGasPrice(); + const ctor = (abi as AbiFunction[]).find((x) => (x as { type?: string }).type === "constructor"); + // High fixed gas ceiling: Rome charges exact, so an over-provisioned limit is safe (create-rome-app pattern). + const hash = await wallet.deployContract({ + abi: abi as never, + bytecode, + args: coerceArgs(ctor?.inputs ?? [], argsCsv) as never, + chain: null, + gas: 26_000_000n, + maxFeePerGas: (gp * 3n) / 2n, + maxPriorityFeePerGas: 0n, + }); + const rcpt = await pub.waitForTransactionReceipt({ hash }); + return { chainId: c.chainId, deployer: account.address, txHash: hash, address: rcpt.contractAddress, status: rcpt.status }; +} + +/** Write to a contract via submitRomeTx (the correct Rome write path). Needs a key. */ +export async function sendContract( + chain: string | number, + address: string, + signature: string, + argsCsv: string | undefined, + _deps: Deps = defaultDeps, +) { + const c = getChainFacts(chain); + const key = requireEvmKey(); + const fn = parseFn(signature); + const data = encodeFunctionData({ abi: [fn], functionName: fn.name, args: coerceArgs(fn.inputs, argsCsv) }); + const account = privateKeyToAccount(key); + const provider = eip1193FromAccount(account, c.rpcUrl, c.chainId); + const hash = await submitRomeTx(provider, { from: account.address, to: address, data }); + return { chainId: c.chainId, from: account.address, to: address, function: fn.name, txHash: hash }; +} diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index f144fa6..14c7496 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -1,5 +1,6 @@ import { getChainFacts, getTokenFacts, getContractFacts, getGasFacts, getBalanceFacts, getProgramFacts } from "./facts.js"; import { getCpiRecipe, getPatterns } from "./cookbook.js"; +import { callContract, deployContract, sendContract } from "./actions.js"; import { type Deps } from "./deps.js"; export interface ArgSpec { @@ -8,29 +9,55 @@ export interface ArgSpec { description: string; } +/** + * "read" capabilities are pure lookups — exposed on BOTH the CLI and the MCP server. + * "action" capabilities write on-chain — they need a signing key and are CLI-ONLY; + * they are never registered as MCP tools, so a key can never reach the MCP surface. + */ +export type CapabilityKind = "read" | "action"; + export interface Capability { id: string; group: string; command: string; + /** true = invoked as a single verb (`rome deploy`); false = grouped (`rome facts chain`). */ + verb: boolean; + /** how the CLI invokes it: "facts chain" (grouped) or "deploy" (verb). */ + cliPath: string; mcpTool: string; summary: string; + kind: CapabilityKind; + requiresKey: boolean; args: ArgSpec[]; handler: (args: Record, deps?: Deps) => Promise | unknown; } -function cap( +function mkCap( + kind: CapabilityKind, + verb: boolean, group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"], ): Capability { - // MCP tool names use underscores only (some clients reject hyphens); the CLI keeps the - // ergonomic hyphen. Derivation stays deterministic → alignment holds (see alignment.test.ts). - const mcpTool = `${group}_${command}`.replace(/-/g, "_"); - return { id: `${group}.${command}`, group, command, mcpTool, summary, args, handler }; + // id stays group.command (stable). CLI + MCP names use single-verb form for verbs. + // MCP tool names use underscores only (some clients reject hyphens); derivation is + // deterministic → alignment holds (see alignment.test.ts). + const cliPath = verb ? command : `${group} ${command}`; + const mcpTool = (verb ? command : `${group}_${command}`).replace(/-/g, "_"); + return { id: `${group}.${command}`, group, command, verb, cliPath, mcpTool, summary, kind, requiresKey: kind === "action", args, handler }; } +// grouped read (CLI+MCP) / grouped action (CLI-only, key). +const cap = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) => + mkCap("read", false, group, command, summary, args, handler); +// single-verb read / action. +const verbCap = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) => + mkCap("read", true, group, command, summary, args, handler); +const verbAction = (group: string, command: string, summary: string, args: ArgSpec[], handler: Capability["handler"]) => + mkCap("action", true, group, command, summary, args, handler); + const chainArg: ArgSpec = { name: "chain", required: true, description: "chain id, name, or slug (e.g. 200010 or hadrian)" }; /** @@ -77,8 +104,54 @@ export const CAPABILITIES: Capability[] = [ [{ name: "goal", required: false, description: "optional goal keyword (e.g. lending, amm, oracle)" }], (a) => getPatterns(a.goal), ), + + // ── contract verbs: `call` is a read (CLI+MCP); `deploy`/`send` are CLI-only actions (key) ── + verbCap( + "contract", + "call", + "Read a contract via eth_call (no key). e.g. rome call hadrian 0x… \"balanceOf(address) returns (uint256)\" 0x…", + [ + chainArg, + { name: "address", required: true, description: "contract address (0x…)" }, + { name: "signature", required: true, description: 'function signature, e.g. "balanceOf(address) returns (uint256)"' }, + { name: "args", required: false, description: "comma-separated call args" }, + ], + (a, deps) => callContract(a.chain, a.address, a.signature, a.args, deps), + ), + verbAction( + "contract", + "deploy", + "Deploy a compiled contract (abi+bytecode artifact) to a Rome chain, handling Rome's gas quirks. Needs ROME_EVM_KEY.", + [ + chainArg, + { name: "artifact", required: true, description: "path to a compiled artifact JSON (abi + bytecode; Foundry/Hardhat/solc)" }, + { name: "args", required: false, description: "comma-separated constructor args" }, + ], + (a, deps) => deployContract(a.chain, a.artifact, a.args, deps), + ), + verbAction( + "contract", + "send", + "Write to a contract via submitRomeTx (the correct Rome write path). Needs ROME_EVM_KEY.", + [ + chainArg, + { name: "address", required: true, description: "contract address (0x…)" }, + { name: "signature", required: true, description: 'function signature, e.g. "deposit(uint256)"' }, + { name: "args", required: false, description: "comma-separated call args" }, + ], + (a, deps) => sendContract(a.chain, a.address, a.signature, a.args, deps), + ), ]; export function findCapability(group: string, command: string): Capability | undefined { return CAPABILITIES.find((c) => c.group === group && c.command === command); } + +/** Resolve CLI argv to a capability + its remaining positional args. Handles grouped + verb forms. */ +export function resolveCli(args: string[]): { cap: Capability; rest: string[] } | undefined { + const grouped = CAPABILITIES.find((c) => !c.verb && c.group === args[0] && c.command === args[1]); + if (grouped) return { cap: grouped, rest: args.slice(2) }; + const verb = CAPABILITIES.find((c) => c.verb && c.command === args[0]); + if (verb) return { cap: verb, rest: args.slice(1) }; + return undefined; +} diff --git a/src/core/eip1193.ts b/src/core/eip1193.ts new file mode 100644 index 0000000..0c39f36 --- /dev/null +++ b/src/core/eip1193.ts @@ -0,0 +1,38 @@ +import { createPublicClient, createWalletClient, http, type Account } from "viem"; + +/** + * A minimal EIP-1193 provider backed by a viem account, so the SDK's `submitRomeTx` + * — which expects an injected wallet like `window.ethereum` — can run in Node with a + * key from the environment. Answers `eth_accounts`/`eth_chainId` locally, signs + + * broadcasts `eth_sendTransaction`, and forwards every read to the RPC. + * (Standard pattern, mirrors create-rome-app's template shim.) + */ +export function eip1193FromAccount(account: Account, rpcUrl: string, chainId: number) { + const pub = createPublicClient({ transport: http(rpcUrl) }); + const wallet = createWalletClient({ account, transport: http(rpcUrl) }); + const big = (v: unknown) => (v === undefined || v === null ? undefined : BigInt(v as string)); + return { + request: async ({ method, params }: { method: string; params?: unknown[] }) => { + switch (method) { + case "eth_accounts": + case "eth_requestAccounts": + return [account.address]; + case "eth_chainId": + return `0x${chainId.toString(16)}`; + case "eth_sendTransaction": { + const t = ((params ?? [])[0] ?? {}) as Record; + return wallet.sendTransaction({ + to: t.to as `0x${string}` | undefined, + data: t.data as `0x${string}` | undefined, + value: big(t.value), + gas: big(t.gas), + maxFeePerGas: big(t.maxFeePerGas), + maxPriorityFeePerGas: big(t.maxPriorityFeePerGas), + } as Parameters[0]); + } + default: + return pub.request({ method: method as never, params: params as never }); + } + }, + }; +} diff --git a/src/core/keys.ts b/src/core/keys.ts new file mode 100644 index 0000000..49016ca --- /dev/null +++ b/src/core/keys.ts @@ -0,0 +1,21 @@ +export const EVM_KEY_ENV = "ROME_EVM_KEY"; + +/** + * The signing key for actions, read from the environment ONLY — never a flag, never + * logged, never passed through the MCP server. Throws a clear, actionable error if absent + * or malformed. The returned key is used to build a viem account and is never printed. + */ +export function requireEvmKey(): `0x${string}` { + const raw = process.env[EVM_KEY_ENV]; + if (!raw) { + throw new Error( + `No signing key. Set ${EVM_KEY_ENV} in your environment (a 0x-prefixed EVM private key). ` + + `It is read from the environment only — never a flag, never logged, never sent through MCP.`, + ); + } + const key = (raw.startsWith("0x") ? raw : `0x${raw}`) as `0x${string}`; + if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { + throw new Error(`${EVM_KEY_ENV} is not a valid 32-byte hex private key.`); + } + return key; +} diff --git a/src/mcp.ts b/src/mcp.ts index 3b49a78..d9a4e77 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -10,9 +10,12 @@ export interface McpToolDef { capability: Capability; } -/** The MCP tool list, derived from the same CAPABILITIES the CLI uses. */ +/** + * The MCP tool list — ONLY read-only capabilities. Actions (deploy/send/…) need a signing + * key and are CLI-only; they are never exposed as MCP tools, so a key can never reach MCP. + */ export function buildMcpTools(): McpToolDef[] { - return CAPABILITIES.map((c) => ({ name: c.mcpTool, description: c.summary, capability: c })); + return CAPABILITIES.filter((c) => c.kind === "read").map((c) => ({ name: c.mcpTool, description: c.summary, capability: c })); } function inputShape(cap: Capability): ZodRawShape { diff --git a/test/actions.test.ts b/test/actions.test.ts new file mode 100644 index 0000000..0340d40 --- /dev/null +++ b/test/actions.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { CAPABILITIES } from "../src/core/capabilities.js"; +import { buildMcpTools } from "../src/mcp.js"; +import { parseFn, coerceArgs, sendContract } from "../src/core/actions.js"; +import { requireEvmKey } from "../src/core/keys.js"; + +// Phase 2 introduces ACTIONS (deploy/send/…): CLI-only, key-gated, NEVER on the MCP surface. +// Read-only capabilities stay on both surfaces. This is the load-bearing security boundary. + +describe("capability kinds", () => { + it("every capability declares a kind of 'read' or 'action'", () => { + for (const c of CAPABILITIES) { + expect(c.kind === "read" || c.kind === "action").toBe(true); + } + }); + + it("facts + cookbook stay read-only", () => { + for (const c of CAPABILITIES.filter((c) => c.group === "facts" || c.group === "cookbook")) { + expect(c.kind).toBe("read"); + } + }); + + it("there is at least one action, and actions require a key", () => { + const actions = CAPABILITIES.filter((c) => c.kind === "action"); + expect(actions.length).toBeGreaterThan(0); + for (const a of actions) expect(a.requiresKey).toBe(true); + }); +}); + +describe("MCP surface excludes actions (no keys ever reach MCP)", () => { + it("the MCP tool list contains ONLY read capabilities", () => { + const toolCaps = buildMcpTools().map((t) => t.capability); + expect(toolCaps.every((c) => c.kind === "read")).toBe(true); + }); + + it("no action capability is exposed as an MCP tool", () => { + const toolNames = new Set(buildMcpTools().map((t) => t.name)); + for (const a of CAPABILITIES.filter((c) => c.kind === "action")) { + expect(toolNames.has(a.mcpTool)).toBe(false); + } + }); + + it("MCP tool count === read-capability count", () => { + const reads = CAPABILITIES.filter((c) => c.kind === "read").length; + expect(buildMcpTools().length).toBe(reads); + }); +}); + +describe("Phase 2 commands are present as actions", () => { + it("deploy / send / call exist", () => { + const byId = new Map(CAPABILITIES.map((c) => [c.id, c])); + expect(byId.get("contract.deploy")?.kind).toBe("action"); + expect(byId.get("contract.send")?.kind).toBe("action"); + // `call` is a read (eth_call) — CLI + MCP: + expect(byId.get("contract.call")?.kind).toBe("read"); + }); +}); + +describe("action helpers", () => { + it("parseFn accepts bare + full signatures", () => { + expect(parseFn("balanceOf(address)").name).toBe("balanceOf"); + expect(parseFn("function deposit(uint256) returns (bool)").name).toBe("deposit"); + expect(() => parseFn("not a sig!!")).toThrow(); + }); + it("coerceArgs types uint→bigint, bool→boolean, address→string", () => { + const out = coerceArgs([{ type: "uint256" }, { type: "bool" }, { type: "address" }], "42, true, 0xabc"); + expect(out).toEqual([42n, true, "0xabc"]); + expect(coerceArgs([], undefined)).toEqual([]); + }); +}); + +describe("action key-gating (no key → clear error, nothing leaks)", () => { + it("requireEvmKey throws a clear error when unset", () => { + const prev = process.env.ROME_EVM_KEY; + delete process.env.ROME_EVM_KEY; + try { + expect(() => requireEvmKey()).toThrow(/ROME_EVM_KEY/); + } finally { + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + } + }); + it("an action (send) refuses without a key rather than doing anything", async () => { + const prev = process.env.ROME_EVM_KEY; + delete process.env.ROME_EVM_KEY; + try { + await expect(sendContract("hadrian", "0x0000000000000000000000000000000000000001", "deposit(uint256)", "1")).rejects.toThrow( + /ROME_EVM_KEY/, + ); + } finally { + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + } + }); +}); diff --git a/test/alignment.test.ts b/test/alignment.test.ts index ce1e48f..22d584b 100644 --- a/test/alignment.test.ts +++ b/test/alignment.test.ts @@ -15,9 +15,12 @@ describe("capability registry integrity", () => { expect(new Set(tools).size).toBe(CAPABILITIES.length); expect(new Set(pairs).size).toBe(CAPABILITIES.length); }); - it("mcp tool name is derived from group+command (aligned naming, underscore-normalized)", () => { + it("mcp tool + cliPath are deterministically derived (grouped vs verb), underscore-normalized", () => { for (const c of CAPABILITIES) { - expect(c.mcpTool).toBe(`${c.group}_${c.command}`.replace(/-/g, "_")); + const expectedTool = (c.verb ? c.command : `${c.group}_${c.command}`).replace(/-/g, "_"); + const expectedCli = c.verb ? c.command : `${c.group} ${c.command}`; + expect(c.mcpTool).toBe(expectedTool); + expect(c.cliPath).toBe(expectedCli); expect(c.mcpTool).toMatch(/^[a-z0-9_]+$/); expect(typeof c.handler).toBe("function"); } @@ -25,16 +28,18 @@ describe("capability registry integrity", () => { }); describe("both surfaces expose exactly the capability set", () => { - it("CLI command table === capabilities", () => { + it("CLI command table === every capability's cliPath", () => { const cli = cliCommandTable().sort(); - const caps = CAPABILITIES.map((c) => `${c.group} ${c.command}`).sort(); + const caps = CAPABILITIES.map((c) => c.cliPath).sort(); expect(cli).toEqual(caps); }); - it("MCP tool list === capabilities", () => { + it("MCP tool list === the READ-ONLY capabilities (actions excluded)", () => { const tools = buildMcpTools() .map((t) => t.name) .sort(); - const caps = CAPABILITIES.map((c) => c.mcpTool).sort(); - expect(tools).toEqual(caps); + const readCaps = CAPABILITIES.filter((c) => c.kind === "read") + .map((c) => c.mcpTool) + .sort(); + expect(tools).toEqual(readCaps); }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 63e77e4..a6ca0f1 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -32,6 +32,17 @@ describe("CLI dispatch (main)", () => { expect(c.out.join("\n")).toMatch(/rome-dex/); }); + it("dispatches a single verb command (rome send, no key → exit 1 with a clear error)", async () => { + const prev = process.env.ROME_EVM_KEY; + delete process.env.ROME_EVM_KEY; + const c = capture(); + const code = await main(["node", "rome", "send", "hadrian", "0x0000000000000000000000000000000000000001", "deposit(uint256)", "1"]); + c.restore(); + if (prev !== undefined) process.env.ROME_EVM_KEY = prev; + expect(code).toBe(1); + expect(c.err.join("\n")).toMatch(/ROME_EVM_KEY/); + }); + it("errors (exit 1) on a missing required arg", async () => { const c = capture(); const code = await main(["node", "rome", "facts", "chain"]);