Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/mcp-transaction-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ton/mcp': patch
---

Added on-chain-anchored spend limits for agentic wallets. Limits are set by the owner on-chain (via `ChangeNftContentMsg`) and verified client-side before every transfer against the wallet's rolling transaction history — per-transaction caps and rolling time-window caps for TON and jettons. No local limits file or persisted counters; the on-chain history is the source of truth.
14 changes: 14 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ Examples of user requests, approximate corresponding raw CLI commands via `npx @
| `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 |

## Spend limits for agentic wallets

Agentic wallets can be capped by their **owner**, on-chain. The owner sets limits from the
[dashboard](https://agents.ton.org/); the MCP only reads and enforces them — it never sets limits.

- Limits are anchored by a `limits_hash` attribute in the wallet's on-chain NFT content and carried as a
`limitsDict` (`{asset: {window_seconds: max_spend}}`) in the owner-signed limits-change transaction.
- Before every transfer the MCP reads the on-chain `limits_hash`; if it changed it re-syncs the limits
from the latest limits-change transaction and verifies the hash. A hash that cannot be verified blocks the send.
- Each limit is checked against the wallet's **rolling on-chain history**: a window of `0` seconds is a
per-transaction cap, and any other window is "max spend within the last N seconds". TON and jettons are
metered independently (jettons matched by master address; TON metered net of incoming value).
- Wallets with no `limits_hash` set are unlimited. Synced limits are cached per wallet in `config.json`.

## Available Tools

In registry mode, wallet-scoped tools below also accept optional `walletSelector`. If omitted, the active wallet is used.
Expand Down
12 changes: 5 additions & 7 deletions packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('AgenticSetupSessionManager', () => {
});

it('accepts dashboard callback payloads', async () => {
const manager = new AgenticSetupSessionManager();
const manager = await AgenticSetupSessionManager.create();
managers.push(manager);

const session = await manager.createSession('setup-1');
Expand Down Expand Up @@ -73,7 +73,7 @@ describe('AgenticSetupSessionManager', () => {
});

it('answers CORS preflight requests', async () => {
const manager = new AgenticSetupSessionManager();
const manager = await AgenticSetupSessionManager.create();
managers.push(manager);

const session = await manager.createSession('setup-2');
Expand All @@ -92,7 +92,7 @@ describe('AgenticSetupSessionManager', () => {
});

it('marks sessions as completed and cancelled', async () => {
const manager = new AgenticSetupSessionManager();
const manager = await AgenticSetupSessionManager.create();
managers.push(manager);

await manager.createSession('setup-3');
Expand All @@ -106,7 +106,7 @@ describe('AgenticSetupSessionManager', () => {

it('restores persisted callback payloads and callback urls from config-backed store', async () => {
const store = new ConfigBackedAgenticSetupSessionStore();
const manager = new AgenticSetupSessionManager({ store });
const manager = await AgenticSetupSessionManager.create({ store });
managers.push(manager);

const session = await manager.createSession('setup-5');
Expand All @@ -124,9 +124,7 @@ describe('AgenticSetupSessionManager', () => {
});
expect(response.status).toBe(200);

const reopened = new AgenticSetupSessionManager({
store,
});
const reopened = await AgenticSetupSessionManager.create({ store });
managers.push(reopened);

expect(reopened.getSession('setup-5')).toMatchObject({
Expand Down
98 changes: 98 additions & 0 deletions packages/mcp/src/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import {
removeWallet,
saveConfig,
setActiveWallet,
updateAgenticWalletLimits,
updateAgenticWalletNftIndex,
upsertPendingAgenticDeployment,
upsertWallet,
} from '../registry/config.js';
import type { StoredLimits } from '../registry/config.js';

describe('mcp config registry', () => {
const baseAddress = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c';
Expand Down Expand Up @@ -319,6 +321,102 @@ describe('mcp config registry', () => {

expect(result).toBe(false);
});

const LIMITS: StoredLimits = { assets: { TON: { windows: { '0': '5' } } } };
const FORWARD_MAP = { EQjetton: '0:abc' };

it('createAgenticWalletRecord stores the forward jetton map and survives a save round-trip', async () => {
const record = createAgenticWalletRecord({
name: 'Agent wallet',
network: 'mainnet',
address: baseAddress,
ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS,
limits: LIMITS,
limitsHash: 'hash-1',
jettonWallets: FORWARD_MAP,
});
expect(record).toMatchObject({ limits: LIMITS, limits_hash: 'hash-1', jetton_wallets: FORWARD_MAP });

saveConfig({ ...createEmptyConfig(), active_wallet_id: record.id, wallets: [record] });
const persisted = await loadConfigWithMigration();
expect(persisted?.wallets[0]).toMatchObject({ jetton_wallets: FORWARD_MAP, limits_hash: 'hash-1' });
});

it('updateAgenticWalletLimits sets limits, hash, and forward map together', () => {
const agent = createAgenticWalletRecord({
name: 'Agent wallet',
network: 'mainnet',
address: baseAddress,
ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS,
});
const config = upsertWallet(createEmptyConfig(), agent);

const updated = updateAgenticWalletLimits(config, agent.id, LIMITS, 'hash-1', FORWARD_MAP);

expect(updated).not.toBe(config);
expect(updated.wallets.find((w) => w.id === agent.id)).toMatchObject({
limits: LIMITS,
limits_hash: 'hash-1',
jetton_wallets: FORWARD_MAP,
});
});

it('updateAgenticWalletLimits upgrades a legacy record (same hash, forward map newly added)', () => {
const agent = createAgenticWalletRecord({
name: 'Agent wallet',
network: 'mainnet',
address: baseAddress,
ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS,
limits: LIMITS,
limitsHash: 'hash-1',
// No jettonWallets: a record written before the forward map existed.
});
const config = upsertWallet(createEmptyConfig(), agent);
expect(config.wallets[0]).not.toHaveProperty('jetton_wallets');

const updated = updateAgenticWalletLimits(config, agent.id, LIMITS, 'hash-1', FORWARD_MAP);

// The hash is unchanged, but the missing forward map must still force a write.
expect(updated).not.toBe(config);
expect(updated.wallets.find((w) => w.id === agent.id)).toMatchObject({ jetton_wallets: FORWARD_MAP });
});

it('updateAgenticWalletLimits is a no-op when hash and forward map are unchanged', () => {
const agent = createAgenticWalletRecord({
name: 'Agent wallet',
network: 'mainnet',
address: baseAddress,
ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS,
limits: LIMITS,
limitsHash: 'hash-1',
jettonWallets: FORWARD_MAP,
});
const config = upsertWallet(createEmptyConfig(), agent);

const result = updateAgenticWalletLimits(config, agent.id, LIMITS, 'hash-1', { ...FORWARD_MAP });

expect(result).toBe(config);
});

it('updateAgenticWalletLimits clears limits, hash, and forward map when all are undefined', () => {
const agent = createAgenticWalletRecord({
name: 'Agent wallet',
network: 'mainnet',
address: baseAddress,
ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS,
limits: LIMITS,
limitsHash: 'hash-1',
jettonWallets: FORWARD_MAP,
});
const config = upsertWallet(createEmptyConfig(), agent);

const cleared = updateAgenticWalletLimits(config, agent.id, undefined, undefined, undefined);

const wallet = cleared.wallets.find((w) => w.id === agent.id)!;
expect(wallet).not.toHaveProperty('limits');
expect(wallet).not.toHaveProperty('limits_hash');
expect(wallet).not.toHaveProperty('jetton_wallets');
});
});

describe('mcp config registry compatibility with real CLI config', () => {
Expand Down
Loading
Loading