# PinMarket MCP server

Let an AI agent buy gift cards, mobile top-ups and eSIMs with **USDC on Solana**, using its own wallet.

- **Endpoint:** `https://pinmarket.fun/mcp`
- **Transport:** MCP Streamable HTTP. Send JSON-RPC 2.0 with `POST`; responses are plain `application/json`. Stateless: no sessions, no event stream (`GET` returns 405, as the spec allows).
- **Protocol versions:** `2025-06-18`, `2025-03-26`, `2024-11-05`
- **Auth:** none. Searching and quoting are public. Paying needs only a Solana wallet with USDC.
- **Custody:** your private key never leaves your agent. The server returns an **unsigned** transaction and only ever broadcasts one that is exactly the transaction it issued.
- **Network:** Solana mainnet. Currency: USDC (`EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`), 6 decimals. Listed prices already include every fee.

## Connect

**Claude Code**
```bash
claude mcp add --transport http pinmarket https://pinmarket.fun/mcp
```

**Cursor / VS Code / other clients that accept a URL**
```json
{ "mcpServers": { "pinmarket": { "url": "https://pinmarket.fun/mcp" } } }
```

**Claude Desktop** (add as a custom connector with the URL above, or bridge it with `mcp-remote`)
```json
{ "mcpServers": { "pinmarket": { "command": "npx", "args": ["-y", "mcp-remote", "https://pinmarket.fun/mcp"] } } }
```

