---
title: "What I learned running a paid, per-user remote MCP server"
description: "Per-request server scoping, OAuth 2.1 with DCR for Claude and ChatGPT connectors, partial-failure warnings, and stopping the bill when a user cancels."
slug: "running-a-paid-remote-mcp-server"
url: "https://bankbridge.money/guides/running-a-paid-remote-mcp-server"
category: "how-to"
published: "2026-10-27"
updated: "2026-10-27"
---
# What I learned running a paid, per-user remote MCP server

> Build the server per request and scope it to the authenticated user. Serve the protected-resource metadata at both the root and the path-suffixed URL, support both DCR and client metadata documents, give every result a warnings array and keep isError for real failures, and put every paid upstream removal behind one audited function with more than one trigger.

BankBridge is a remote MCP server that gives an agent read-only access to your bank accounts, transactions, and investments. You connect your banks once on bankbridge.money, add the server to Claude, ChatGPT, Claude Code, Cursor, or anything else that speaks MCP, and ask things like “which subscriptions went up this year?”

It's a paid product: $8 a month per connected bank. Every tool call belongs to one person, touches that person's money, and has a real cost behind it. Most MCP examples are single-user servers running on your own machine, so a lot of what follows didn't come up in the tutorials I learned from.

Here's what the server looks like after a few months in production and a review for Claude's connectors directory.

## One McpServer per HTTP request

The MCP TypeScript SDK makes it tempting to create one `McpServer` at startup and reuse it. With multiple users that's how you leak one person's data to another. Tool handlers close over whatever user the server was built for, so a shared server means a shared user.

BankBridge builds a fresh server and a fresh transport for every request, scoped to the user the bearer token resolves to:

```
// src/pages/api/mcp.ts (trimmed)
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith("Bearer ")) {
    res.setHeader("WWW-Authenticate", wwwAuthenticateHeader());
    return res.status(401).json({ error: "Missing API key. Send Authorization: Bearer <key>." });
  }

  const valid = await validateApiKey(auth.slice(7).trim());
  if (!valid) {
    res.setHeader("WWW-Authenticate", wwwAuthenticateHeader());
    return res.status(401).json({ error: "Invalid API key" });
  }

  const user = await getUserById(valid.userId);
  const server = createMcpServer(user.id, accessFor(user));
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined, // stateless: no session tracking
  });

  res.on("close", async () => {
    await transport.close();
    await server.close();
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
}
```

There are no MCP sessions. Auth is checked on every request, so revoking a key or an OAuth grant takes effect on the next call with nothing cached to invalidate. Building a server per request sounds wasteful, but it's a handful of `registerTool` calls, which is nothing next to a round trip to a bank.

## Why the endpoint lives in the Pages Router

The rest of the site is Next.js 16 App Router. `/api/mcp` is the one route in `src/pages/api/`.

`StreamableHTTPServerTransport.handleRequest(req, res, body)` writes to a Node `ServerResponse`. App Router route handlers give you Web `Request` and `Response` objects instead. You can write an adapter, but a Pages Router API route hands you `IncomingMessage` and `ServerResponse` directly, and Next.js happily runs both routers in one app. The body is parsed by Next (`bodyParser: { sizeLimit: "1mb" }`) and passed through as `parsedBody`.

## OAuth 2.1, DCR, and what the connectors expect

Pasting a bearer key into a config file works for Claude Code and Cursor. claude.ai and ChatGPT want a URL and a sign-in button, which means the server has to be an OAuth 2.1 authorization server that a client it has never seen can register with.

The discovery chain goes like this:

1.  The client calls `/api/mcp` with no token and gets a 401 with `WWW-Authenticate: Bearer resource_metadata="https://bankbridge.money/.well-known/oauth-protected-resource"`.
2.  That document (RFC 9728) names the authorization server.
3.  `/.well-known/oauth-authorization-server` lists the authorize, token, registration, and revocation endpoints, PKCE S256, and the grant types.
4.  The client registers itself (RFC 7591), sends the user through authorize with PKCE, and exchanges the code.

