# MCP server

Let an AI agent manage and use your M-Pesa integration — a remote, OAuth-authenticated MCP endpoint. Nothing to install.

## Nothing to install

paylod ships a **remote** MCP server. There is no npm package to add, no process to run, and no API key to paste into a config file. You give your AI client one URL; it discovers the authorization server, opens your browser once, and you approve exactly the capabilities you want. The agent gets a scoped token — never your Daraja credentials, never your paylod API key.

```text title="Server URL"
https://mcp.paylod.dev/mcp
```

Transport: `streamable-http`. Auth: OAuth 2.1 (authorization code + PKCE, with Dynamic Client Registration) against `https://paylod.dev/oauth`. Any MCP client that speaks remote HTTP works — and stdio-only clients can bridge.

## Add it to your client

Pick your client. Each one runs the OAuth flow for you on first connect: a browser tab opens, you sign in to paylod, you consent to scopes, done.

### Claude Code

One command. Use `--scope user` to make it available across all your projects (`local` is the default; `project` writes a shared `.mcp.json`).

```bash title="Terminal"
claude mcp add --transport http --scope user paylod https://mcp.paylod.dev/mcp
```

Then run `/mcp` inside Claude Code and pick **paylod** to authenticate. After that, `/mcp` shows the server connected with its tools listed.

Prefer to commit the config? Put this in `.mcp.json` at the repo root. The `type` field is required — an entry with a `url` but no `type` is treated as stdio and skipped.

```json title=".mcp.json"
{
  "mcpServers": {
    "paylod": {
      "type": "http",
      "url": "https://mcp.paylod.dev/mcp"
    }
  }
}
```

### Claude Desktop

Remote servers are added in the UI, not in `claude_desktop_config.json` (that file is for local stdio servers). Go to **Settings → Connectors → Add custom connector**, paste the server URL, and click **Connect** to run the OAuth flow. Leave the advanced client-ID/secret fields blank — paylod supports Dynamic Client Registration, so your client registers itself.

### Cursor

Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project). Cursor keys remote servers off `url`. Open **Settings → MCP** and click the login prompt to authorize.

```json title="~/.cursor/mcp.json"
{
  "mcpServers": {
    "paylod": {
      "url": "https://mcp.paylod.dev/mcp"
    }
  }
}
```

### VS Code

Add to `.vscode/mcp.json`. Note the top-level key is `servers`, not `mcpServers`. VS Code implements the MCP authorization spec including DCR, so it prompts you to sign in on first use.

```json title=".vscode/mcp.json"
{
  "servers": {
    "paylod": {
      "type": "http",
      "url": "https://mcp.paylod.dev/mcp"
    }
  }
}
```

### Windsurf

Add to `~/.codeium/windsurf/mcp_config.json`. Windsurf uses `serverUrl` for remote servers and supports OAuth on Streamable HTTP.

```json title="~/.codeium/windsurf/mcp_config.json"
{
  "mcpServers": {
    "paylod": {
      "serverUrl": "https://mcp.paylod.dev/mcp"
    }
  }
}
```

### Codex CLI

Codex supports remote Streamable-HTTP MCP servers with OAuth. Add the block below to `~/.codex/config.toml`, then run `codex mcp login paylod`.

```toml title="~/.codex/config.toml"
[mcp_servers.paylod]
url = "https://mcp.paylod.dev/mcp"
```

### Fallback for stdio-only clients

If your client only speaks stdio, bridge it with `mcp-remote` — the standard stdio-to-HTTP proxy. It handles the OAuth flow (opening your browser, caching the token under `~/.mcp-auth`) and needs no flags against paylod.

```json title="mcp-remote bridge"
{
  "mcpServers": {
    "paylod": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.paylod.dev/mcp"]
    }
  }
}
```

You almost certainly do not need this — Claude Code, Claude Desktop, Cursor, VS Code, Windsurf and Codex all speak remote HTTP natively.

## First thing to try

`decode_mpesa_error` is pure and offline — it needs no scopes, no credentials, and touches no network. That makes it the ideal way to confirm the server is wired up before you grant anything. Ask your agent:

```text title="Prompt"
What does M-Pesa error 2001 mean?
```

