Skip to main content
Glama

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)


Related MCP server: clearance-mcp

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

# 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)

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 ioredis
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:

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

Related MCP Connectors

Related MCP Servers