Some clients skip the header hint and derive the metadata URL from the resource path, so I serve the same document at `/.well-known/oauth-protected-resource/api/mcp` too.

A few things I only learned by connecting real clients:

**Registration responses need `client_secret_expires_at`.** RFC 7591 makes it required whenever you issue a secret. One connector refused to register without it. `0` means it never expires:

```
return {
  client_id: clientId,
  client_secret: clientSecret,
  client_id_issued_at: Math.floor(Date.parse(now) / 1000),
  client_secret_expires_at: 0,
  redirect_uris: input.redirect_uris,
  grant_types: input.grant_types ?? ["authorization_code", "refresh_token"],
  token_endpoint_auth_method: row.token_endpoint_auth_method,
};
```

**claude.ai may not register at all.** Its default is to send an https URL as the `client_id` (a Client ID Metadata Document). The server fetches that URL, reads the client's name and redirect URIs, caches them for an hour, and treats it as a public client with PKCE required. Advertising `client_id_metadata_document_supported: true` in the metadata and handling both paths covers every client I've tested.

**OAuth tokens are API keys.** Access tokens are minted as the same `bbk_` keys a user can create by hand, stored hashed in the same table with `source = "oauth"`. The MCP endpoint has one validation path whether the token came from a pasted key or a connector. Access tokens live an hour, refresh tokens 90 days, and every refresh rotates both.

**DCR means anyone can register a client called “Claude”.** Open registration is the point, so the consent page is where you defend. If a client's name claims a known brand but the redirect URI isn't on that brand's domain (or the user's own machine), the consent screen says “This may not be BankBridge” in red and shows exactly where the code is going.

## Don't 403 after a successful sign-in

Most people who arrive through a connector button have never heard of BankBridge. They click Connect in claude.ai, sign in, and haven't paid or linked a bank yet.

The first version returned a 403 from `/api/mcp` for anyone without an active subscription. From the user's side, they had just finished signing in and Claude told them the connection failed. Nothing in that message points to “you need to finish setup.”

Now an unpaid user gets a working MCP server in setup mode. The tool list and schemas are identical to a paid account, so hosts and reviewers see the real surface, but every handler is swapped for one that returns a non-error result with the link that finishes setup:

```
function withSetupRequired(server: McpServer, reason: SetupReason) {
  const original = server.registerTool.bind(server);
  (server as any).registerTool = (name: string, config: unknown) =>
    original(name, config, async () => {
      const { message, setup_url } = setupRequiredMessage(reason);
      const body = { setup_required: true, reason, setup_url, message, warnings: [/* same message */] };
      return {
        content: [
          { type: "text", text: JSON.stringify(body) },
          { type: "text", text: `Account notices:\n- ${message}` },
        ],
        structuredContent: body,
      };
    });
}
```

The connection succeeds, the first question gets an answer that says what's left to do, and the moment they finish checkout and link a bank the same connection starts returning data. Nothing needs reinstalling.

## Live fetches, zero cache

BankBridge doesn't store transactions, balances, or holdings. The database holds accounts, subscriptions, API key hashes, and the encrypted (AES-256-GCM) tokens for each bank connection. Every tool call goes to the bank data source live, aggregates in memory, and returns.

That's a privacy decision first. It also removes a whole class of bugs: there's no sync job to fall behind, no stale balance, nothing to backfill. The cost is that every call depends on several banks responding quickly, so the fetch layer gets careful about time:

-   Connections are fetched in parallel with `Promise.allSettled`, so one slow bank can't sink the others.
-   Each call carries an `AbortSignal` budget: 7 seconds for the common tools, 20 for investment history, under a 15 second ceiling on the HTTP client itself.
-   Transaction pagination stops at a hard cap (2,000 rows) and says so in the result.
-   Every upstream error lands in one of four buckets. _Reauth_ flags the connection and emails the owner (at most once a day). _Dead_ means the connection is gone upstream. _Retry_ gets exponential backoff (500 ms, 1.5 s, 4 s, three attempts max). _Skip_ covers outages and unsupported products. Unknown codes go to retry.

