Adding an MCP Connector So Claude & ChatGPT Can Update Your App
Updated · 20 min read
Heads up: Version note: the code below was written for mcp-handler 1.x. mcp-handler 2.x (MCP SDK 2.x) changed the tool context to ctx.http.authInfo, removed the basePath option, and no longer limits itself to one path, so guard your route segment. The OAuth design is unchanged.
Step-by-step, using SpendFlow (a real personal-finance app) as the working example. By the end, a user can open Claude or ChatGPT, say "add 50 for coffee," and have it land as a real transaction in their account — authorized through a proper OAuth consent flow, not a copy-pasted API key.
What MCP actually is
MCP (Model Context Protocol) is not a separate server you have to run somewhere. It's a message format — JSON-RPC 2.0 over plain HTTPS — that an AI chat client (Claude, ChatGPT) speaks to any endpoint you expose. Your Next.js API route is the "MCP server." No daemon, no socket, no new infra.
Three conversations happen over that one endpoint:
initialize → "I'm spendflow v1.0"
tools/list → 7 tool definitions (name + description + JSON schema)
tools/call add_expense → Prisma insert → "Added ৳50.00 to Coffee Shops"
Tip: The model never sees your source code — only the tool descriptions you write. Good descriptions are what make the model pick the right tool with the right arguments. Treat them like API docs, not comments.
Since Claude/ChatGPT are talking to your app on the user's behalf, you need a way to know which user is on the other end of a given request. That's what the OAuth layer in this post is for — it's the harder half of this feature, but it's what turns "anyone with the URL can write to any account" into "only the account owner, after they click Approve."
Architecture overview
| Piece | Path | Job |
|---|---|---|
| MCP endpoint | app/api/[transport]/route.ts | Handles JSON-RPC, resolves bearer token → userId |
| Discovery metadata | app/.well-known/oauth-authorization-server | Tells clients where /connect and the token endpoint live |
| Client registration | app/api/oauth/register | Claude/ChatGPT self-register (RFC 7591 DCR) |
| Token exchange | app/api/oauth/token | Auth code + PKCE → bearer token |
| Consent page | app/(app)/connect/page.tsx | Session-gated "Approve" screen |
| Tools | modules/mcp/domain/tools.ts | add_expense, list_categories, get_summary, … |
| Data layer | lib/data/repos/mcp.ts | Clients, one-time auth codes, tokens (all hashed) |
Full authorization flow, end to end — this is the part most tutorials skip and it's the part that actually matters:
1. User pastes https://yourapp.com/api/mcp into Claude → 2. Claude fetches /.well-known/oauth-authorization-server → 3. Claude self-registers via /api/oauth/register (DCR) → 4. Browser opens /connect?client_id=…&code_challenge=… → 5. Already-logged-in user clicks Approve → 6. One-time code → redirected back to Claude → 7. Claude exchanges code + PKCE verifier at /api/oauth/token → 8. Bearer token issued, stored hashed server-side → 9. Every tool call now carries "Authorization: Bearer sfmcp_…"
1. Install mcp-handler
mcp-handler is Vercel's official adapter — it turns a Next.js route handler into an MCP endpoint (protocol handshake, tool registration, streamable HTTP transport) so you don't hand-roll the JSON-RPC plumbing.
npm install mcp-handlerTip: Stick to the streamable HTTP transport (the default). The older SSE transport needs Redis for session resumability — skip it unless you specifically need it.
2. Design the OAuth data model
You're building a minimal OAuth 2.1 provider — not because it's fun, but because neither Claude's nor ChatGPT's connector UI accepts a pasted API key. They only speak OAuth with Dynamic Client Registration (DCR). Three tables, same one-time-token pattern as any link-token flow:
// Public OAuth clients from Dynamic Client Registration — no secret,
// PKCE (S256) protects the code exchange instead.
model McpClient {
clientId String @id @default(uuid()) @map("client_id") @db.Uuid
clientName String @map("client_name")
redirectUris Json @map("redirect_uris") // string[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@map("mcp_clients")
}
// One-time authorization codes (10-min expiry, single use).
model McpAuthCode {
codeHash String @id @map("code_hash")
clientId String @map("client_id") @db.Uuid
userId String @map("user_id") @db.Uuid
codeChallenge String @map("code_challenge")
redirectUri String @map("redirect_uri")
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
@@index([userId])
@@map("mcp_auth_codes")
}
// Bearer access tokens — only the hash is ever stored.
model McpToken {
id String @id @default(uuid()) @db.Uuid
tokenHash String @unique @map("token_hash")
userId String @map("user_id") @db.Uuid
clientName String @map("client_name")
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(3)
@@index([userId])
@@map("mcp_tokens")
}Heads up: Never store raw codes or tokens. Store sha256(code) / sha256(token) only. If your database ever leaks, a hash-only table is useless to an attacker without also having the original secret.
3. Crypto helpers: hashing & PKCE
PKCE (Proof Key for Code Exchange) is what stops a stolen authorization code from being redeemed by someone who wasn't the one who requested it. The math is simple: the client picks a random secret (the "verifier"), sends its SHA-256 hash (the "challenge") up front, then proves it knew the verifier at exchange time.
import { createHash, randomBytes } from "node:crypto";
export const TOKEN_PREFIX = "sfmcp_";
export const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export function generateSecret(prefix: string = TOKEN_PREFIX): string {
return prefix + randomBytes(32).toString("base64url"); // 256 bits of entropy
}
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/** PKCE S256: base64url(sha256(verifier)) must equal the stored challenge. */
export function verifyPkceS256(verifier: string, challenge: string): boolean {
const computed = createHash("sha256").update(verifier).digest("base64url");
return computed === challenge;
}Test it against the official RFC 7636 test vector, so you know your implementation is spec-correct, not just "seems to work":
it("accepts the RFC 7636 test vector", () => {
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
expect(verifyPkceS256(verifier, challenge)).toBe(true);
});4. Write your tools
Each tool is a plain async function: dependency-injected repos in, a human-readable text result out. Never throw — a thrown error becomes an opaque 500 the model can't recover from. Return an error message instead, and the model reads it and retries sensibly.
export type ToolResult = { text: string; isError?: boolean };
export async function addTransaction(
repos: McpRepos, userId: string, type: "income" | "expense",
input: { amount: number; category: string; account?: string; note?: string; date?: string },
): Promise<ToolResult> {
const profile = await repos.profiles.getForUser(userId);
if (!profile) return { text: "Profile not found.", isError: true };
if (!Number.isFinite(input.amount) || input.amount <= 0) {
return { text: "Amount must be a positive number, e.g. 12.50.", isError: true };
}
const amountMinor = toMinor(input.amount, profile.currency);
// Fuzzy-match the category name the model guessed against the real list.
const cats = await repos.categories.listForUser(userId, type);
const cat = matchByTitle(cats, input.category);
if (!cat.ok) {
// Feed the mismatch back as TEXT — the model reads this and retries
// with a name from the list, instead of failing silently.
return {
text: `Category "${input.category}" not found. Available: ${cat.candidates.join(", ")}.`,
isError: true,
};
}
const accounts = await repos.accounts.listForUser(userId);
const account = accounts.find((a) => a.isDefault) ?? accounts[0];
const tx = await repos.transactions.createForUser(userId, {
type, amountMinor, categoryId: cat.item.id, accountId: account.id,
occurredOn: input.date ?? today(), note: input.note ?? null,
});
return { text: `Added ${type}: ${formatMoney(tx.amountMinor, profile.currency)} in ${cat.item.title}.` };
}The fuzzy matcher is a small pure function — exact match, then unique prefix, then unique substring, and it never guesses between two equally-good candidates:
export type MatchResult<T> =
| { ok: true; item: T }
| { ok: false; reason: "none" | "ambiguous"; candidates: string[] };
export function matchByTitle<T extends { title: string }>(items: T[], query: string): MatchResult<T> {
const q = query.trim().toLowerCase();
const exact = items.find((i) => i.title.trim().toLowerCase() === q);
if (exact) return { ok: true, item: exact };
const prefix = items.filter((i) => i.title.toLowerCase().startsWith(q));
if (prefix.length === 1) return { ok: true, item: prefix[0] };
if (prefix.length > 1) return { ok: false, reason: "ambiguous", candidates: prefix.map((i) => i.title) };
const sub = items.filter((i) => i.title.toLowerCase().includes(q));
if (sub.length === 1) return { ok: true, item: sub[0] };
if (sub.length > 1) return { ok: false, reason: "ambiguous", candidates: sub.map((i) => i.title) };
return { ok: false, reason: "none", candidates: items.map((i) => i.title) };
}5. The MCP route + discovery metadata
The endpoint route lives at app/api/[transport]/route.ts — with basePath: "/api", that resolves to POST /api/mcp. It's wrapped in withMcpAuth, which does the bearer-token dance for you: verify the token, resolve it to a user, attach that to every tool call.
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { z } from "zod";
import { mcpRepo } from "@/lib/data";
import { sha256Hex, TOKEN_PREFIX } from "@/modules/mcp/domain/oauth";
import { addTransaction } from "@/modules/mcp/domain/tools";
const handler = createMcpHandler(
(server) => {
server.registerTool(
"add_expense",
{
title: "Add expense",
description: "Record an expense in the user's account.",
inputSchema: {
amount: z.number().positive().describe("Amount in major units, e.g. 12.50"),
category: z.string().min(1).describe("Category name (fuzzy-matched)"),
account: z.string().optional(),
note: z.string().max(500).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
},
},
async (input, extra) => {
const userId = (extra.authInfo?.extra as { userId?: string })?.userId;
if (!userId) return { content: [{ type: "text", text: "Unauthorized." }], isError: true };
const result = await addTransaction(repos, userId, "expense", input);
return { content: [{ type: "text", text: result.text }], isError: result.isError };
},
);
// ...6 more tools: add_income, list_categories, list_accounts,
// list_recent_transactions, get_budget_status, get_summary
},
{ serverInfo: { name: "spendflow", version: "1.0.0" }, capabilities: { tools: {} } },
{ basePath: "/api", maxDuration: 60 },
);
const verifyToken = async (_req: Request, bearerToken?: string) => {
if (!bearerToken?.startsWith(TOKEN_PREFIX)) return undefined;
const resolved = await mcpRepo.resolveToken(sha256Hex(bearerToken));
if (!resolved) return undefined;
return { token: bearerToken, scopes: ["spendflow"], clientId: "spendflow-mcp",
extra: { userId: resolved.userId } };
};
const authHandler = withMcpAuth(handler, verifyToken, {
required: true,
resourceMetadataPath: "/.well-known/oauth-protected-resource",
});
export { authHandler as GET, authHandler as POST };The two .well-known routes are what let Claude/ChatGPT discover your OAuth server automatically — same idea as robots.txt, but for machines negotiating auth:
export function GET(req: Request) {
const origin = new URL(req.url).origin;
return Response.json({
issuer: origin,
authorization_endpoint: `${origin}/connect`,
token_endpoint: `${origin}/api/oauth/token`,
registration_endpoint: `${origin}/api/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"], // public client, PKCE instead
});
}6. OAuth endpoints: register & token
Dynamic Client Registration lets Claude/ChatGPT create their own OAuth client on the fly — no manual "create an app in a developer console" step, which is exactly why MCP connectors feel like they "just work."
const BodySchema = z.object({
client_name: z.string().min(1).max(200).optional(),
redirect_uris: z.array(z.string().url()).min(1).max(10),
});
export async function POST(req: Request) {
const parsed = BodySchema.safeParse(await req.json());
if (!parsed.success) return Response.json({ error: "invalid_client_metadata" }, { status: 400 });
const client = await mcpRepo.registerClient(
parsed.data.client_name ?? "MCP Client",
parsed.data.redirect_uris,
);
return Response.json({
client_id: client.clientId,
redirect_uris: client.redirectUris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
}, { status: 201 });
}The token endpoint is where PKCE actually gets checked, and where the one-time code gets consumed (deleted the moment it's read — replay is structurally impossible, not just discouraged):
export async function POST(req: Request) {
const form = await req.formData();
if (form.get("grant_type") !== "authorization_code") {
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
}
const stored = await mcpRepo.consumeAuthCode(sha256Hex(String(form.get("code"))));
if (!stored) return Response.json({ error: "invalid_grant" }, { status: 400 });
if (!verifyPkceS256(String(form.get("code_verifier")), stored.codeChallenge)) {
return Response.json({ error: "invalid_grant" }, { status: 400 });
}
const token = generateSecret();
await mcpRepo.createToken(sha256Hex(token), {
userId: stored.userId, clientId: stored.clientId,
expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
});
return Response.json({
access_token: token, token_type: "bearer",
expires_in: Math.floor(TOKEN_TTL_MS / 1000), scope: "spendflow",
}, { headers: { "Cache-Control": "no-store" } });
}7. The consent page
This is the human half of the flow, and it's the piece that makes the whole thing feel like "Sign in with Google" instead of a sketchy API-key form. Because it lives at a normal app route, your existing auth middleware already gates it — if the user isn't logged in, they hit your normal login page first, then land back here.
"use server";
export async function decideConnection(_prev: unknown, formData: FormData) {
const userId = await getCurrentUserId();
if (!userId) return { error: "Not signed in" };
const clientId = String(formData.get("clientId"));
const redirectUri = String(formData.get("redirectUri"));
const codeChallenge = String(formData.get("codeChallenge"));
const decision = formData.get("decision");
const client = await mcpRepo.getClient(clientId);
// Redirect URI must be one THIS client registered — never trust a URL
// that arrives only via the query string.
if (!client || !client.redirectUris.includes(redirectUri)) {
return { error: "Unknown client" };
}
if (decision === "deny") {
redirect(`${redirectUri}?error=access_denied`);
}
const code = generateSecret();
await mcpRepo.createAuthCode(sha256Hex(code), { clientId, userId, codeChallenge, redirectUri });
redirect(`${redirectUri}?code=${encodeURIComponent(code)}`);
}Security: Show the real redirect host, not just the client's self-chosen name. A client can register itself with any display name it wants — "Claude", literally anything — since registration is unauthenticated by design (that's how DCR works). If your consent screen only shows that name, an attacker can register a client called "SpendFlow Assistant" pointing at their own server and phish an approval from a logged-in user. Always render the actual redirect_uri host so the user can verify where access is really going:
// modules/mcp/ui/ApproveForm.tsx
const redirectHost = new URL(props.redirectUri).host;
// ...
<p>Access will be sent to: {redirectHost}</p>8. Settings UI + revoke
Give the user a way to see and kill active connections — same pattern as the Telegram bot's connect card, listing active mcp_tokens rows with a delete button:
export async function revokeMcpConnection(id: string) {
const userId = await getCurrentUserId();
if (!userId) return { error: "Not signed in" };
const parsed = z.string().uuid().safeParse(id);
if (!parsed.success) return { error: "Invalid request" };
await mcpRepo.revokeConnection(userId, parsed.data); // ownership-checked delete
return { ok: true };
}9. Rate-limit the write path
This is the part it's easy to forget: once a chat client is holding a long-lived token, nothing stops it (or a leaked token) from calling add_expense in a tight loop. No new infra needed — reuse the createdAt column your transactions table already has:
const WRITE_RATE_LIMIT = 20;
const WRITE_RATE_WINDOW_MS = 60_000;
export async function addTransaction(repos: McpRepos, userId: string, /* ... */) {
const recentWrites = await repos.transactions.countCreatedSince(
userId, new Date(Date.now() - WRITE_RATE_WINDOW_MS),
);
if (recentWrites >= WRITE_RATE_LIMIT) {
return { text: "Too many requests. Try again in a minute.", isError: true };
}
// ...rest of the function
}Tip: Returning a controlled error text (not throwing, not a bare 500) means the model reads "too many requests" and can tell the user to slow down, instead of just failing mysteriously.
10. Connect from Claude & ChatGPT
- Deploy. Sanity-check discovery:
curl https://yourapp.com/.well-known/oauth-authorization-server - Claude: Settings → Connectors → Add custom connector → paste
https://yourapp.com/api/mcp - Click Connect — your browser opens the consent page, already-logged-in user clicks Approve.
- ChatGPT: Settings → Apps & Connectors → enable Developer mode → same URL, OAuth.
- Test with the MCP Inspector before trusting a real client:
npx @modelcontextprotocol/inspector
mcp-handlerinstalled, route responds 401 withWWW-Authenticatewhen unauthenticated- Both
.well-knownroutes return valid discovery JSON - DCR endpoint validates
redirect_uris(https-only, except localhost for dev) - Auth codes and tokens stored as hashes only, single-use codes actually deleted on consume
- Consent page shows the real redirect host, not just the client's self-chosen name
- Write-path rate limit in place
- Settings UI lets the user list + revoke connections
Security lessons (learned the hard way)
Security: Unauthenticated DCR is normal, but it's also your biggest attack surface. By spec, anyone can call /api/oauth/register — that's how Claude/ChatGPT self-register without you doing anything. It also means anyone else can register a client too. Your only real defenses are: validate redirect_uris strictly (https-only), and never let the consent screen hide where access actually goes.
Security: PKCE is not optional, and it must be checked unconditionally. Every token exchange must verify the S256 challenge/verifier pair, regardless of whether redirect_uri was even supplied on the exchange call (some clients omit it — that's normal, don't treat it as suspicious, just don't rely on it as your only binding check).
Security: Clickjacking on the consent page is a real, not theoretical, risk. Someone could iframe your /connect page invisibly and trick a logged-in user into clicking Approve without realizing it. Add X-Frame-Options: DENY / frame-ancestors 'none' scoped to that one route — not app-wide if you have any legitimately iframed auth flow elsewhere (e.g. a Firebase popup/redirect sign-in handler), or you'll break that instead.
Gotcha: Bundler duplication can break instanceof error checks. If you map Prisma errors with something like e instanceof Prisma.PrismaClientKnownRequestError, under some dev bundling setups that class can end up duplicated in the module graph, silently making the check fail — an error you expect to be handled gracefully (e.g. "auth code already used") instead surfaces as a raw 500. Fallback to checking e.name === "PrismaClientKnownRequestError" alongside the instanceof check if you hit this.
Heads up: 30-day tokens with no refresh flow is a deliberate v1 tradeoff, not an oversight — but say so out loud to whoever reviews this. On expiry the client just re-runs the OAuth dance (one click, since the app session persists), so it's low-friction. If you need shorter-lived tokens later, add refresh-token rotation rather than shortening TOKEN_TTL_MS and accepting more re-auth friction.