mcp-wallet
by gifarz
README.md
# mcp-wallet
**The missing bridge between remote MCP servers and Web3 wallets.**
MCP servers run on your backend. User wallets (MetaMask, Phantom, WalletConnect) live in the browser. This package connects them — without ever touching private keys.
```
Claude → MCP tool → mcp-wallet → Approval URL → User's wallet → Signed tx
```
---
## Packages
This is a monorepo — install only what you need:
| Package | What it's for | Required? |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------- |
| `@wyntraxyz/mcp-wallet` | Core server SDK — bridge, storage adapters (in-memory + Prisma), chain/signer helpers, on-chain verification | Yes |
| `@wyntraxyz/mcp-wallet-ui` | React `<WalletApproval />` component + hooks for the approval page | Only if you're building the approval page in React/Next.js |
| `@wyntraxyz/mcp-wallet-adapters` | Extra storage adapters not bundled in core — currently Redis | Only if you need the Redis adapter (Prisma ships in core) |
---
## The Problem
Every existing approach forces a bad tradeoff:
| Approach | Problem |
| ------------------------------------ | -------------------------------------------- |
| Private key in env | Agent has full unsupervised access to funds |
| Custodial wallet (Coinbase AgentKit) | You own the keys, not the user |
| `mcp-wallet-signer` | stdio only — breaks with remote/HTTP MCP |
| Phantom MCP server | Phantom accounts only |
`@wyntraxyz/mcp-wallet` works with **remote MCP over HTTP** (the transport used by claude.ai connectors, Claude Desktop remote servers, and any production deployment) and supports **any wallet** the user already has.
---
## How It Works
1. **MCP tool is called** → bridge creates a pending request in storage, returns an `approvalUrl`
2. **User opens the URL** → sees a clear breakdown of what's being requested
3. **User approves in their wallet** → MetaMask / Phantom popup appears, they confirm
4. **Your frontend calls `bridge.resolve()`** → MCP tool can now confirm the action
No keys ever leave the user's wallet.
---
## Install
```bash
# Core server SDK — always required
npm install @wyntraxyz/mcp-wallet
# React UI component — optional, skip if you're building your own approval UI
npm install @wyntraxyz/mcp-wallet-ui
# Extra storage adapters (Redis) — optional, skip if using in-memory or Prisma
npm install @wyntraxyz/mcp-wallet-adapters
```
---
## Quick Start
### 1. Initialize the bridge (MCP server)
```ts
import { createWalletBridge } from "@wyntraxyz/mcp-wallet";
export const bridge = createWalletBridge({
approvalBaseUrl: "https://yourapp.xyz/wallet/approve",
// storage: new PrismaAdapter(prisma), // for production
ttl: 600, // 10 minutes
});
```
### 2. Use it in any MCP tool
```ts
import { bridge, formatBridgeResult } from "@wyntraxyz/mcp-wallet";
// Inside your MCP tool handler:
server.tool("buy_product", async ({ productId, price }, ctx) => {
const pending = await bridge.requestSignature({
sessionId: ctx.user.id,
transaction: {
type: "transfer",
chain: "base",
to: "0xRecipient...",
value: price,
metadata: { productId, action: "buy_product" },
},
});
return formatBridgeResult(pending);
// Claude returns: "Please approve this transaction at: https://yourapp.xyz/wallet/approve/abc123"
});
```
### 3. Add the approval page (Next.js)
```tsx
// app/wallet/approve/[id]/page.tsx
"use client";
import { WalletApproval } from "@wyntraxyz/mcp-wallet-ui";
import { useSendTransaction } from "wagmi";
export default function ApprovePage({ params }: { params: { id: string } }) {
return (
<WalletApproval
requestId={params.id}
appName="Your App"
fetchRequest={async (id) => {
const res = await fetch(`/api/mcp-wallet/requests/${id}`);
return res.json();
}}
onApprove={async (request) => {
// Sign with wagmi / viem / @solana/web3.js
const hash = await sendTransaction({ ... });
// Tell the bridge
await fetch(`/api/mcp-wallet/resolve/${request.id}`, {
method: "POST",
body: JSON.stringify({ txHash: hash, signerAddress: address }),
});
return { txHash: hash, signerAddress: address };
}}
onReject={async (request) => {
await fetch(`/api/mcp-wallet/reject/${request.id}`, { method: "POST" });
}}
/>
);
}
```
### 4. Add 3 API routes
```ts
// GET /api/mcp-wallet/requests/[id] → bridge.getRequest(id)
// POST /api/mcp-wallet/resolve/[id] → bridge.resolve(id, result)
// POST /api/mcp-wallet/reject/[id] → bridge.reject(id, reason)
```
See `docs/examples/nextjs-api-routes.ts` for the full implementation.
---
## Storage Adapters
### In-Memory (default) — `@wyntraxyz/mcp-wallet`
Zero config. Good for development and single-process deployments.
```ts
const bridge = createWalletBridge({ approvalBaseUrl: "..." });
```
### Prisma (production) — `@wyntraxyz/mcp-wallet`
Ships in core — no extra install needed.
```ts
import { PrismaAdapter } from "@wyntraxyz/mcp-wallet/adapters/prisma";
import { prisma } from "./lib/prisma";
const bridge = createWalletBridge({
approvalBaseUrl: "...",
storage: new PrismaAdapter(prisma),
});
```
Add to your `schema.prisma`:
```prisma
model McpWalletRequest {
id String @id @default(cuid())
sessionId String
transaction Json
status String @default("pending")
approvalUrl String
createdAt DateTime @default(now())
expiresAt DateTime
resolvedAt DateTime?
result Json?
error String?
@@index([sessionId])
@@index([status, expiresAt])
}
```
### Redis (production, multi-process) — `@wyntraxyz/mcp-wallet-adapters`
Requires the separate adapters package plus `ioredis`:
```bash
npm install @wyntraxyz/mcp-wallet-adapters ioredis
```
```ts
import { createWalletBridge } from "@wyntraxyz/mcp-wallet";
import { RedisAdapter } from "@wyntraxyz/mcp-wallet-adapters/redis";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const bridge = createWalletBridge({
approvalBaseUrl: "...",
storage: new RedisAdapter(redis),
});
```
Good fit when you're running multiple server instances behind a load balancer and want pending requests visible to all of them without a full SQL database. Expired-request cleanup uses `SCAN` rather than `KEYS`, so it's safe to run against a shared, multi-tenant Redis instance.
### Custom Adapter
Implement the `StorageAdapter` interface to use any database:
```ts
import { StorageAdapter, PendingRequest } from "@wyntraxyz/mcp-wallet";
class MyAdapter implements StorageAdapter {
async create(req) { ... }
async findById(id) { ... }
async findBySession(sessionId) { ... }
async update(id, patch) { ... }
async delete(id) { ... }
async cleanup(olderThan?) { ... }
}
```
---
## Supported Chains
| Chain | ID | Family |
| ------------- | --------------- | ------ |
| Ethereum | `ethereum` | EVM |
| Base | `base` | EVM |
| Base Sepolia | `base-sepolia` | EVM |
| Polygon | `polygon` | EVM |
| Arbitrum | `arbitrum` | EVM |
| Optimism | `optimism` | EVM |
| Solana | `solana` | Solana |
| Solana Devnet | `solana-devnet` | Solana |
Custom chains: pass any string as `chain` in your transaction — the bridge stores it and passes it through to the approval UI.
---
## Transaction Types
```ts
// Native token transfer
{ type: "transfer", chain: "base", to: "0x...", value: "0.01" }
// ERC-20 / SPL token
{ type: "token_transfer", chain: "base", to: "0x...", tokenAddress: "0x...", amount: "100" }
// Contract interaction
{ type: "contract_call", chain: "base", to: "0x...", abi: [...], functionName: "mint", args: [] }
// Message signing (no on-chain tx)
{ type: "sign_message", chain: "ethereum", message: "Sign in to YourApp" }
// EIP-712 typed data
{ type: "sign_typed", chain: "ethereum", domain: {...}, types: {...}, value: {...} }
```
---
## Bridge API
```ts
// Create a pending request
bridge.requestSignature({ sessionId, transaction, ttl? })
→ { requestId, approvalUrl, expiresAt, status: "pending" }
// Get current status
bridge.getRequest(requestId)
→ PendingRequest | null
// Wait for resolution (long-polling)
bridge.waitForApproval(requestId, { pollInterval?, timeout? })
→ PendingRequest
// Resolve (called by your frontend after signing)
bridge.resolve(requestId, { txHash?, signature?, signerAddress })
→ PendingRequest
// Reject
bridge.reject(requestId, reason?)
→ PendingRequest
// List pending requests for a session
bridge.listPending(sessionId)
→ PendingRequest[]
// Clean up expired requests
bridge.cleanup()
→ number (count cleaned)
```
---
## Security: verify before you trust `resolve()`
`bridge.resolve(requestId, result)` is normally called from a public API
route (`POST /api/mcp-wallet/resolve/:id`). That route has no way to
know, on its own, whether `result.txHash` / `result.signature` actually
correspond to a real transaction or signature — the browser could send
anything. **If you gate access, unlock paid content, or release funds when
a request resolves, an unverified `resolve()` lets anyone who knows a
`requestId` mark it as paid without moving any funds.**
Pass `verify` to `createWalletBridge()` to close this:
```ts
import { createWalletBridge, createEvmVerifier } from "@wyntraxyz/mcp-wallet";
const bridge = createWalletBridge({
approvalBaseUrl: "https://yourapp.xyz/wallet/approve",
verify: createEvmVerifier({ publicClient }), // checks sender, recipient, amount on-chain
});
```
`createEvmVerifier` fetches the transaction/receipt from your RPC (via a
viem `PublicClient`) and confirms it succeeded, that `from` matches the
claimed `signerAddress`, and that `to`/value match what was requested,
before `resolve()` is allowed to move the request out of `"pending"`. For
`sign_message` / `sign_typed`, it recovers the signer from the signature
instead. `createSolanaVerifier` does the equivalent for Solana transfers;
`sign_message` on Solana needs you to supply `verifyOffChainSignature`
(e.g. with `tweetnacl`), since this package doesn't bundle an ed25519
verifier.
Without `verify`, `resolve()` logs a warning and falls back to trusting the
client-submitted result — fine for a local demo, not for production.
---
## React Hooks
```ts
import {
useWalletBridgeRequest,
useBridgeApproval,
} from "@wyntraxyz/mcp-wallet-ui";
// Fetch + poll a request
const { request, loading, error, refetch } = useWalletBridgeRequest({
endpoint: "/api/mcp-wallet/requests",
requestId,
});
// Handle approve/reject actions
const { approve, reject, approving, phase } = useBridgeApproval({
resolveEndpoint: "/api/mcp-wallet/resolve",
rejectEndpoint: "/api/mcp-wallet/reject",
});
```
---
## Theming (`@wyntraxyz/mcp-wallet-ui`)
`<WalletApproval />` reads its accent color from three CSS custom properties
— `--accent`, `--accent2` (hover shade), `--accent3` (darker gradient end) —
via `var(--accent, #CCFF00)` internally. That means it inherits your app's
theme automatically, with no config on your end:
**1. Nothing to do, if you already set these globally.** If your app's
`global.css` defines them on `:root`, any page that imports that stylesheet
— including your `/wallet/approve/[id]` route, since it shares your app's
root layout — will theme the component correctly. CSS custom properties
resolve through the DOM at render time, not through your file/package
structure, so it doesn't matter that `mcp-wallet-ui` lives in a separate
package from `src/app`.
```css
/* src/app/global.css */
:root {
--accent: #CCFF00;
--accent2: #B8E600;
--accent3: #9ACC00;
}
```
**2. Per-instance override**, e.g. for per-tenant branding, without touching
global CSS:
```tsx
<WalletApproval
theme={{ accent: "#FF6B00", accent2: "#E65F00", accent3: "#CC5400" }}
// ...
/>
```
**3. Do nothing at all** and it falls back to the built-in lime theme
(`#CCFF00` / `#B8E600` / `#9ACC00`).
If a consumer outside Wyntrax installs this package with no global theme and
no `theme` prop, they get the lime default — it's not tied to your app's
CSS in any way that would break for them.
---
## Apps Using This
- [Wyntrax](https://wyntrax.xyz) — Web3 creator monetization platform
Add yours via PR.
---
## Roadmap
- [ ] WebSocket push (instead of polling)
- [ ] WalletConnect v3 deep-link support
- [ ] Transaction simulation preview (via Tenderly / Alchemy)
- [ ] Session key support (pre-authorized, scoped spending)
- [ ] React Native / mobile approval UI
---
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues