@wyntraxyz/mcp-wallet-ui
v0.1.1
Published
React UI components for @wyntraxyz/mcp-wallet — approval modal, countdown, status display.
Readme
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 txPackages
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
- MCP tool is called → bridge creates a pending request in storage, returns an
approvalUrl - User opens the URL → sees a clear breakdown of what's being requested
- User approves in their wallet → MetaMask / Phantom popup appears, they confirm
- Your frontend calls
bridge.resolve()→ MCP tool can now confirm the action
No keys ever leave the user's wallet.
Install
# 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-adaptersQuick Start
1. Initialize the bridge (MCP server)
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
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)
// 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
// 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.
const bridge = createWalletBridge({ approvalBaseUrl: "..." });Prisma (production) — @wyntraxyz/mcp-wallet
Ships in core — no extra install needed.
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:
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:
npm install @wyntraxyz/mcp-wallet-adapters ioredisimport { 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:
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
// 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
// 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:
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
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.
/* 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:
<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 — 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
