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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pnpm build # obuild via build.config.ts
pnpm lint # oxlint + oxfmt check
pnpm lint:fix # oxlint + oxfmt fixes
pnpm playground <f> # 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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/pi/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions packages/pi/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 |
Expand All @@ -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.
24 changes: 23 additions & 1 deletion packages/pi/extensions/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
encodeWif,
decodeWif,
generateWallet,
generateBip39Mnemonic,
getAddress,
inspectMnemonic,
lookupBip39Indices,
Expand All @@ -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<TSchema> & {
readonly type: "object";
Expand Down Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions src/tool-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<GeneratedMnemonicDetails>} Mnemonic, word count and transcript warning.
*/
export function generateBip39Mnemonic(
wordsValue: unknown = 12,
): ToolResult<GeneratedMnemonicDetails> {
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.
Expand Down
3 changes: 3 additions & 0 deletions src/tool-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion src/tool-schemas.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
6 changes: 5 additions & 1 deletion test/eval-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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/);
Expand Down
29 changes: 29 additions & 0 deletions test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions test/pi-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -33,6 +34,34 @@ function registerTools(): ReadonlyMap<string, RegisteredTool> {
}

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");
Expand Down
Loading