# I built an MCP server and had to design UX with no screen

> Source: <https://sharemypage.app/blog/how-i-built-the-sharemypage-mcp-and-measured-its-ux>
> Published: 2026-07-08 07:58:05+00:00

July 3, 2026 · 15 min read

# How I Built the ShareMyPage MCP (and How I Measured Its UX)

I built an MCP server that lets Claude create, edit, and comment on real hosted pages. There is no screen, so every UX decision lives in a tool schema, an error message, or a single line of returned text, and measuring whether any of it actually works needed the same kind of design. Here is both halves of that story.

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.

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.

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.

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 unstructured 500, forces the model to interpret and reword it. That interpretation step is exactly where confident sounding nonsense creeps in.

## Two doors, one scoped identity

People reach the MCP two ways. Inside Claude, an **OAuth** connection identifies the user, and their workspace gets resolved from that identity. From a script, a **scoped token**, an `sp_live_`

secret, carries its own workspace. Both funnel through one function that produces a single trusted context.

This is the single most important line in the whole server. A caller never passes "which workspace." It is derived from the credential. That one rule means a leaked or malicious token still cannot reach across into someone else's data. It fails closed.

None of this is ever visible to the person using the tool. That is exactly the point. The parts of an experience that matter most are often the ones nobody ever sees. Get them wrong and the result is not a rough edge. It is an incident.

The scoped token itself is never stored in plaintext either. It carries about 240 bits of entropy, nothing close to brute-forceable, so a fast SHA-256 hash is the right call here, not a slow password hash meant for guessable secrets. Only that hash is ever stored. The plaintext is shown exactly once, at creation.

## Twelve tools, grouped by what a person is trying to do

This is information architecture, not database design. If the tools mirrored the schema, there would be one for pages, one for folders, one for comments. Both the AI and anyone reading the tool list would then have to reconstruct the actual workflow from the data model themselves. Instead the twelve tools are grouped by intent: look at what is there, make something, fix one thing without redoing it, and talk it through with a teammate.

**Look around**

| Tool | What it does |
|---|---|
`status` | who am I connected as, and which workspace |
`list_pages` | everything in the workspace |
`list_folders` | the folder structure |
`get_page` | one page's current HTML |

**Make and change**

| Tool | What it does |
|---|---|
`create_page` | a new page from HTML |
`update_page` | replace the whole document |
`delete_page` | remove a page, marked destructive |

**Edit in place**

| Tool | What it does |
|---|---|
`patch_page` | change one exact span, leave the rest untouched |

**Collaborate**

| Tool | What it does |
|---|---|
`list_comments` | read comments and their anchors |
`comment_on_page` | post a note or reply to a thread |
`resolve_comment` | close feedback once it is handled |
`send_feedback` | tell the ShareMyPage team something is confusing, broken, or missing |

## The decision with the most obvious effect on how this feels to use

Early on, every edit meant the AI had to hold the entire page in its context, change a few words, and send all of it back. On a large page, that pause is long enough that a person waiting on the other end notices it. It also burns through a long chat's context window fast enough to end the session early. Both of those are UX problems dressed up as engineering ones. `patch_page`

exists because of that: the agent sends a tiny find and replace, and the document never leaves the server.

Same one word edit on a 38 KB page: `update_page`

resends 38,400 characters. `patch_page`

sends 41 characters. By reference, not by value. The difference is felt directly, as how long someone waits for a reply.

Where this is needed.Any MCP that edits large text or files. The speed is the obvious win here. The guard is the one that protects trust. An AI editing by find and replace will eventually hit an ambiguous match. The right response at that moment is a clear refusal, not a page quietly mangled somewhere the person only discovers later.

## A comment becomes an instruction the agent can target

A comment is someone's intent, written once, in their own words. Turning it into a `find`

/`replace`

call is a translation the person leaving feedback never has to think about. They write "make this final." The agent locates the exact span the anchor points at, then closes the loop by resolving the thread. That handoff, from a loose human note to a precise, verifiable edit, is the entire value of exposing the anchor at all.

## The reply is the only UI this server has

None of what follows shows up in a screenshot, because there is no screen. It shows up entirely in what each tool says back. That is the one place a carefully built MCP and a merely functional one are impossible to tell apart, looking at the schema alone.

``` php
status          -> Connected as Henning · workspace "NewStore" · plan pro · 42 page(s).
create_page     -> Created. Shareable URL: https://sharemypage.app/p/gckcg27xwxwntn
comment_on_page -> Comment posted.
(no token)      -> 401 Unauthorized
(rate limited)  -> Rate limit exceeded for this token. Slow down a moment.
```

