diff --git a/AGENTS.md b/AGENTS.md index f260382..2105260 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ pnpm build # obuild via build.config.ts pnpm lint # oxlint + oxfmt check pnpm lint:fix # oxlint + oxfmt fixes pnpm playground # run any TS file via tsx -pnpm test:mcp # build and exercise all 15 MCP tools over stdio +pnpm test:mcp # build and exercise all 16 MCP tools over stdio ``` ## NOTES diff --git a/README.md b/README.md index eb91487..323b75c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Typed key generation, address derivation, and message signing across ten blockch - 🛤️ **BIP44 paths** - derivation path utilities for all supported chains - 🧩 **BIP39 puzzles** - validate phrases, narrow one missing word, and map words or indices across all 10 official lists - 🔌 **Lazy loading** - blockchain implementations load on demand for smaller bundles -- 🤖 **MCP server** - the same 15 key, mnemonic, address, and signing tools over stdio +- 🤖 **MCP server** - the same 16 key, mnemonic, address, and signing tools over stdio - 📐 **Fully typed** - TypeScript definitions for every interface ## Install @@ -201,7 +201,7 @@ Language keys cover the 10 official BIP39 lists. Word lookup is case-insensitive ## MCP server -The package includes a stdio MCP server with the same 15 operations used by the Pi extension. After installing the package, configure an MCP client to run `keys mcp`. A checkout can run the built entry directly: +The package includes a stdio MCP server with the same 16 operations used by the Pi extension. After installing the package, configure an MCP client to run `keys mcp`. A checkout can run the built entry directly: ```json { @@ -216,6 +216,8 @@ The package includes a stdio MCP server with the same 15 operations used by the Hosts that own their transport can import `createMcpServer` from `@agntn/keys/mcp`. +Use `keys_generate_mnemonic` with `{ "words": 24 }` for a fresh English BIP39 mnemonic, or `{}` for 12 words. It also accepts 15, 18 and 21 words. Generation uses the library's cryptographic randomness, not entropy supplied by the model. The result is saved in the transcript, so it is for tests and disposable wallets only. + The server handles private keys, mnemonics, entropy, messages, and signatures as plaintext MCP arguments or results. They enter client transcripts. Use only public puzzle material or disposable test keys, never a wallet that controls real funds. ## Supported Blockchains diff --git a/packages/pi/AGENTS.md b/packages/pi/AGENTS.md index f142b75..8538f05 100644 --- a/packages/pi/AGENTS.md +++ b/packages/pi/AGENTS.md @@ -2,11 +2,11 @@ ## Scope -Pi coding agent extension only. Wraps the `@agntn/keys` library as 15 agent tools. **Do not** add blockchain logic, crypto, or chain implementations here. Those live in `../../src/`. This package is a thin tool surface over shared executors. +Pi coding agent extension only. Wraps the `@agntn/keys` library as 16 agent tools. **Do not** add blockchain logic, crypto, or chain implementations here. Those live in `../../src/`. This package is a thin tool surface over shared executors. ## Layout -- `extensions/keys.ts`: the extension. One `export default function(pi: ExtensionAPI)` registering 15 tools via `pi.registerTool`. +- `extensions/keys.ts`: the extension. One `export default function(pi: ExtensionAPI)` registering 16 tools via `pi.registerTool`. ## Key facts diff --git a/packages/pi/README.md b/packages/pi/README.md index 08d7b92..a964561 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -1,6 +1,6 @@ # @agntn/keys: Pi extension -Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library as 15 agent tools for key generation, WIF conversion, BIP39 entropy encoding, inspection and recovery, address derivation, validation, signing, and BIP44 paths across 10 blockchains (Bitcoin, Litecoin, Decred, Ethereum, Base, Solana, Aptos, TRON, SUI, Cardano). +Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library as 16 agent tools for key generation, WIF conversion, BIP39 generation, entropy encoding, inspection and recovery, address derivation, validation, signing, and BIP44 paths across 10 blockchains (Bitcoin, Litecoin, Decred, Ethereum, Base, Solana, Aptos, TRON, SUI, Cardano). > [!WARNING] > **This extension is experimental.** The package name, public API, provider model, CLI flags, and tool surfaces may change before the first stable release. Pin exact versions if you build on it now. @@ -14,6 +14,7 @@ Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library | `keys_generate_wallet` | Generate private key + public key + address for a chain | | `keys_derive_wallet` | Derive public key + address from an existing private key | | `keys_derive_hd_wallet` | Derive public key + address from a mnemonic and path | +| `keys_generate_mnemonic` | Generate a disposable English BIP39 mnemonic | | `keys_inspect_mnemonic` | Validate a BIP39 mnemonic and recover its entropy | | `keys_encode_bip39_entropy` | Encode hexadecimal entropy as an English BIP39 mnemonic | | `keys_lookup_bip39_indices` | Map numeric positions to words in an official BIP39 list | @@ -38,9 +39,11 @@ The extension loads the shared executors from `dist/tool-operations.mjs`; in a c Both WIF tools require a chain and default to mainnet. Encoding defaults to compressed keys; decoding preserves the encoded flag. Bitcoin and Litecoin testnet WIFs overlap, so decoding checks the requested context rather than identifying ownership. Decred supports compressed ECDSA keys only. +`keys_generate_mnemonic` accepts `{ "words": 24 }` for 24 words, or `{}` for the default 12. The other supported lengths are 15, 18 and 21 words. It generates fresh cryptographic randomness rather than asking the model for entropy. + ## Security note -`keys_generate_wallet` returns a plaintext private key, while `keys_derive_wallet` and `keys_sign_message` accept one. The BIP39 tools accept words or complete and partial phrases, and may return equivalent entropy, indices, or words allowed by the checksum. WIF tools convert between two equivalent secret representations, neither encrypted. Tool arguments and output land in the agent transcript. +`keys_generate_wallet` returns a plaintext private key, while `keys_derive_wallet` and `keys_sign_message` accept one. `keys_generate_mnemonic` returns a plaintext mnemonic. Other BIP39 tools accept words or complete and partial phrases, and may return equivalent entropy, indices, or words allowed by the checksum. WIF tools convert between two equivalent secret representations, neither encrypted. Tool arguments and output land in the agent transcript. > [!CAUTION] > **Never use this with real funds or with any wallet that has ever been used.** Treat every key it touches as burned the moment it appears in tool output. Generate fresh throwaway keys for testing only; assume anything passing through this extension is compromised and discard it. Keys that control real funds belong on a hardware wallet, never in an agent transcript. diff --git a/packages/pi/extensions/keys.ts b/packages/pi/extensions/keys.ts index 2c71327..283a1a6 100644 --- a/packages/pi/extensions/keys.ts +++ b/packages/pi/extensions/keys.ts @@ -7,7 +7,11 @@ import { Type } from "typebox"; import type * as KeysTools from "../../../dist/tool-operations.d.mts"; import { TOOL_ADDRESS_TYPES, TOOL_CHAINS, TOOL_NETWORKS } from "../../../src/tool-parameters.ts"; import { BIP39_LANGUAGES } from "../../../src/utils/bip39/languages.ts"; -import { WIF_ENCODE_PARAMETERS, WIF_DECODE_PARAMETERS } from "../../../src/tool-schemas.ts"; +import { + WIF_ENCODE_PARAMETERS, + WIF_DECODE_PARAMETERS, + GENERATE_MNEMONIC_PARAMETERS, +} from "../../../src/tool-schemas.ts"; const sourceModuleUrl = new URL("../../../src/tool-operations.ts", import.meta.url); const distributionModuleUrl = new URL("../../../dist/tool-operations.mjs", import.meta.url); @@ -187,6 +191,24 @@ export default function keysExtension(pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "keys_generate_mnemonic", + label: "Generate BIP39 Mnemonic", + description: "Generate a random English BIP39 mnemonic for tests or disposable wallets", + promptSnippet: "Use when a test needs a fresh BIP39 mnemonic rather than supplied entropy.", + promptGuidelines: [ + "Choose 12, 15, 18, 21 or 24 words. Default: 12", + "The result is saved in the transcript. Never use it for real funds", + ], + parameters: GENERATE_MNEMONIC_PARAMETERS, + renderCall(_args, _theme) { + return new Text("🧩 Generate disposable BIP39 mnemonic", 0, 0); + }, + async execute(_toolCallId, params) { + return (await loadToolOperations()).generateBip39Mnemonic(params.words); + }, + }); + pi.registerTool({ name: "keys_inspect_mnemonic", label: "Inspect Mnemonic", diff --git a/src/mcp.ts b/src/mcp.ts index 18095da..a099b88 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -25,6 +25,7 @@ import { encodeWif, decodeWif, generateWallet, + generateBip39Mnemonic, getAddress, inspectMnemonic, lookupBip39Indices, @@ -38,7 +39,11 @@ import { verifyMessage, } from "./tool-operations.ts"; import { version } from "./version.ts"; -import { WIF_ENCODE_PARAMETERS, WIF_DECODE_PARAMETERS } from "./tool-schemas.ts"; +import { + WIF_ENCODE_PARAMETERS, + WIF_DECODE_PARAMETERS, + GENERATE_MNEMONIC_PARAMETERS, +} from "./tool-schemas.ts"; type ReadonlyObjectSchema = Readonly & { readonly type: "object"; @@ -182,6 +187,15 @@ const tools: readonly ToolDefinition[] = [ args["network"], ), }, + { + name: "keys_generate_mnemonic", + title: "Generate BIP39 Mnemonic", + description: + "Generate a random English BIP39 mnemonic for tests or disposable wallets. The result enters the transcript. Never use it for real funds.", + inputSchema: GENERATE_MNEMONIC_PARAMETERS, + annotations: SENSITIVE_CREATE, + execute: (args) => generateBip39Mnemonic(args["words"]), + }, { name: "keys_inspect_mnemonic", title: "Inspect Mnemonic", diff --git a/src/tool-operations.ts b/src/tool-operations.ts index c444a64..2505076 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -12,12 +12,14 @@ import { TOOL_ADDRESS_TYPES_BY_CHAIN, TOOL_CHAINS, TOOL_NETWORKS, + TOOL_MNEMONIC_WORD_COUNTS, TOOL_WIF_CHAINS, type ToolChain, type ToolNetwork, } from "./tool-parameters.ts"; import { entropyToMnemonic, + generateMnemonic, getMnemonicWordCandidates, lookupBIP39Indices, lookupBIP39Words, @@ -76,6 +78,12 @@ export interface MnemonicInspectionDetails { entropy?: string; } +/** Fresh disposable mnemonic and its word count. */ +export interface GeneratedMnemonicDetails { + words: number; + mnemonic: string; +} + /** Mnemonic generated from supplied entropy. */ export interface EncodedEntropyDetails { words: number; @@ -440,6 +448,26 @@ export async function deriveHdWallet( }; } +/** + * Generate a disposable English mnemonic using the library's cryptographic randomness. + * @param wordsValue - Word count, defaulting to 12. + * @returns {ToolResult} Mnemonic, word count and transcript warning. + */ +export function generateBip39Mnemonic( + wordsValue: unknown = 12, +): ToolResult { + if (typeof wordsValue !== "number" || !TOOL_MNEMONIC_WORD_COUNTS.includes(wordsValue)) { + throw new RangeError("BIP39 word count must be 12, 15, 18, 21, or 24"); + } + const mnemonic = generateMnemonic((wordsValue / 3) * 32); + return { + content: content( + `Mnemonic: ${mnemonic}\nWords: ${wordsValue}\nThis mnemonic is saved in the transcript. Never use it for real funds.`, + ), + details: { words: wordsValue, mnemonic }, + }; +} + /** * Validate an English BIP39 mnemonic and recover its entropy when valid. * @param mnemonicValue - English BIP39 mnemonic candidate. diff --git a/src/tool-parameters.ts b/src/tool-parameters.ts index f8dee90..144074d 100644 --- a/src/tool-parameters.ts +++ b/src/tool-parameters.ts @@ -15,6 +15,9 @@ export const BIP44_PATH_MODE_SCHEMA = { ], } as const; +/** Supported BIP39 mnemonic lengths for generation tools. */ +export const TOOL_MNEMONIC_WORD_COUNTS: readonly number[] = [12, 15, 18, 21, 24]; + /** Every blockchain exposed by the tool surfaces. */ export const TOOL_CHAINS = [ "bitcoin", diff --git a/src/tool-schemas.ts b/src/tool-schemas.ts index 231b86f..713a992 100644 --- a/src/tool-schemas.ts +++ b/src/tool-schemas.ts @@ -1,5 +1,18 @@ import { Type } from "typebox"; -import { TOOL_WIF_CHAINS, TOOL_NETWORKS } from "./tool-parameters.ts"; +import { TOOL_WIF_CHAINS, TOOL_NETWORKS, TOOL_MNEMONIC_WORD_COUNTS } from "./tool-parameters.ts"; + +/** Shared MCP and Pi schema for generating a disposable English mnemonic. */ +export const GENERATE_MNEMONIC_PARAMETERS = Type.Object( + { + words: Type.Optional( + Type.Integer({ + enum: TOOL_MNEMONIC_WORD_COUNTS, + description: "Mnemonic word count: 12, 15, 18, 21 or 24. Default: 12", + }), + ), + }, + { additionalProperties: false }, +); const wifContext = { chain: Type.String({ diff --git a/test/eval-mcp.mjs b/test/eval-mcp.mjs index 08e6477..f0af46a 100644 --- a/test/eval-mcp.mjs +++ b/test/eval-mcp.mjs @@ -62,7 +62,7 @@ await client.connect(transport); try { const listed = await client.listTools(); - if (listed.tools.length !== 15) throw new Error(`Expected 15 tools, got ${listed.tools.length}`); + if (listed.tools.length !== 16) throw new Error(`Expected 16 tools, got ${listed.tools.length}`); const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; const mnemonic = @@ -93,6 +93,10 @@ try { { chain: "bitcoin", mnemonic, path: "m/84'/0'/0'/0/0" }, /bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu/, ); + const generated = await call("keys_generate_mnemonic", { words: 24 }, /Words: 24/); + const generatedMnemonic = /Mnemonic: ([a-z ]+)/.exec(generated)?.[1]; + if (!generatedMnemonic) throw new Error("keys_generate_mnemonic returned no mnemonic"); + await call("keys_inspect_mnemonic", { mnemonic: generatedMnemonic }, /Valid BIP39: yes/); await call("keys_inspect_mnemonic", { mnemonic }, /Valid BIP39: yes/); await call("keys_encode_bip39_entropy", { entropy: "00".repeat(16) }, /Words: 12/); await call("keys_lookup_bip39_indices", { indices: [0, 2047] }, /2047: zoo/); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 01dbb3f..cbfa942 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -10,6 +10,7 @@ const TOOL_NAMES = [ "keys_generate_wallet", "keys_derive_wallet", "keys_derive_hd_wallet", + "keys_generate_mnemonic", "keys_inspect_mnemonic", "keys_encode_bip39_entropy", "keys_lookup_bip39_indices", @@ -44,6 +45,34 @@ afterEach(async () => { }); describe("keys MCP server", () => { + it("generates fresh mnemonics with a default length through MCP", async () => { + const client = await connectTestClient(); + const mnemonics: string[] = []; + for (const args of [{}, {}, { words: 24 }]) { + const result = await client.callTool({ name: "keys_generate_mnemonic", arguments: args }); + expect(result.isError).not.toBe(true); + const mnemonic = /Mnemonic: ([a-z ]+)/.exec(text(result.content))?.[1]; + if (!mnemonic) throw new Error("Missing mnemonic"); + expect(mnemonic.split(" ")).toHaveLength(args.words ?? 12); + expect(text(result.content)).toContain("Never use it for real funds"); + const inspected = await client.callTool({ + name: "keys_inspect_mnemonic", + arguments: { mnemonic }, + }); + expect(text(inspected.content)).toContain("Valid BIP39: yes"); + mnemonics.push(mnemonic); + } + expect(new Set(mnemonics).size).toBe(3); + const listed = await client.listTools(); + expect( + listed.tools.find((tool) => tool.name === "keys_generate_mnemonic")?.annotations, + ).toMatchObject({ readOnlyHint: false, idempotentHint: false, openWorldHint: false }); + for (const args of [{ words: 13 }, { words: "12" }, { words: null }, { extra: true }]) { + const result = await client.callTool({ name: "keys_generate_mnemonic", arguments: args }); + expect(result.isError).toBe(true); + } + }); + it.each(wifTestVectors)("converts $chain $network WIF through MCP", async (vector) => { const client = await connectTestClient(); const { chain, network, compressed, privateKey, wif } = vector; diff --git a/test/pi-extension.test.ts b/test/pi-extension.test.ts index 125eaed..6e882b9 100644 --- a/test/pi-extension.test.ts +++ b/test/pi-extension.test.ts @@ -4,6 +4,7 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { litecoinTestVectors, decredTestVectors, wifTestVectors } from "./fixtures.ts"; import keysExtension from "../packages/pi/extensions/keys.ts"; +import { mnemonicToEntropy, validateMnemonic } from "../src/utils/bip39/index.ts"; interface RegisteredTool { readonly name: string; @@ -33,6 +34,34 @@ function registerTools(): ReadonlyMap { } describe("keys Pi extension", () => { + it.each([12, 15, 18, 21, 24])( + "generates a disposable %i-word mnemonic through Pi", + async (words) => { + const tool = registerTools().get("keys_generate_mnemonic"); + if (!tool) throw new Error("keys_generate_mnemonic was not registered"); + expect(Value.Check(tool.parameters, { words })).toBe(true); + const result = await tool.execute("generate", { words }); + const mnemonic = result.content[0]?.text?.match(/Mnemonic: ([a-z ]+)/)?.[1]; + if (!mnemonic) throw new Error("Missing mnemonic"); + expect(validateMnemonic(mnemonic)).toBe(true); + expect(mnemonic.split(" ")).toHaveLength(words); + expect(mnemonicToEntropy(mnemonic)).toHaveLength((words / 3) * 4); + expect(result).toMatchObject({ details: { words, mnemonic } }); + expect(result.content[0]?.text).toContain("Never use it for real funds"); + }, + ); + + it("rejects invalid mnemonic lengths even when Pi skips schemas", async () => { + const tool = registerTools().get("keys_generate_mnemonic"); + if (!tool) throw new Error("keys_generate_mnemonic was not registered"); + for (const words of [0, 11, 13, 25, 12.5, "12", null, true, NaN, Infinity]) { + expect(Value.Check(tool.parameters, { words })).toBe(false); + await expect(tool.execute("invalid", { words })).rejects.toThrow( + "BIP39 word count must be 12, 15, 18, 21, or 24", + ); + } + }); + it.each(wifTestVectors)("converts $chain $network WIF through Pi", async (vector) => { const tools = registerTools(); const encode = tools.get("keys_encode_wif");