**Your own agent** can call it with plain HTTP, see [Raw JSON-RPC](#raw-json-rpc).

## The purchase flow

```
search_products ─▶ get_product ─▶ buy_gift_card ─▶ (agent signs) ─▶ submit_signed_transaction ─▶ wait_for_code
   find a card      read prices     order + unsigned tx   your wallet      optional broadcast helper        returns the code
```

1. **`search_products`** (or `list_filters` first) finds a `product_id`.
2. **`get_product`** lists the denominations. Pick a `package_id`.
3. **`buy_gift_card`** with your wallet's public key. You get `order_token` and `transaction_base64`, an unsigned Solana transaction. The server has already checked that the wallet holds enough USDC and SOL.
4. **Sign** `transaction_base64` with that wallet's key. Then either **broadcast it yourself**, or pass the signed base64 to **`submit_signed_transaction`** and PinMarket broadcasts it.
5. **`wait_for_code`** waits for the payment to confirm and the card to be bought, then returns the `code`. If it returns `done: false`, call it again.

Nothing is charged until the signed transaction lands on-chain. The **`order_token` is a secret**: it is the only handle to the order and its code. Anyone holding it can read the code.

Timing: the quote is valid for **15 minutes**. The transaction's blockhash lasts about **60 seconds**, so sign promptly; if it expires, `refresh_transaction` gives you a fresh transaction for the same order and price.

## Tools

All tools return both `structuredContent` (JSON) and a text copy of the same JSON. Errors have `isError: true` and `{ "error": { "code", "message", ... } }`.

### `search_products`
Search the catalogue. Every word of `query` must match the brand, name, country or category.

| Argument | Type | Notes |
|---|---|---|
| `query` | string, optional | e.g. `"steam brazil"`, `"netflix"` |
| `country` | string, optional | ISO code, e.g. `US`, `TR` |
| `category` | string, optional | id from `list_filters`, e.g. `games` |
| `limit` | integer 1–25, optional | default 10 |
| `offset` | integer, optional | pagination |

Returns `{ total, offset, next_offset, products: [{ product_id, name, brand, country_code, country, currency, categories, from_price_usdc }] }`.

### `list_filters`
No arguments. Returns every `countries: [{ code, name, products }]` and `categories: [{ id, products }]`.

### `get_product`
| Argument | Type |
|---|---|
| `product_id` | string, required |

Returns `{ product_id, name, country, currency, in_stock, requires_phone_number, terms, packages: [{ package_id, value, currency, price_usdc }], purchasable }`. `value` is the card's face value in `currency`; `price_usdc` is what you pay.

### `get_shop_status`
No arguments. `{ open, reason? }`. The shop closes automatically when it is restocking.

### `buy_gift_card`
| Argument | Type | Notes |
|---|---|---|
| `product_id` | string, required | from `search_products` |
| `package_id` | string, required | from `get_product` |
| `payer_wallet` | string, required | public key (base58) of the wallet that will sign and pay |
| `phone_number` | string | only for top-up products (`requires_phone_number`), international format |

Returns:

```json
{
  "order_id": "3f9c2a71-…",
  "order_token": "…secret…",
  "status": "awaiting_payment",
  "product": "Steam Brazil", "value": "55",
  "amount_usdc": "11.020000",
  "quote_expires_at": "2026-10-07T12:15:00.000Z",
  "network": "solana-mainnet",
  "transaction_base64": "AQAAAAAA…",
  "message_base64": "AQABB…",
  "recent_blockhash": "…", "last_valid_block_height": 123456789,
  "valid_for_seconds": 60,
  "next_step": "…"
}
```

`transaction_base64` is a **legacy wire-format transaction with an empty signature slot**. It contains three instructions: compute-unit limit, compute-unit price (about 600 lamports of priority fee), and one SPL `transferChecked` of exactly `amount_usdc` USDC from your token account to PinMarket's, carrying a unique read-only *reference* account that identifies the order. You are the only signer and the fee payer.

### `refresh_transaction`
| Argument | Type |
|---|---|
| `order_token` | string, required |

A fresh unsigned transaction (new blockhash) for the same unpaid order. Same amount, same order. Only the newest transaction can be submitted through `submit_signed_transaction`.

### `submit_signed_transaction`
Optional: use it when your signer cannot broadcast.

| Argument | Type |
|---|---|
| `order_token` | string, required |
| `signed_transaction_base64` | string, required: the same transaction bytes with your signature filled in |

The server accepts the transaction only if its message is byte-for-byte the one it issued **and** the payer's signature verifies. Anything else is rejected (`invalid_transaction`). Returns `{ signature, explorer }`.

### `get_order`
| Argument | Type |
|---|---|
| `order_token` | string, required |

Current state, immediately, with the code if delivered.

### `wait_for_code`
| Argument | Type | Notes |
|---|---|---|
| `order_token` | string, required | |
| `timeout_seconds` | integer 1–45 | default 30 |

Waits for the payment to confirm on-chain and the card to be delivered. Returns the order state; when `status` is `delivered` it includes:

```json
{
  "status": "delivered", "done": true,
  "code": "A7QK-9TZP-3WLM-X2VD",
  "fields": [{ "key": "code", "value": "A7QK-9TZP-3WLM-X2VD" }],
  "redemption_instructions": "Go to store.steampowered.com …",
  "raw": "code: A7QK-…"
}
```

Order `status` values: `awaiting_payment` → `paid` → `fulfilling` → `delivered`. Side states: `awaiting_balance` and `failed` (delivery delayed: contact support, do **not** pay again) and `expired` (the quote ran out before payment).

## Signing the transaction

You only need to put a valid ed25519 signature over the message into the signature slot. The wire format is:

```
[ 0x01 ][ 64-byte signature ][ message … ]      message_base64 = the bytes after the signature
```

### JavaScript / TypeScript (`@solana/web3.js`)

```ts
import { Keypair, VersionedTransaction, Connection } from "@solana/web3.js";

const wallet = Keypair.fromSecretKey(/* your agent's key */);
const tx = VersionedTransaction.deserialize(Buffer.from(transaction_base64, "base64"));
tx.sign([wallet]);

// A) broadcast yourself
const sig = await new Connection(RPC_URL).sendRawTransaction(tx.serialize());

// B) or let PinMarket broadcast: pass the signed bytes to submit_signed_transaction
const signed_transaction_base64 = Buffer.from(tx.serialize()).toString("base64");
```

### Python (`solders`)

```python
import base64
from solders.keypair import Keypair
from solders.transaction import VersionedTransaction

wallet = Keypair.from_bytes(SECRET_KEY_BYTES)
tx = VersionedTransaction.from_bytes(base64.b64decode(transaction_base64))
signed = VersionedTransaction(tx.message, [wallet])
signed_transaction_base64 = base64.b64encode(bytes(signed)).decode()
```

### Wallets that only sign messages (MPC, HSM, custody APIs)

Sign the raw bytes of `message_base64` with ed25519, then build the transaction yourself: `0x01` + `signature (64 bytes)` + `message`. Base64-encode it and send it via `submit_signed_transaction`.

### Before you sign, check

An agent should not sign blindly. Decoding the transaction shows: the fee payer is **your** wallet; the only value-moving instruction is one SPL `transferChecked` of USDC (mint `EPjFWdd5…Dt1v`, 6 decimals) whose amount equals `amount_usdc`. If anything differs, do not sign.

## Raw JSON-RPC

```bash
# initialise (optional for a stateless server, but clients normally do it)
curl -s https://pinmarket.fun/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}'

# list the tools
curl -s https://pinmarket.fun/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# search
curl -s https://pinmarket.fun/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_products","arguments":{"query":"steam brazil","limit":3}}}'

# buy (returns the unsigned transaction)
curl -s https://pinmarket.fun/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"buy_gift_card","arguments":{"product_id":"steam-brazil","package_id":"…","payer_wallet":"YourWalletPubkey"}}}'
```

Notifications (messages without an `id`) get an empty `202`. Batches (arrays of up to 20 messages) are supported.

## Errors

| `error.code` | Meaning | What to do |
|---|---|---|
| `invalid_arguments` | An argument is missing or malformed | Fix the argument named in `message` |
| `shop_paused` | The shop is restocking or in maintenance | Wait; check `get_shop_status` |
| `product_not_found`, `package_unavailable` | Unknown or out-of-stock item | Search again |
| `phone_required` | Top-up product needs `phone_number` | Add it |
| `invalid_wallet` | `payer_wallet` is not a valid public key | Fix it |
| `insufficient_usdc` | No single token account holds enough USDC | Fund the wallet; `have` and `need` are returned |
| `insufficient_sol` | Not enough SOL (~0.00001) for the network fee | Add a little SOL |
| `too_many_open_orders` | 3 unpaid orders for this wallet | Pay or wait for them to expire |
| `rate_limited` | Too many requests | Back off for a minute |
| `invalid_transaction` | `submit_signed_transaction` got something other than the issued transaction | `reason` says why: `not_signed`, `message_mismatch`, `bad_signature` |
| `blockhash_expired` | The transaction waited too long | `refresh_transaction`, sign again |
| `order_not_found` | Wrong `order_token` | Use the one from `buy_gift_card` |
| `order_expired`, `not_payable` | The 15-minute quote ended, or the order is already paid | Create a new order, unless you already paid |
| `no_transaction`, `no_payer` | The order has no issued transaction | Call `refresh_transaction`, or create a new order |
| `insufficient_funds` | The wallet lost the USDC or SOL between building and sending | Top up, then `refresh_transaction` |
| `broadcast_failed` | Solana rejected the transaction (`detail` has the reason) | Retry, or broadcast it yourself |
| `shop_unavailable`, `internal_error` | A problem on our side | Retry shortly; if it persists, write to support |
| `rpc_unavailable` | A Solana RPC call failed | Retry shortly |

## Limits and behaviour

- Quotes last **15 minutes**; transactions about **60 seconds**.
- Rate limits are per IP: reads 120/min, orders 12/hour, refreshes 60/hour, submits 40/hour. `wait_for_code` waits up to 45 seconds per call.
- Only fixed-denomination products are supported. Custom-amount products show `purchasable: false`.
- No email is needed or sent for agent orders. The code is returned by `wait_for_code` / `get_order` only. Keep the `order_token`; if you lose it, you can still see the purchase by signing in with the paying wallet at <https://pinmarket.fun/#/codes>.
- Payment detection is automatic: it finds your transfer by its unique reference account, so it does not matter whether you broadcast it yourself or via `submit_signed_transaction`.
- If you pay the right amount but delivery is delayed, the order moves to `awaiting_balance` or `failed` and stays on record. Contact <support@pinmarket.fun> with the `order_id`. Do not pay twice.

## Security notes

- The server never asks for, receives, or stores a private key.
- `submit_signed_transaction` is not a generic relay: it refuses any transaction that is not exactly the one issued for that order, so it cannot be used to broadcast anything else.
- Treat `order_token` like a password. It is not recoverable from the order id.
- The reference account is read-only and unique per order; it can only ever credit this order.

## Support

<support@pinmarket.fun>. Include the `order_id`.