A good answer comes back with the cause (wrong PIN), the fix, and the message to show the customer — that is the tool, not the model guessing. Anyone who has stared at a bare `2001` in a Daraja response knows why this exists.

## How the OAuth flow works

Your client hits the server URL with no token and gets a `401` carrying a `WWW-Authenticate` header that points at the protected-resource metadata. From there it discovers the authorization server at `https://paylod.dev/oauth`, registers itself dynamically, and opens your browser. You sign in to paylod and land on a consent screen listing exactly which capabilities the agent is asking for — one scope per capability, each with its own checkbox.

```http title="The 401 that starts it all"
curl -i -X POST https://mcp.paylod.dev/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.paylod.dev/.well-known/oauth-protected-resource"
```

The three high-risk scopes — `payments.payout` (send money out), `credentials.write` (write your Daraja keys) and `keys.mint` (mint API keys) — are **unchecked by default**. You have to deliberately turn them on. Leave them off and the agent cannot call `payout`, `reversal`, `set_credentials` or `mint_key` at all: the server rejects the call on the scope, so it is not a prompt you have to trust the model to respect. Grant only `payments.simulate` and the agent can build against the sandbox without ever being able to touch real money.

The resulting access token is bound to this server as its audience, held by your client, and never pasted into the conversation. Your Daraja consumer key and secret are write-only — no tool reads them back, so an agent can store them for you but can never exfiltrate them.

## Tools

Every call is authorized by the OAuth access token and gated on the scope shown next to it — a token without the scope cannot invoke the tool.

### Management — set the integration up

| Tool | Scope | What it does |
| --- | --- | --- |
| `create_app` | `paylod:apps.write` | First-run onboarding: create your organization and its first application (paybill or till). Returns the applicationId, your callback URL and a one-time API key. |
| `create_application` | `paylod:apps.write` | Add another application to an organization you already have. |
| `get_callback_url` | `paylod:apps.write` | Get the hosted M-Pesa callback URL for an application — the URL you paste into the Daraja portal. Treat it as a secret. |
| `set_credentials` | `paylod:credentials.write` | Store or rotate the Daraja consumer key, secret, shortcode and passkey. Write-only — never read back. |
| `mint_key` | `paylod:keys.mint` | Mint a paylod API key (`mp_test_…` / `mp_live_…`) for your own backend. Returned once. |
| `configure_webhook` | `paylod:webhooks.write` | Create or update a webhook endpoint so results are pushed to your server, signed. |
| `list_webhooks` | `paylod:webhooks.write` | List the webhook endpoints configured for an application. |
| `list_applications` | `paylod:team.read` | List the applications you can access, with their environments and configuration state. |
| `authenticate` | — (no scope) | Show who the token belongs to and which scopes were actually granted. |

### Runtime — move and read money

| Tool | Scope | What it does |
| --- | --- | --- |
| `request_stk_push` | `paylod:payments.collect` | Send an STK Push to a customer's handset. Accepts an idempotency key, so a retrying agent never double-charges. |
| `get_payment_status` | `paylod:payments.read` | Look up one payment by id. If still pending, runs a live STK query and settles it on the spot. |
| `generate_qr` | `paylod:payments.collect` | Generate an M-Pesa QR code (base64 PNG). Stateless — no money moves. |
| `register_c2b` | `paylod:payments.collect` | Register C2B validation/confirmation URLs so paybill payments made outside your app reach you. |
| `get_account_balance` | `paylod:payments.read` | Query the M-Pesa account balance. Asynchronous — returns a queryId; the result arrives on your callback. |
| `get_transaction_status` | `paylod:payments.read` | Query the status of an M-Pesa transaction by receipt. Asynchronous, same callback pattern. |
| `payout` | `paylod:payments.payout` | Send money out (B2C). Irreversible on a live application. Requires the opt-in money-out scope. |
| `reversal` | `paylod:payments.payout` | Reverse or refund a transaction. Requires the opt-in money-out scope. |

### Sandbox & offline

