How I Built the ShareMyPage MCP (and How I Measured Its UX) · ShareMyPage<br>English<br>Sign inGet started
The code that makes this work is not the hard part. The hard part was the experience. An MCP server has no screen, no button, no hover state, no red versus grey. Every decision that would normally be a UI decision has to be made instead in a tool's name, its input schema, or the single sentence it hands back. That is the whole thing, out in the open: every decision that shapes what an agent actually experiences using it. Then a harder question showed up after I shipped it. How do you know any of that is working, when nothing about the experience ever appears on a screen for you to watch? Both halves live here, including the moment a routine analytics hookup nearly started leaking real page content out of the server before I caught it.
A tool is the entire interface
An MCP exposes a set of tools to an AI client. Each tool has a name, a schema for its arguments, and a function that runs on your server. Claude connects, reads what is available, and calls a tool on the user's behalf. That much is mechanism.
The design problem sits one layer up. Everything a person will ever perceive about this server comes from three surfaces: a tool's name and description, the shape of its schema, and the sentence it returns. Does it read as careful or careless? Fast or fumbling? Trustworthy or reckless? All three answers live in those same three places. There is no fourth surface. No layout, no icon, no color, no spacing. Get those three things wrong and the experience is wrong. No amount of correct backend logic fixes that afterward.
The request path itself is not specific to this server. It is the same shape for any MCP: a person asks in plain language, Claude picks a tool, the connector carries the call over HTTP with authentication attached, the server runs the tool, and it reaches the actual data. What varies from one MCP to the next is entirely in how well those three surfaces are designed.
The request path: a person asks in plain language, Claude picks a tool, the connector carries the call with auth attached, the MCP server runs it, and it reaches the actual data.<br>Twelve tools, one route handler file, reachable two ways (an OAuth connection or a scoped token), and no extra servers to run.
The whole server is one route handler
There is no separate service behind this. The MCP lives at a single Next.js route, the same app that already serves the product. A helper library, mcp-handler, does the protocol plumbing. You register tools, it speaks MCP. Keeping the surface this small also keeps it something I can hold in my head as one coherent set of twelve decisions, not twelve unrelated ones.
// app/api/[transport]/route.ts<br>// transport = "mcp", so the endpoint is /api/mcp<br>import { createMcpHandler, withMcpAuth } from "mcp-handler";
const handler = createMcpHandler((server) => {<br>server.registerTool("status", { /* ... */ }, statusFn);<br>server.registerTool("create_page", { /* ... */ }, createFn);<br>// ...ten more tools<br>}, { serverInfo: { name: "sharepage", version: "1.0.0" } });
// Every request is checked before any tool runs.<br>const authed = withMcpAuth(handler, verifyBearer);<br>export { authed as GET, authed as POST };
Where this is needed. Any HTTP host that can run a serverless function works. This one runs on Vercel Fluid Compute, so there is no long lived server to babysit. The connector calls the URL, the function wakes, runs a tool, returns.
The schema is the form the AI fills out
Write it clearly and Claude calls create_page with the right shape on the first attempt. Write it vaguely and every call turns into a small negotiation the person waiting on the other end has to sit through: a guess, a correction, a retry. That difference is entirely design work, done in field names and descriptions instead of labels and placeholder text.
The annotations do a second, quieter job. destructiveHint and readOnlyHint are the tool-calling equivalent of a red delete button versus a plain grey one. They tell the client how much friction an action deserves before it just does the thing versus stopping to confirm first.
server.registerTool(<br>"create_page",<br>description: "Create a page from HTML. Returns the shareable URL.",<br>inputSchema: {<br>html: z.string(),<br>title: z.string().optional(),<br>visibility: z.enum(["workspace", "private", "public"]).optional(),<br>},<br>annotations: { readOnlyHint: false, destructiveHint: false },<br>},<br>async (args, { authInfo }) => {<br>const ctx = await ctxFrom(authInfo?.extra); // who, and which workspace<br>const { url } = await createPage(ctx, args);<br>return text(`Created. Shareable URL: ${url}`); // prose, not raw JSON<br>},<br>);
The reply is the interface. A tool has no screen to render a result on, so its return value has to carry both the data and the message. "Created. Shareable URL: ..." is something Claude can read once and repeat back to the person it is helping, verbatim. A raw JSON object, or worse an...