First, the thing everyone conflates
There are two different MCP conversations happening at once and they use identical words. The first is connectors: plugging Notion, Linear or Figma into your builder so the AI writing your app can read your issues and docs. That's MCP pointed inward, at your workflow.
The second is your app as an MCP server: you expose a handful of named tools, and anyone using ChatGPT, Claude, Cursor or Codex can call them. That's MCP pointed outward, at your customers. This guide is about the second one.
The mental model that helps: an MCP server is a menu you hand to an AI. Each item has a name, a description, a shape of input it accepts, and a note on whether calling it is safe or destructive. The assistant reads the menu and decides what to order.
When it's actually worth building
Good fits are boring and useful. Look something up in your product. Create a record. List the things a user owns. Run a check. Anything a customer would otherwise do by opening your app, clicking four times, and copying a value out.
The constraint that catches people out: an MCP tool call is a synchronous request with a client-side timeout on the assistant's end. If the work takes tens of seconds — video generation, a big scrape, a multi-step model chain, OCR over a large document — the assistant shows the call as stuck or interrupted even when your app finished the job fine.
So don't expose the heavy thing as a tool. Expose the fast parts around it: start the job, list the results, fetch one. Let the long-running work stay inside your app where it belongs.
And be honest about whether you need it at all. If nobody is asking to drive your product from an assistant, an MCP server is a demo, not a feature.
The three things you actually write
This is the part that surprises people: you don't write the HTTP endpoint, the JSON-RPC envelope, the transport, or the OAuth metadata route. Those are generated. You write three things.
One file per tool. A server entry that imports the tools and gives the server a name, a version and a line of instructions. One line in the build config that turns the whole thing into a live endpoint. That's the shape of it.
A tool file is small enough to read in one screen — a name, a title, a description, a validated input schema, behaviour hints, and a handler that returns content:
The handler is ordinary application code. It can query your database, call your own logic, hit a third-party API. The only rules are: return quickly, validate the input, and throw a proper tool error when something the caller did was wrong, so the assistant can explain the failure instead of shrugging.
export default defineTool({
name: "get_order",
title: "Get order",
description: "Look up one order by its reference for the signed-in customer.",
inputSchema: { reference: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false },
handler: async ({ reference }, ctx) => {
// ctx carries the verified caller — never trust an id from the input
const order = await findOrder(ctx.getUserId(), reference);
return { content: [{ type: "text", text: JSON.stringify(order) }] };
},
});The auth decision, made properly
This is the only genuinely consequential choice in the whole build, and it takes about ten seconds to get wrong.
Public means no login. Anyone on the internet who has the URL can call every tool and read everything those tools return. That is fine for a currency converter or a docs search. It is catastrophic for anything keyed to a user account.
OAuth means the caller signs in as a real user of your app, and every query runs as that user, with your row-level security intact. If a tool touches customer data, this is the only correct answer. It's more setup — a consent screen, a verified token, tools that derive the user from the token rather than the input — but it's the difference between a feature and a breach.
Two rules we hold without negotiation. Never take a user id from tool input; derive it from the verified token. And never put a service-role key behind an unauthenticated endpoint — that hands your entire database to every caller who finds the URL. If a public tool comes back empty because your security policies block it, that's not a bug to work around. That's the data telling you it isn't public.
Tool descriptions are the product
Nobody believes this until they watch an assistant pick the wrong tool three times in a row. The model chooses what to call based on the name, the title, the description and the hints. That metadata is the interface, in the same way button labels are the interface of a UI.
Write descriptions as one clear sentence about what the tool does and when to use it. Say what it returns. Mark read-only tools as read-only and destructive tools as destructive — assistants treat those hints as permission to act without asking, or not.
Fewer, sharper tools beat a long list of overlapping ones. If two tools could plausibly answer the same request, the model will flip a coin, and your users will experience that as your product being unreliable.
The mistakes we keep fixing
Reading environment variables at the top of the file. The server entry gets evaluated at build time and again on cold start, before secrets exist. Read them inside the handler, where the request is.
Long-running handlers, covered above. The single most common cause of 'my MCP server doesn't work' when the server is fine.
Vague descriptions. 'Handles data' tells the model nothing, so it either never calls the tool or calls it constantly.
Skipping OAuth because it looked like more work, on an app with user accounts. This is the one that ends up in an incident write-up.
Forgetting the boring parts: a favicon, a real server name and title, an instructions line. Those show up verbatim in every connector list your customers see. A server called my-app-mcp with no icon looks abandoned before anyone calls a tool.
How we'd approach your first one
Pick three tools, not fifteen. One read that answers the question your support inbox gets most, one list, one write that's genuinely useful and clearly scoped.
Wire OAuth from the start if there's any user data involved. Retrofitting auth onto a public server is more work than building it right, and in between you're exposed.
Then connect it to Claude or ChatGPT yourself and use it like a customer would for an hour. You'll rewrite half your tool descriptions, and that hour is the difference between a server that demos and a server that people keep connected.