Skip to main content
Glama
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