Skip to content
5 changes: 5 additions & 0 deletions .changeset/tough-pianos-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ton/mcp': patch
---

Persist wallet secrets in protected files and allow creating the MCP server directly from a WalletKit signer
55 changes: 39 additions & 16 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,30 @@ HTTP mode keeps a separate MCP session/transport per client session id, so multi
| `AGENTIC_CALLBACK_HOST` | `127.0.0.1` | Host for the local callback server in stdio mode |
| `AGENTIC_CALLBACK_PORT` | random free port | Port for the local callback server in stdio mode |

## Key Storage

`@ton/mcp` stores secrets differently depending on the runtime mode.

### Registry mode

- Wallet metadata is stored in `~/.config/ton/config.json` by default, or at `TON_CONFIG_PATH` if provided.
- Mnemonics and private keys are not kept inline in the registry after persistence. The config stores only a `sign_method` reference, currently `{ "type": "local_file", "file_path": "..." }`.
- Secret material is written into separate files under `<config-dir>/private-keys/...`, including wallet secrets, pending agentic deployment secrets, and pending agentic key rotation secrets.
- The config file and every secret file are written with strict filesystem permissions: files use `0600`, directories use `0700`.
- Both config and secret files go through the same `protected-file` layer, so the raw bytes on disk do not contain the plaintext mnemonic or private key.
- Legacy inline secrets from older config formats are automatically detected. When the config is read, these secrets are migrated to the `sign_method` storage format.
- When a wallet, pending deployment, or pending rotation is removed, orphaned secret files are deleted as part of the config transition.

### Single-wallet mode

- If you start the server with `MNEMONIC` or `PRIVATE_KEY`, the wallet is created directly from those values.
- In this mode the secret is not persisted by `@ton/mcp` into the local registry. It is kept in process memory for the lifetime of the MCP server.

### Security note

The built-in `protected-file` wrapper helps avoid writing mnemonics and private keys to disk in readable form, but it is not a replacement for an OS keychain, HSM, or external KMS.
Future versions are expected to support not only local key storage, but also trusted external signing and secret-management services.

## Available Tools

In registry mode, wallet-scoped tools below also accept optional `walletSelector`. If omitted, the active wallet is used.
Expand Down Expand Up @@ -436,26 +460,25 @@ The package also exports a programmatic API for building custom MCP servers:

```typescript
import { createTonWalletMCP } from '@ton/mcp';
import { Signer, WalletV5R1Adapter, TonWalletKit, MemoryStorageAdapter, Network } from '@ton/walletkit';
import { Signer } from '@ton/walletkit';

// Initialize TonWalletKit
const network = Network.mainnet();
const kit = new TonWalletKit({
networks: { [network.chainId]: {} },
storage: new MemoryStorageAdapter(),
});
await kit.waitForReady();

// Create wallet from mnemonic
// Create signer from mnemonic
const signer = await Signer.fromMnemonic(mnemonic, { type: 'ton' });
const walletAdapter = await WalletV5R1Adapter.create(signer, {
client: kit.getApiClient(network),
network,

// Create MCP server directly from signer
const server = await createTonWalletMCP({
signer,
network: 'mainnet',
walletVersion: 'v5r1',
});
const wallet = await kit.addWallet(walletAdapter);
```

If you already have a custom wallet adapter, you can still pass it directly:

```typescript
import { createTonWalletMCP } from '@ton/mcp';

// Create MCP server
const server = await createTonWalletMCP({ wallet });
const server = await createTonWalletMCP({ wallet: walletAdapter });
```