**A cheap "who am I."** The `status`

tool does nothing but confirm the connection. It is the first thing a person runs, and it removes all doubt about which workspace and plan they are on before they act. That is the same job a connection indicator does in any ordinary app, just spoken instead of shown.

**Everything is reversible.** Every edit writes a new version and keeps the old one. That is why `update_page`

and `patch_page`

are marked not destructive: nothing is ever truly overwritten. That is what lets an agent act with less hesitation, and a person undo their way out of a bad edit instead of asking for one up front.

## One honest gotcha

The client asks "what tools do you have" once, when it connects, and remembers the answer. So shipping a new tool does not reach an already connected client, and a fresh chat will not pick it up on its own. A serverless server also cannot push the update, because there is no open connection to push through.

The fix is one click.In the Claude connector settings there is a "Refresh tools list" action. It re-fetches the tools without a full disconnect. Worth knowing before you spend an afternoon wondering why your new tool is invisible. Behavior changes inside an existing tool need no refresh at all.

## Measuring an interface with no screen to watch

A normal product's UX shows up in things you can watch happen: a rage click, an abandoned form, a support ticket. None of that exists here. An agent either succeeds or fails silently, and a person only ever sees whatever the model decided to say about it. Answering "is this actually good" needed its own design, on the server, same as everything else in this piece.

Two decisions, both server driven, since nothing about a chat carries over to the next one.

**Track every call, but only what is safe to leave the building.** PostHog's own MCP analytics SDK wraps the server and captures every tool call, discovery request, and initialize handshake automatically: tool name, duration, whether it errored. Its default capture also includes the raw arguments and raw response of every call, and several of these tools carry real page content there. `get_page`

returns a full document. `comment_on_page`

's body is someone's actual comment. Before any of that leaves the process, a hook strips both fields unconditionally.

That is a real answer to "where does this actually hurt": which tool nobody calls, which one errors more than the rest, whether a session ends right after a failure.

**Ask, but only once real usage earns it, and only through a reply that was already happening.** A rating prompt on every connection is exactly the friction this whole design has tried to avoid. So there is a `send_feedback`

tool, gated in its own description: only call this once the person agrees, summarize what happened instead of pasting page content. But a chat has no memory of the last one, so the invitation to use it cannot live in a system prompt either. It has to come from the server itself. The threshold: twenty tool calls, then a quiet thirty days. Cross it, and `status`

's own reply grows one extra line.

Not a popup. Not a forced choice. A sentence appended to a reply that was already going to be read.

## If you build your own

None of this is protocol specific. It is what building an MCP that people actually trust to touch their work ends up teaching, and most of the lessons are design lessons that happen to be enforced in code.

**There is no screen.** Every decision you would normally make in a mockup, you now make in a tool description, a schema field, or a returned string. Give that the same seriousness.**The schema is the form.** Write it so the AI can fill it out correctly without a clarifying question first.**Annotate intent.**`readOnlyHint`

and`destructiveHint`

are your confirmation dialogs. Use them instead of hoping the model infers the stakes from context.**Scope the principal, never the request.** Derive the workspace from the credential, not from an argument the caller controls. Fail closed.**The reply is the whole interface.** Write it like someone is going to read it out loud to a person, because someone will.**Respect the model's context.** Prefer a small find and replace over resending a whole document. Context is the scarce resource, and the person waiting feels every extra second of it.**Make everything reversible.** Undo is a design decision before it is an engineering one. It is what lets an agent act with confidence instead of asking permission for everything.**Give people a cheap "who am I."** A read only status tool removes doubt before anyone makes a change. Confidence beats guessing.**Remember discovery is cached.** New tools need a refresh, not faith. Tell your users where that button is.**Redact by default, not by allowlist.** If any tool can carry real content in its arguments or response, strip those fields unconditionally before they leave the process. A blanket rule cannot miss a field the way a per-tool list can.**Earn the ask, then let the server do the asking.** A chat has no memory of the last one, so a feedback prompt that depends on the client remembering it will not survive to the next session. Track real usage server side and surface the invitation in a reply that was already happening.

It is built with `mcp-handler`

on Vercel Fluid Compute, the Next.js App Router, Drizzle and Neon Postgres, Vercel Blob for the HTML bodies, and Clerk for auth. Want to see it live? [Connect it in Claude](https://sharemypage.app/mcp?utm_source=blog&utm_medium=content&utm_campaign=how-i-built-the-sharemypage-mcp), try `status`

a few times, and watch for the line at the bottom of the reply.