| Tool | Scope | What it does |
| --- | --- | --- |
| `simulate_test_payment` | `paylod:payments.simulate` | Create a simulated collection in sandbox — no handset, no real money. |
| `simulate_outcome` | `paylod:payments.simulate` | Force a simulated payment to succeed or fail, so the agent can exercise your failure paths. |
| `decode_mpesa_error` | — (no scope) | Decode any Safaricom result code or Daraja error into cause, fix, and a customer-facing message. Pure and offline. |

The same codes `decode_mpesa_error` returns are browsable in the [error reference](/docs/errors).

## Set an integration up with the tools, in order

The tools are for **setting the integration up and inspecting it**. They are not what your application calls at runtime — that is the [Node SDK](/docs/sdk). Call them in this order.

1. **Do I already have an app?** → `list_applications`. Anything back means you are onboarded.
2. **Create the application.** A brand-new user calls `create_app` (it creates the organization *and* its first application). Someone who already has an organization calls `create_application` instead — `create_app` returns a `409 already onboarded` for them. Picking the wrong one is the single most common first-call mistake.
3. **Store the Daraja credentials** → `set_credentials`. It needs `product` — the *kind of shortcode*, `paybill` or `till`, not the Daraja product name. For a **till** you must also pass `partyB` (the till number that receives the funds), and it has to differ from `shortcode`, or Daraja rejects the push with `2002`. Credentials are encrypted at rest and write-only: no tool reads them back.
4. **Get the callback URL** → `get_callback_url`, then **paste it into the Safaricom Daraja portal** as the CallbackURL / ResultURL / ValidationURL / ConfirmationURL. This is the one step no agent can do for you — Safaricom has no API for it — and skipping it means payments never resolve. Treat the URL as a secret: the random `cbk_…` token in the path is what authenticates Safaricom's callback.
5. **Mint an API key** → `mint_key` (`mp_test_…` sandbox, `mp_live_…` production). Shown once. This is the key your *server* puts in `PAYLOD_API_KEY` — the MCP server itself never uses one.
6. **Optionally register a webhook** → `configure_webhook`. Skip it and poll instead; every payment is recorded either way.
7. **Then write the application code with the SDK** — `npm install @paylod/node`, `new Paylod(process.env.PAYLOD_API_KEY!)`, `await paylod.collectAndWait({ amount, phone })`. Render `outcome.message`; offer a retry only when `outcome.retryable`. Do not write a fetch wrapper, a polling loop, or a callback handler: see [Quickstart](/docs/quickstart) and the [Node SDK](/docs/sdk).
8. **Exercise the failure paths** → `simulate_test_payment` + `simulate_outcome` drive success, cancellation, a wrong PIN or a timeout through the real settlement path, without a handset.

`request_stk_push` and `get_payment_status` exist so an agent can *test* the loop end to end (and so a human can say "charge this number 10 bob"). Shipping them as your runtime integration means re-implementing the SDK badly.

> [!warn] `4999` is a **pending** payment, not a failed one — the customer has not entered their PIN yet. It is not retryable: a second `request_stk_push` sends a second prompt and can double-charge. Keep polling `get_payment_status`.

## What an agent can actually do

The point of the management tools is that an agent can take you from nothing to a working, live M-Pesa integration without you opening a dashboard. Connect the server, then ask:

```text title="Prompt"
Set up M-Pesa for my shop "Acme Ltd". Here are my Daraja sandbox
credentials: consumer key …, secret …, shortcode 174379, passkey ….
Create the app, store them, mint me a test API key, give me my
callback URL, then send a 10 KES STK push to 0712345678 and tell me
when it settles.
```

The agent calls `create_app` → `set_credentials` → `mint_key` → `get_callback_url` → `request_stk_push`, then polls `get_payment_status` until the payment settles. If it fails, it calls `decode_mpesa_error` and tells you in plain English that the customer cancelled the prompt (`1032`) or the handset timed out (`1037`). You hand it a phone number; it hands you back an API key and a receipt.

The one step the agent cannot do for you is paste that callback URL into the Safaricom Daraja portal — Safaricom has no API for it. Everything else, including the webhook that pushes results to your own server (`configure_webhook`), it can do.

> Everything the agent does over MCP is the same platform your backend talks to over HTTP at `/functions/v1` — see the [API reference](/docs/api). MCP is a second front door, not a separate system.
