What I learned running a paid, per-user remote MCP server

By Jake Marsh9 min read
Direct answer: 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.

This guide shows how to work with your own account data using AI tools. It is general information, not financial, tax or investment advice. For decisions about your money, check with a qualified professional.