Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.DS_Store
.env
.env.*
.secrets/
21 changes: 11 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <group> <command> [args]",
" rome mcp start the MCP server (stdio, read-only)",
"Usage: rome <command> [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");
}
Expand All @@ -44,13 +45,13 @@ export async function main(argv: string[]): Promise<number | void> {
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<string, string> = {};
cap.args.forEach((spec, i) => {
Expand Down
121 changes: 121 additions & 0 deletions src/core/actions.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const abi = j.abi as unknown[] | undefined;
let bc: unknown = j.bytecode ?? (j.evm as Record<string, unknown> | undefined)?.bytecode;
if (bc && typeof bc === "object") bc = (bc as Record<string, unknown>).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 };
}
83 changes: 78 additions & 5 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string, string>, deps?: Deps) => Promise<unknown> | 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)" };

/**
Expand Down Expand Up @@ -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;
}
38 changes: 38 additions & 0 deletions src/core/eip1193.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<typeof wallet.sendTransaction>[0]);
}
default:
return pub.request({ method: method as never, params: params as never });
}
},
};
}
21 changes: 21 additions & 0 deletions src/core/keys.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 5 additions & 2 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading