MCP server
Connect Claude, ChatGPT, Cursor or your own agent to Canopy's MCP tools. Create deposits and read status without writing an integration.
Copy this whole, and paste it into your coding agent along with your own instructions about the codebase. It is self-contained: it restates every part of the Canopy contract it needs, so the agent does not have to reach this site to follow it.
Also available as plain text at /agents/mcp/raw — see the prompt index for the rest of the catalogue.
It will ask you for credentials before it writes code
That is deliberate. Keys, webhook endpoints and embed origins all need a human with a dashboard session — an agent cannot create any of them. Have your keys to hand.
The prompt
# Connect an agent to Canopy's MCP server
Canopy exposes its API as MCP tools. An agent connected to it can, for the
Canopy merchant account it is authenticated as:
1. create a deposit for an end user, optionally naming a per-intent
destination wallet and chain;
2. list the chains and tokens that user can pay from, with the minimum for
each;
3. fetch the deposit address for the chain and token the user picked;
4. read the deposit's status, through detection and settlement;
5. list the account's deposits.
**The MCP server never moves funds.** It creates intents, issues addresses and
reads state. The end user sends from their own wallet to the address you show
them, or pays through the hosted Canopy page linked from the create result.
Canopy detects the transfer, deducts its platform fee and delivers the net to
the intent's destination. Nothing an agent does over MCP signs or submits a
transaction.
Supported funding tokens are whatever the server returns for that intent. That
includes native assets as well as tokens, and delivery can bridge or convert,
so the token that arrives is not necessarily the token sent. Never describe
Canopy as stablecoin-only, and never name a chain or token the tools did not
return.
---
## 1. The server URL
```
{CANOPY_API_BASE}/api/mcp
```
For Canopy's production host that is:
```
https://www.canopypay.io/api/mcp
```
**Include the `www.`.** The apex host 308-redirects to `www`, and most HTTP
clients drop the `Authorization` header across that cross-host hop. A good key
then gets `401`, which looks exactly like a bad key. If your Canopy account is
served from a different host, use that host with `/api/mcp` appended. The
headless prompt (`/agents/headless`, section 1) explains how to confirm a base
URL.
Transport is **Streamable HTTP**: stateless, `POST` only, JSON responses. There
is no session to resume and no server-sent event stream. A `GET` or `DELETE`
on `/api/mcp` returns `405`; that is expected, not an outage. Clients that only
speak the older SSE transport cannot connect.
There are two ways to authenticate, and which one you use depends on the
client, not on preference:
| Client | Auth |
| --------------------------------------------------- | -------------------------- |
| Claude Code, Cursor, any client with a header field | Secret key as bearer token |
| Claude Messages API MCP connector | Secret key as bearer token |
| OpenAI Responses API remote MCP tool | Secret key as bearer token |
| ChatGPT custom connectors / apps | OAuth |
| Claude.ai and Claude Desktop custom connectors | OAuth |
---
## 2. Auth mode 1: secret key as a bearer token
```
Authorization: Bearer cnpy_sk_live_...
```
The secret key comes from the Canopy dashboard, under API keys. You cannot
create one; ask the human who owns the Canopy account for it. It needs the
`payment_intents:create` scope, which every MCP tool requires. A key without it
gets HTTP `403` (`insufficient_scope`) on every MCP request.
**The secret key is server-side only.** It can create intents on the merchant's
account. Never paste it into a shared chat, a ticket, a screenshot or a
committed file, and never put it in a client app, a browser bundle or a mobile
build. Every config below reads it from the environment. A publishable key
(`cnpy_pk_live_`) does not work here and is refused like a made-up token.
Export it once in the shell your client launches from:
```bash
export CANOPY_SECRET_KEY=cnpy_sk_live_...
```
### Claude Code
```bash
claude mcp add --transport http canopy https://www.canopypay.io/api/mcp \
--header "Authorization: Bearer $CANOPY_SECRET_KEY"
```
The shell expands `$CANOPY_SECRET_KEY` when you run this, so the key ends up
in Claude Code's config. If you would rather keep it out of the config, use a
project `.mcp.json`, which is expanded at launch instead:
```json
{
"mcpServers": {
"canopy": {
"type": "http",
"url": "https://www.canopypay.io/api/mcp",
"headers": {
"Authorization": "Bearer ${CANOPY_SECRET_KEY}"
}
}
}
}
```
`.mcp.json` is usually committed. That is safe with `${CANOPY_SECRET_KEY}` in
it and unsafe with the literal key in it. Check which one you wrote before you
commit.
### Cursor
`~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
```json
{
"mcpServers": {
"canopy": {
"url": "https://www.canopypay.io/api/mcp",
"headers": {
"Authorization": "Bearer ${env:CANOPY_SECRET_KEY}"
}
}
}
}
```
### Claude Messages API (MCP connector)
The connector needs both halves: the server in `mcp_servers` and a matching
`mcp_toolset` in `tools`, under the `mcp-client-2025-11-20` beta. A request
with only `mcp_servers` is rejected as a validation error.
```ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const response = await client.beta.messages.create({
model: "claude-opus-5-5",
max_tokens: 16000,
betas: ["mcp-client-2025-11-20"],
mcp_servers: [
{
type: "url",
url: "https://www.canopypay.io/api/mcp",
name: "canopy",
authorization_token: process.env.CANOPY_SECRET_KEY,
},
],
tools: [{ type: "mcp_toolset", mcp_server_name: "canopy" }],
messages: [{ role: "user", content: "Set up a deposit for user 4812." }],
});
```
This call runs on your server, because it carries the secret key. Do not make
it from a browser.
### OpenAI Responses API (remote MCP tool)
```ts
import OpenAI from "openai";
const client = new OpenAI();
const response = await client.responses.create({
model: "gpt-5",
tools: [
{
type: "mcp",
server_label: "canopy",
server_url: "https://www.canopypay.io/api/mcp",
headers: { Authorization: `Bearer ${process.env.CANOPY_SECRET_KEY}` },
require_approval: "never",
},
],
input: "Set up a deposit for user 4812.",
});
```
Same rule: server side only. Use whatever model your OpenAI account runs;
the `tools` entry is what matters.
### Check the connection
List the tools. You should see exactly five: `create_deposit`,
`list_deposit_options`, `get_deposit_address`, `get_deposit_status` and
`list_deposits`. If the client reports an auth failure instead:
- the URL is on the apex host and the header was dropped (use `www.`);
- the key is revoked, from another account, or a publishable key;
- the header is `Authorization: cnpy_sk_live_...` without `Bearer `.
None of these are fixed by retrying. Go back to the human for a working key or
URL.
---
## 3. Auth mode 2: OAuth (ChatGPT, Claude.ai, Claude Desktop)
Hosted chat products cannot hold a secret key for you, so they connect with
OAuth instead. The person using the chat:
1. adds a custom connector and pastes `https://www.canopypay.io/api/mcp` as the
server URL;
2. is sent to Canopy, signs in to their Canopy dashboard if they are not
already signed in, and sees a consent screen naming the connecting app, the
host it will redirect to and the Canopy account;
3. approves, and is sent back to the chat product, which now holds a Canopy
access token for that account.
That's the whole user flow. The chat product refreshes the token itself. To
disconnect, remove the connector in the chat product. Access is scoped to the
one Canopy account that approved it, with scope `payment_intents:create`, the
same scope a secret key needs.
### For client builders
If you are writing an MCP client rather than using one, this is what Canopy
implements. It follows the MCP authorization spec (OAuth 2.1).
**Discovery.** An unauthenticated `POST /api/mcp` returns `401` with a JSON-RPC
error body and:
```
WWW-Authenticate: Bearer resource_metadata="https://www.canopypay.io/.well-known/oauth-protected-resource/api/mcp", scope="payment_intents:create"
```
Fetch that protected resource metadata (it is also served at
`/.well-known/oauth-protected-resource`). It names the authorization server;
its metadata is at `/.well-known/oauth-authorization-server`.
| Endpoint | Path |
| ----------------------- | --------------------- |
| Authorization (consent) | `/oauth/authorize` |
| Token | `/api/oauth/token` |
| Dynamic registration | `/api/oauth/register` |
| Revocation (RFC 7009) | `/api/oauth/revoke` |
**Clients are public.** `token_endpoint_auth_methods_supported` is `["none"]`.
There is no client secret. Register either way:
- **Client ID Metadata Document (CIMD).** Your `client_id` is an `https` URL
that serves your client metadata, and the document's `client_id` must equal
that URL. Canopy fetches it over https only, with a timeout and a size cap,
refuses private addresses and does not follow redirects.
- **Dynamic Client Registration (RFC 7591)** at `/api/oauth/register`.
**Authorization code with PKCE.** Only `response_type=code` and only
`code_challenge_method=S256`. Send `resource` equal to the canonical MCP URL
(`https://www.canopypay.io/api/mcp`, exactly), and
`scope=payment_intents:create`. Redirect URIs must match the registered value
exactly; the one exception is loopback (`http://localhost:<port>/callback` or
`http://127.0.0.1:<port>/callback`), which matches on any port. The redirect
back carries `code`, `state` and `iss`; check `iss`. A denied consent comes
back as `error=access_denied`.
**Tokens.** The code is single-use and lives 60 seconds. Exchange it at
`/api/oauth/token` (form-urlencoded) with the same `redirect_uri`, `resource`
and `client_id`, and your `code_verifier`. You get:
- an access token, `cnpy_oat_…`, valid 1 hour, bound to the MCP resource;
- a refresh token, `cnpy_ort_…`, valid 30 days.
Refresh tokens **rotate on every use**. Store the new one each time. Presenting
a refresh token that was already used revokes the whole token family, so a
client that retries a refresh with the old token logs its user out. A dead
code or token is `invalid_grant`; start the authorization flow again rather
than retrying.
Send the access token exactly like a secret key:
`Authorization: Bearer cnpy_oat_…`.
---
## 4. Tools
Every tool acts only on the Canopy account the token belongs to. Another
account's intent is `not_found`, never a permission error, so the two cannot be
told apart. Every input schema is strict: an unknown key, including any
attempt to pass a fee or a treasury, is rejected.
Each result has `structuredContent` (the fields below), a short
`content[0].text` summary for the model, and the same JSON as
`structuredContent` in `content[1].text` for clients that ignore
`structuredContent`.
| Tool | What it does | Input | Output (`structuredContent`) | Read-only |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `create_deposit` | Creates a payment intent for one end user, or returns the active one for the same destination. | `priceUnits?`, `oneTime?`, `merchantReference?`, `metadata?`, `payoutWallet?`, `payoutNamespace?`, `payoutChainReference?`, `payoutTokenAddress?` | `{ intentId, created, priceUnits, oneTime, hostedPageUrl }` | No |
| `list_deposit_options` | Lists the chains and tokens the user can fund this intent from, with minimums, and the rails it settles in. | `{ intentId }` | `{ origins: [{ caip19, namespace, reference, chainId, name, token: { symbol, address, decimals }, minimumUnits, minimumDisplay, depositIssuable }], settleRails: [...] }` | Yes |
| `get_deposit_address` | Issues (or returns) the deposit address for one option, and tells Canopy's detection to watch it. Issue-or-get: calling it again returns the same address. | `{ intentId, caip19 }` | `{ address, caip10, chainName, token: { symbol, decimals }, minimumDisplay }` | No (idempotent) |
| `get_deposit_status` | Reads detection and settlement state for an intent. | `{ intentId }` | `{ intentId, state, depositDetected, belowMinimum, payoutDestination, transfers: [{ stage, amountDisplay, feeDisplay, netDisplay, tokenSymbol, chainId, sourceTx, settlementTx, observedAt }] }` | Yes |
| `list_deposits` | Lists the account's intents, oldest first. | `{ limit?, cursor? }` | `{ data: [{ intentId }], hasMore, nextCursor }` | Yes |
None of the tools reach outside Canopy (`openWorldHint: false`) and none are
destructive.
### `create_deposit` inputs
| Field | Notes |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `priceUnits` | Base-units decimal **string** of the settle asset, `^[1-9][0-9]{0,29}$`. Omit for an open amount. See rule 3 below. |
| `oneTime` | Boolean. Requires `priceUnits`. |
| `merchantReference` | ≤128 chars. Echoed on settlement webhooks; the merchant's join key. Not returned by any MCP tool. |
| `metadata` | ≤16 keys, keys ≤64 chars, values string ≤256 / number / boolean, ≤2048 bytes total. |
| `payoutWallet` | Per-intent destination wallet, usually the end user's own wallet. Omit to use the account default. |
| `payoutNamespace` | `"eip155"`, `"solana"` or `"stacks"`. Only with `payoutWallet`. |
| `payoutChainReference` | The destination chain's reference string (for example `"8453"` on EVM). Only with `payoutWallet`. |
| `payoutTokenAddress` | The destination token. Only with `payoutWallet`. Omit to use the account default for that chain. |
There is no fee field and no treasury field. Do not look for one.
`hostedPageUrl` is a Canopy-hosted checkout for this intent. Giving the user
that link is the alternative to steps 3 to 5 of the flow below: the page shows
the options, the address and the status itself.
---
## 5. A worked flow
The user says: "I want to top up my account from Base."
**1. Create the deposit.**
```
create_deposit { "payoutWallet": "0x8a1F…c03E", "payoutNamespace": "eip155",
"payoutChainReference": "8453" }
→ { "intentId": "0f2ad4e9…", "created": true, "priceUnits": null,
"oneTime": false, "hostedPageUrl": "https://www.canopypay.io/customer-pay?…" }
```
Name the destination only if the user or the app told you which wallet the
funds go to. Never guess one. Without destination fields the account default
applies.
**2. List the options.**
```
list_deposit_options { "intentId": "0f2ad4e9…" }
→ { "origins": [
{ "caip19": "eip155:8453/erc20:0x8335…2913", "name": "Base",
"token": { "symbol": "USDC", … }, "minimumDisplay": "2.0825",
"depositIssuable": true },
{ "caip19": "eip155:8453/erc20:0xfde4…", "name": "Base",
"token": { "symbol": "USDT", … }, "minimumDisplay": "2.0825",
"depositIssuable": true },
… ],
"settleRails": [ … ] }
```
**3. Let the user pick.** Show every option with its chain name, token symbol
and minimum, and mark the ones with `depositIssuable: false` as unavailable.
Two options can share a chain name (Base USDC and Base USDT above), so keep
the `caip19` of the one they choose; it is the only unique key.
**4. Get the address.**
```
get_deposit_address { "intentId": "0f2ad4e9…",
"caip19": "eip155:8453/erc20:0x8335…2913" }
→ { "address": "0x74cE…90E9", "caip10": "eip155:8453:0x74cE…90E9",
"chainName": "Base", "token": { "symbol": "USDC", "decimals": 6 },
"minimumDisplay": "2.0825" }
```
**5. Show it with its chain and token.**
> Send **USDC on Base** to `0x74cE…90E9` (write out the full address).
> Minimum 2.0825 USDC. Send only USDC, and only on Base. Any other token, or
> USDC on any other network, will not be credited and may be lost for good.
**6. Follow the status.**
```
get_deposit_status { "intentId": "0f2ad4e9…" }
→ { "state": "active", "depositDetected": true, "belowMinimum": false,
"transfers": [ { "stage": "settled", "amountDisplay": "25", "feeDisplay": "0.25",
"netDisplay": "24.75", "tokenSymbol": "USDC", … } ] }
```
Each transfer moves through `stage` values: `observed`, `confirming`,
`finalized`, `submitted`, then `settled`. `settled`, `quarantined` and
`terminal_failure` are final; `reorged` and `recoverable_failure` are not. An
empty `transfers` list means nothing has been seen yet, which is normal for
the first minutes after a user sends. Base your wording on `stage` and treat a
value you do not recognise as "still in progress".
Check it when the user asks or says they have sent, not in a tight loop. Rate
limits apply (section 7). Report `amountDisplay`, `feeDisplay` and
`netDisplay` as given; do not recompute them.
**Status from an agent is for the conversation only.** The authoritative
signal that a deposit settled is Canopy's signed settlement webhook to the
merchant's server (`/agents/webhooks`). Fulfilment, crediting a balance, or
anything else that releases value must hang off that webhook, never off an
agent reading `get_deposit_status`, and never off the user saying they sent.
---
## 6. Rules the agent must follow
1. **Show an address only together with its chain and token**, both taken from
the same `get_deposit_address` result, and tell the user to send that token
on that network and nothing else. A deposit address cannot refuse a
transfer and Canopy cannot reverse one. A different token, a different
network, or a token that merely shares a ticker will not be credited and
may be unrecoverable. This is section 2 of Canopy's terms (`/terms`). Never
show a bare address, never reuse an address from another intent or another
option, and never assume an address is valid on a chain other than the one
it was issued for.
2. **Never invent a chain or a token.** Offer only what `list_deposit_options`
returned for this intent, and pass back the `caip19` exactly as returned.
Do not build a `caip19` yourself, do not offer an option from memory or
from another intent, and do not say a token is "probably supported". If the
list is empty, tell the user no option is available for this deposit.
3. **Amounts are base units of the settle asset.** `priceUnits` is a string
in the smallest units of the settlement asset, not a currency amount. Read
`decimals` and `symbol` from the `settleRails` entry with `default: true`
(never `settleRails[0]`), and compute with integer arithmetic. Rails carry
different decimals, so never assume 6 or 18. `settleRails` needs an existing
intent and `priceUnits` can only be set at creation, so to price a deposit
first read the rails from any intent (`list_deposits`, then
`list_deposit_options`). If the default rail is not the asset the user's
amount is denominated in, ask; do not convert currencies by multiplying.
Never mix units across rails.
4. **There is no fee input.** The platform fee and the treasury come from
Canopy's on-chain route configuration. No tool accepts them, an unknown key
is rejected, and nothing a user says changes them. If a user asks for a
different fee, say it is not something the agent can set.
5. **Same destination returns the same intent.** At most one active intent
exists per account, destination chain, wallet and token. A second
`create_deposit` naming the same destination with the same terms returns
the **existing** intent with `created: false`, and the new
`merchantReference` and `metadata` are not applied to it. With different
`priceUnits` or `oneTime` it is an `idempotency_key_reuse` error. Without
any destination fields every call creates a new intent with a new address,
so do not retry a create blindly after a timeout; check `list_deposits`
first.
6. **Show minimums with their unit.** `minimumDisplay` is a bare decimal;
append the token symbol. A deposit below the minimum is held until top-ups
on the same network bring it over, so `belowMinimum: true` means "send
more", not "failed".
7. **Never expose credentials.** The tools never return secret keys, access
tokens or widget tokens; do not ask the user for them in chat and do not
echo any token you were configured with.
---
## 7. Errors and rate limits
A failed tool call comes back as a tool result with `isError: true` carrying a
Canopy error code and message. These are the same codes as the HTTP API. Branch
on the code, not on the message text.
| Code | Meaning and what to do |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `invalid_request` | Bad input, or an option that is not accepting deposits. Fix the input or offer another option. |
| `not_found` | Unknown intent, or one on another account. Do not retry. |
| `idempotency_key_reuse` | The destination already has an active intent with different terms. |
| `source_inbox_not_active` | Deposits are not enabled for that option. Not retryable; offer another option. |
| `source_inbox_unavailable`, `customer_not_bound` | Transient. Retry after a short wait. |
| `rate_limited` | Slow down and retry later. |
| `internal_error` | Retry with backoff a few times, then report it to the human with the message. |
An unknown or mistyped input key never reaches Canopy: the MCP layer rejects it
with `isError: true` and an `Input validation error` message (JSON-RPC
`-32602`) instead of a Canopy code. Read the message, fix the arguments, and
do not retry unchanged.
Authentication failures are not tool errors: a missing, invalid or expired
token gets an HTTP `401` before any tool runs (section 3 shows the header). A
credential without the `payment_intents:create` scope gets an HTTP `403` with
`WWW-Authenticate: Bearer error="insufficient_scope", …`; a human must reissue
it. An
OAuth client should refresh its token and retry once; a secret-key client
should stop and ask for a working key.
Rate limits are per tool and per credential: about **30 calls a minute** for
`create_deposit` and `get_deposit_address`, and **120 a minute** for the
read-only tools. On top of that, each credential is capped at **300 MCP requests
a minute** in total, including `initialize` and `tools/list`; over it, the
request gets HTTP `429` with `Retry-After`. Treat these as a budget for a
conversation, not for a loop.