@tokemak/autopools-mcp
v0.1.2
Published
Tokemak Autopools MCP server (internal): read pool/user data, prepare transactions, and execute them via an agent-owned Privy wallet or the user's own wallet signing in the browser.
Downloads
286
Readme
@tokemak/autopools-mcp
Internal MCP server for Tokemak Autopools: Claude (or any MCP client) reads
pool/user data and prepares deposits, withdrawals, staking, and claims through
@tokemak/autopools — self-custody only. The server holds no keys and can
never sign: autopools_request_signature mints a URL to the agentic console's
/sign page, which rebuilds the plan in the browser with live quotes,
shows drift vs. what the user confirmed with the agent, and signs with the
user's own Privy-connected wallet (embedded or external).
Chains: mainnet (1), Base (8453), Arbitrum (42161). 17 tools; stdio transport;
built on @modelcontextprotocol/server v2 (URL elicitation with plain-URL
fallback). Agent instructions ship in SKILL.md.
Quickstart (Claude Code)
pnpm --filter @tokemak/autopools-mcp build
claude mcp add tokemak -- node <repo>/packages/autopools-mcp/dist/cli.jsRead-only works with zero configuration. For the full write flow, two env vars:
TOKEMAK_MCP_MODE=write # narrow-by-default: read unless set
TOKEMAK_DASHBOARD_URL=https://<agentic-deploy> # signing/connect pagesThe user's wallet is discovered via the wallet_connect tool (they connect
in the browser and share only their address), or pinned with
TOKEMAK_USER_ADDRESS.
Optional: TOKEMAK_RPC_<chainId> (defaults are publicnode — the canonical
public RPCs gas-cap the lens reads), TOKEMAK_MCP_CHAINS=1,8453,42161,
TOKEMAK_MCP_ALLOW_FORCE=1 (exposes deposit-gating bypass),
TOKEMAK_MCP_STATE_DIR (default ~/.tokemak-mcp).
Install in the Claude Desktop app (regular chat, no Claude Code)
Works with the normal Claude Desktop app — you chat, Claude uses the Tokemak
tools. One-time setup, ~5 minutes (also on the console's /setup page):
Get the server built (needs Node ≥ 20 and pnpm once):
git clone https://github.com/Tokemak/v2-apps && cd v2-apps pnpm install && pnpm --filter @tokemak/autopools-mcp buildOpen the Desktop config: Claude Desktop → Settings → Developer → Edit Config. That opens
claude_desktop_config.json(macOS:~/Library/Application Support/Claude/, Windows:%APPDATA%\Claude\).Add the server (use the absolute path from step 1):
{ "mcpServers": { "tokemak": { "command": "node", "args": [ "/ABSOLUTE/PATH/TO/v2-apps/packages/autopools-mcp/dist/cli.js" ], "env": { "TOKEMAK_MCP_MODE": "write", "TOKEMAK_DASHBOARD_URL": "https://<agentic-deploy>" } } } }Read-only browsing (no transactions at all): delete the
envblock.Fully quit and reopen Claude Desktop (⌘Q / quit from the tray — the window close button isn't enough). The Tokemak tools appear under the tools icon in the chat input.
First chat: say "Check my Tokemak wallet status" — Claude calls
wallet_status. To link your wallet, ask it to runwallet_connect, click the URL it returns, and connect in the browser. From then on: ask for a deposit/withdraw → Claude shows you the plan → it hands you a signing link → you review the live numbers and sign in your own wallet. Approve each tool call when Desktop prompts you.
Notes for Desktop: signing/connect links arrive as plain URLs in the chat (Desktop doesn't render MCP link dialogs — the server detects this and falls back automatically), and nothing ever moves without your signature in the browser.
Hosted endpoint — /api/mcp (claude.ai web, zero install)
The agentic app also mounts a hosted, stateless flavor of this server at
/api/mcp (Streamable HTTP via the SDK's web-standard transport, one
instance per request — serverless-safe). Connect from claude.ai
(Settings → Connectors → Add custom connector, paste
https://<agentic-deploy>/api/mcp), from Claude Desktop's connectors, or
from Claude Code (claude mcp add --transport http tokemak <url>/api/mcp).
How it differs from the local stdio server:
Same reads, same prepare tools — but prepare takes the user's wallet address as an explicit
ownerinput (there's no per-user memory), and returns the signing URL inline with the envelope. The/signpage pins the signature toownerand rebuilds with live quotes as always.Signing links are short when the deployment has a store:
/sign#p=pln_…(~47 chars) instead of/sign#i=<~700 chars of base64>. The intent is parked under its planId and/signfetches it from/api/sign/intent; the page re-validates it with the same codec checks rather than trusting what it gets back. Parking costs nothing in exposure — the hosted server built that plan, so it has already seen every field, and a link-holder could always read the same bytes out of the fragment. Without a store (and always on the local stdio server, where "the intent never touches a server" is a real property) the link stays self-contained. A store failure falls back to the long link rather than failing the prepare: a signable plan beats a tidy URL.No plan store, no callback listener, no
plan_status/autopools_request_signature— hosted lambdas share no disk and hold no state. Confirmation flows the other way: with a store,autopools_await_signature { planId, chainId, owner }blocks until the user is done and returns the outcome; without one the page shows the hashes and the user gives them to Claude. Either waytx_statuscan verify any hash on-chain.autopools_await_signaturere-checks every reported hash against the chain before calling anything sent: the record is written by whoever holds the signing link, so a hash the chain does not attribute to the plan's owner comes back asunverified— never as success. That is strictly stronger than trusting a hash a human pasted into chat.- Outcomes are
sent(with per-txfromOwnerand receipt status),rejected(user declined, nothing sent),failed,unverified, orpending— call again, signing takes as long as the user takes.
URL-mode elicitation is wired but dormant on this transport. The consent dialog (the client renders the destination and returns an accept/decline) needs the 2026-07-28 multi-round-trip path, where the client fulfils the
input_requireditself and echoes back arequestState— the only shape a server with no memory can serve. The plumbing is here: an HMACrequestState(TOKEMAK_REQUEST_STATE_KEY, ≥32 bytes) seals the planId/nonce so a re-entry lands on the plan the user is actually looking at rather than rebuilding under a new id, and re-entry is checked before any rebuild for that reason.It does not activate today:
@modelcontextprotocol/[email protected]negotiates at most2025-11-25, and on that era the per-request capability envelope isn't surfaced, so the gate never opens. Fulfilling a 2025-erainput_requiredneeds the SDK's legacy shim, which re-enters in process over a held connection — impossible when every request is a fresh lambda, which is whylegacyShim: falseis set rather than left to hang. Every hosted client therefore gets the pre-rendered markdown link, verified byscripts/smoke-http-elicit.mjs.The stdio server does hold a connection, so it can elicit — but only for a client that declares
elicitation: { url: {} }. A bareelicitation: {}is form mode per spec, and current Claude Code declares exactly that; sending it a URL dialog makes the SDK refuse the leg and fail the whole tool call, so those clients get the markdown link instead.scripts/smoke-elicit.mjscovers all three shapes (none / url+form / bare form-only).wallet_connectships here too, andwallet_statusreturns the same link inline asconnectUrlwhenever no address is known — so a client that never thinks to callwallet_connectstill gets a link instead of a dead end. No URL elicitation on this transport: a dialog's reply arrives as a separate HTTP request that a stateless instance cannot match to anything.The handoff completes on its own when the deployment has a connect store (
BLOB_READ_WRITE_TOKEN):wallet_connectalso returns anonce, the intent is markedclaimable, the/connectpage POSTs the address to its own origin's/api/connect/claim(never a URL from the intent — that fragment is attacker-suppliable, and a post target inside it would be a zero-click address exfiltrator wearing the console's domain), andwallet_await_connection { nonce }blocks up to ~45s and returns it.pendingjust means the user hasn't clicked yet — call again. Without a store none of that is mounted and the page shows the address with a copy button for the user to paste, which is all a stateless deployment can honestly offer.- The nonce is a bearer secret (16 random bytes): first claim wins and is
never overwritten, records expire after 10 minutes, claiming is
same-origin POST only, and expired records are swept on write. A stolen
link buys plans built for the thief's address, which
/signthen refuses to sign with the user's wallet. - The store is injected (
buildHostedServer(config, { connectStore })), so the SDK's runtime closure stays viem + fetch — the Blob dependency lives in the app. The address is the blob's pathname, so a poll is onelistagainst the API and never a content read through the CDN (whose minimum cache is 60s and would serve stale misses for most of the wait).
- The nonce is a bearer secret (16 random bytes): first claim wins and is
never overwritten, records expire after 10 minutes, claiming is
same-origin POST only, and expired records are swept on write. A stolen
link buys plans built for the thief's address, which
Zero config on Vercel: the route defaults the signing-page origin to its own deployment (
TOKEMAK_DASHBOARD_URLoverrides). The endpoint is unauthenticated — everything it serves is public on-chain data and unsigned calldata the user must sign themselves; there is nothing to steal and no signer to abuse.
Verify a deployment with
node scripts/smoke-http.mjs https://<deploy>/api/mcp.
Design notes (the parts that bite)
- Prepare-only by construction: write tools return a Morpho-style
envelope (
planId, per-tx descriptions, expectations, simulation,warnings[]with halt semantics) of unsigned transactions. The only execution path is the human signing in their browser — there is no server-side signer to compromise. - Plan store is disk-persisted (
~/.tokemak-mcp) with validated state transitions (prepared → awaiting_signature → sent → confirmed/failed); parallel MCP processes share it. - Simulation: single txs via
eth_callfrom the owner; [approval, tx] sequences viaeth_simulateV1(simulateCalls), degrading to a loud "skipped" warning — never a silent pass. - The signing page trusts nothing: intents carry raw SDK params and the page rebuilds the plan client-side (fresh quotes — zap quotes live ~60s, so cached calldata is never signed), refuses non-registry autopools and expired links (2h), renders warnings and custom receivers loudly, and pins the sending account to the plan's owner.
- Callback loop: signing results return over a loopback listener
(
127.0.0.1, unguessable planId/nonce auth, Host + content-type enforced, reported hashes verified on-chain against the plan before a plan may confirm); if unreachable, the page shows the hashes for paste-back andplan_status/tx_statusstill resolve on-chain. - msg.sender discipline:
owneris always the connected user wallet, never a tool input (staking credits the sender; withdrawals burn the sender's shares).receiveron withdraw is the one free recipient field and is loudly flagged.
Verification
pnpm --filter @tokemak/autopools-mcp test # unit: codec, serializers, store, locks
pnpm --filter @tokemak/autopools-mcp smoke # live stdio: reads (3 chains), prepare
# envelopes + gating on Base, signing
# flow incl. callback, elicitation pathsStill pending before rollout: one end-to-end browser signing against the deployed console with a real wallet.
The console (apps/agentic)
/ — your positions, rewards, browser-signed tx log with live receipts, and
onchain history. /sign — the signing surface described above. /connect —
hands your address to your agent, serving all three flavors from one page: a
claimable intent (hosted + store) posts as soon as a wallet connects, a
callbackUrl intent (local MCP) keeps its explicit share button, and an
intent with neither shows the address with a copy button for you to paste.
/api/connect/claim — where the first of those posts. /setup — the install
walkthrough. Env: NEXT_PUBLIC_PRIVY_APP_ID, optional BLOB_READ_WRITE_TOKEN
(enables the automatic handoff), optional
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID and NEXT_PUBLIC_RPC_<chainId>.