The same factory also supports registry mode:
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -282,5 +282,5 @@ Parameters:
- Registry mode uses the local TON config file from `~/.config/ton/config.json` or `TON_CONFIG_PATH`
- Agentic onboarding callback state is persisted in the local config; in stdio mode use `AGENTIC_CALLBACK_BASE_URL` and/or `AGENTIC_CALLBACK_PORT` when you need a stable callback endpoint across restarts
- Registry management responses are sanitized and do not expose mnemonic, private keys, operator private keys, or Toncenter API keys
- Read tools can work with imported agentic wallets without `operator_private_key`; write tools cannot
- Read tools can work with imported agentic wallets without `private_key`; write tools cannot
- **Default flow:** After any send, poll get_transaction_status until completed or failed. User can specify whether to check status.
2 changes: 1 addition & 1 deletion packages/mcp/skills/ton-manage-wallets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ Manage the local wallet registry and perform advanced agentic wallet operations
- Use available shell/browser tools to open dashboard URLs for the user whenever possible
- For confirmations and small option sets, prefer the host client's structured confirmation/choice UI when available; otherwise use a short natural-language yes/no prompt and never require an exact magic word
- Registry data is stored in `~/.config/ton/config.json` (or `TON_CONFIG_PATH`)
- Read tools work with imported agentic wallets that don't yet have an `operator_private_key`; write tools require it
- Read tools work with imported agentic wallets that don't yet have a `private_key`; write tools require it
- Management tool responses never expose private keys, mnemonics, or API keys
- To create a brand new agentic wallet, use the `ton-create-wallet` skill instead
6 changes: 3 additions & 3 deletions packages/mcp/src/__tests__/AgenticOnboardingService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('AgenticOnboardingService', () => {
const pendingDeployment: PendingAgenticDeployment = {
id: 'setup-1',
network: 'mainnet',
operator_private_key: '0xpriv',
sign_method: { type: 'local_file', file_path: '/tmp/setup-1.private-key' },
operator_public_key: '0xfeed',
name: 'Agent Alpha',
source: 'MCP flow',
Expand All @@ -33,7 +33,7 @@ describe('AgenticOnboardingService', () => {
network: 'mainnet',
address: 'UQBynBO23ywHy_CgarY9NK9FTz0yDsG82PtcbSTQgGoXwnZF',
owner_address: 'UQAcIXCxCd_gAqQ8RK0UA9vvlVA7wWjV41l2URKVxaMVLeM5',
operator_private_key: '0xpriv',
sign_method: { type: 'local_file', file_path: '/tmp/agent-1.private-key' },
operator_public_key: '0xfeed',
source: 'MCP flow',
collection_address: 'EQByQ19qvWxW7VibSbGEgZiYMqilHY5y1a_eeSL2VaXhfy07',
Expand Down Expand Up @@ -94,7 +94,7 @@ describe('AgenticOnboardingService', () => {

expect(registry.createPendingAgenticSetup).toHaveBeenCalledWith({
network: 'mainnet',
operatorPrivateKey: expect.stringMatching(/^0x[0-9a-f]+$/i),
privateKey: expect.stringMatching(/^0x[0-9a-f]+$/i),
operatorPublicKey: expect.stringMatching(/^0x[0-9a-f]+$/i),
name: 'Agent Alpha',
source: 'MCP flow',
Expand Down
12 changes: 6 additions & 6 deletions packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('AgenticSetupSessionManager', () => {
});

expect(response.status).toBe(200);
expect(manager.getSession('setup-1')).toMatchObject({
await expect(manager.getSession('setup-1')).resolves.toMatchObject({
status: 'callback_received',
payload: {
event: 'agent_wallet_deployed',
Expand Down Expand Up @@ -96,12 +96,12 @@ describe('AgenticSetupSessionManager', () => {
managers.push(manager);

await manager.createSession('setup-3');
manager.markCompleted('setup-3');
expect(manager.getSession('setup-3')).toBeNull();
await manager.markCompleted('setup-3');
await expect(manager.getSession('setup-3')).resolves.toBeUndefined();

await manager.createSession('setup-4');
manager.cancelSession('setup-4');
expect(manager.getSession('setup-4')).toBeNull();
await manager.cancelSession('setup-4');
await expect(manager.getSession('setup-4')).resolves.toBeUndefined();
});

it('restores persisted callback payloads and callback urls from config-backed store', async () => {
Expand Down Expand Up @@ -129,7 +129,7 @@ describe('AgenticSetupSessionManager', () => {
});
managers.push(reopened);

expect(reopened.getSession('setup-5')).toMatchObject({
await expect(reopened.getSession('setup-5')).resolves.toMatchObject({
callbackUrl: session.callbackUrl,
status: 'callback_received',
payload: {
Expand Down
34 changes: 16 additions & 18 deletions packages/mcp/src/__tests__/McpWalletService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,15 @@ describe('McpWalletService.getTransactions', () => {
});

const service = Object.create(McpWalletService.prototype) as McpWalletService & {
wallet: {
getAddress: () => string;
getClient: () => { getEvents: typeof getEvents };
};
address: string;
client: { getEvents: typeof getEvents };
};
Object.defineProperty(service, 'wallet', {
value: {
getAddress: () => 'UQTestWallet',
getClient: () => ({ getEvents }),
},
Object.defineProperty(service, 'address', {
value: 'UQTestWallet',
configurable: true,
});
Object.defineProperty(service, 'client', {
value: { getEvents },
configurable: true,
});

Expand Down Expand Up @@ -109,16 +108,15 @@ describe('McpWalletService.getTransactions', () => {
});

const service = Object.create(McpWalletService.prototype) as McpWalletService & {
wallet: {
getAddress: () => string;
getClient: () => { getEvents: typeof getEvents };
};
address: string;
client: { getEvents: typeof getEvents };
};
Object.defineProperty(service, 'wallet', {
value: {
getAddress: () => 'UQTestWallet',
getClient: () => ({ getEvents }),
},
Object.defineProperty(service, 'address', {
value: 'UQTestWallet',
configurable: true,
});
Object.defineProperty(service, 'client', {
value: { getEvents },
configurable: true,
});

Expand Down
Loading
Loading