## Partial failure is the normal case

With three banks connected, at some point one of them needs a re-login. If that turns the whole call into an error, the agent can't tell you about the other two.

Every tool result has the same shape: the JSON payload with a `warnings` array, mirrored in `structuredContent`. When there are warnings, a second text block repeats them as plain sentences, because some hosts only render text:

```
{
  "accounts": [ ... ],
  "warnings": [
    {
      "code": "...",
      "institution": "Chase",
      "connection_id": "...",
      "reconnect_url": "https://bankbridge.money/dashboard/reconnect/...",
      "message": "Chase needs to be reconnected. Visit https://bankbridge.money/dashboard/reconnect/.... These results exclude Chase."
    }
  ]
}
```

`isError: true` is reserved for two cases: invalid arguments, and total failure where no connected bank contributed anything. If every connection failed because each one needs a re-login, the result becomes a single `REAUTH_REQUIRED` error that lists the reconnect links.

The warnings never talk to the model. Early versions had text like “You MUST tell the user...” in results and tool descriptions. Claude's connectors directory asks for no model-behavior instructions in tool descriptions, so I took that wording out of results and server instructions too. A notice that states what happened (“Chase needs to be reconnected”) is enough; the agent works out that it should mention it.

Argument errors got the same treatment. The SDK's default validation failure is a raw zod dump. I wrap validation so a bad call comes back as something like “end\_date is before start\_date”, which an agent can fix and retry on its own.

## Cancellation has to stop the bill too

This part costs money when it goes wrong. Each connected bank has a monthly cost to me until the connection is removed upstream. If someone cancels and their connections stay alive, I keep paying for a customer who left.

Every removal path goes through one helper, `removeAllBankConnections(userId, reason)`, and every call writes a row to an audit table with the reason and whether the upstream removal succeeded. Several independent triggers feed it:

1.  **Daily subscription sync.** A scheduled job pulls each subscription's status from Stripe and applies cancellations. I don't depend on webhook delivery for anything that affects billing.
2.  **The MCP guard.** An inactive user who calls `/api/mcp` gets setup mode, and a fire-and-forget cleanup of their connections kicks off without slowing the response.
3.  **Nightly reconciliation.** A job looks for connections whose owner isn't paying (including rows whose user was deleted) and removes them. It also corrects Stripe subscription quantity when it has drifted from the real bank count, since quantity is how per-bank billing works.
4.  **User actions.** Disconnecting a bank or deleting the account use the same helper with their own reasons.

If an upstream removal fails, the helper logs it and leaves the row in place, so the next reconciliation retries it. It never throws into the caller.

One policy call took some thought: what to do with `past_due`. That user's card just failed and Stripe is still retrying. Removing their banks right away means someone who fixes their card a day later has to reconnect everything. So `past_due` users are blocked (they get setup mode with a “fix your card” link) but keep their connections. The banks are removed when the subscription becomes `canceled`.

## If you're building one

-   Build the server per request and scope it to the authenticated user. Don't keep a shared instance around.
-   Serve the protected-resource metadata at both the root and the path-suffixed URL, return `WWW-Authenticate` on every 401, and support both DCR and client metadata documents.
-   Let a signed-in user connect successfully even when they can't use the tools yet, and have the tools say what's missing.
-   Give every result a `warnings` array and keep `isError` for real failures.
-   Write tool output for the person reading it, and leave the model out of it.
-   If an upstream resource costs you money per user, put every removal behind one audited function and give it more than one trigger.

You can try the whole thing without an account. The demo key returns a fictional household's data:

```
claude mcp add --transport http bankbridge https://bankbridge.money/api/mcp \
  --header "Authorization: Bearer bbk_demo"
```

Or add `https://bankbridge.money/api/mcp` as a connector in claude.ai and choose “Try the demo” on the [sign-in screen](https://bankbridge.money/login).
