Skip to content
A painted bedroom at night, a laptop glowing on the desk by the window and a cat asleep beside it

Chapter two: AI, MCP and code

A remote MCP server example: OAuth, DCR and 11 tools

How Posy's hosted MCP server works: Streamable HTTP in a Next.js route, OAuth with dynamic client registration, 11 annotated tools and one zod schema for everything.

Himanshu Sangshetti · 6 min read
The Cobalt look on a desktop screen, a two-ink printed link-in-bio page

A remote MCP server is an HTTP endpoint that AI apps connect to over the network, sign in to with OAuth, and call tools on. Posy's runs at https://posy.page/api/mcp: one Next.js route using Streamable HTTP, OAuth 2.1 with dynamic client registration through Better Auth, and 11 tools that edit a link-in-bio page.

This is a write-up of how it is built, with the real code (shortened), because most MCP examples are local stdio servers and stop before the parts that are hard in production: auth, tool design and keeping several interfaces in sync. Posy is MIT licensed, so you can read the whole thing in the repo.

#The shape of the system

Piece What it does Where it lives
MCP endpoint Streamable HTTP, tool calls src/app/api/mcp/route.ts
Authorization server OAuth, DCR, consent, JWTs Better Auth under /api/auth
Protected resource metadata Tells clients who issues tokens (RFC 9728) /.well-known/oauth-protected-resource
Server metadata OAuth discovery (RFC 8414) /.well-known/oauth-authorization-server
Page operations The actual edits, shared with the REST API src/lib/page-ops.ts

The important design choice is the last row. The MCP tools and the REST API call the same functions, so there is one place where an edit can go wrong.

#Transport: one route, Streamable HTTP

Streamable HTTP is the MCP transport for remote servers: the client POSTs JSON-RPC to one URL and the server answers with JSON or a stream. Posy uses Vercel's mcp-handler package, which wraps the official SDK and turns a tool registry into a route handler.

const handler = createMcpHandler(server => {
  server.registerTool('get_page', { title: 'Get page', inputSchema: z.object({}), annotations: READ },
    run(async userId => getState(userId)));
  // ... 10 more tools
}, { serverInfo: SERVER_INFO, instructions: INSTRUCTIONS });

The instructions string is worth more than it looks. It tells the model the flow ("call get_page first, then add tiles, then publish") and the house rules ("ask before deleting things the user added"). Clients show it to the model at connect time, so the model does not have to discover the workflow by trial and error.

#Auth: two kinds of bearer on one endpoint

The route accepts two tokens. API keys (posy_...) are for scripts. OAuth access tokens (JWTs) are for apps like Claude, ChatGPT and Cursor.

const verify = async (_req: Request, token?: string) => {
  if (token?.startsWith('posy_')) return verifyApiKey(token);
  const jwks = createLocalJWKSet(await auth.api.getJwks());
  const { payload } = await jwtVerify(token!, jwks, { issuer: baseURL, audience: RESOURCE() });
  return { token, scopes: String(payload.scope).split(' '), extra: { userId: payload.sub } };
};
export const POST = withMcpAuth(handler, verify, { required: true, requiredScopes: ['page:write'] });

Two details matter. The JWT is checked against the audience https://posy.page/api/mcp, so a token minted for some other resource is refused. And withMcpAuth answers unauthenticated requests with a 401 that points at the protected resource metadata, which is how a client finds the authorization server in the first place.

#OAuth and dynamic client registration

An MCP client connecting for the first time has no client ID. Dynamic client registration (RFC 7591) lets it register itself, then run a normal authorization code flow with PKCE. Better Auth's mcp plugin does all of this.

mcp({
  loginPage: '/login',
  consentPage: '/oauth/consent',
  resource: `${siteUrl()}/api/mcp`,
  scopes: ['openid', 'profile', 'email', 'offline_access', 'page:write'],
  allowDynamicClientRegistration: true,
  allowUnauthenticatedClientRegistration: true,
  grantTypes: ['authorization_code', 'refresh_token'],
}),

The user's experience is: click connect in Claude, land on the normal Posy login, see a consent screen naming the app, and go back. offline_access gives the client a refresh token so the connection survives past the access token's expiry.

Posy also enables client ID metadata documents (the cimd plugin), where a client's ID is a URL that hosts its own metadata, so no registration call is needed at all. Supporting both means older and newer clients both connect.

The two .well-known routes are a few lines each. The protected resource one is the whole of RFC 9728 for this server:

export async function GET(req: Request) {
  const { baseURL } = await auth.$context;
  return protectedResourceHandler({ authServerUrls: [baseURL], resourceUrl: `${siteUrl()}/api/mcp` })(req);
}

#Tool design: 11 tools, shaped like the editor

The tools are get_page, create_page, import_page, add_tile, update_tile, remove_tile, move_tile, set_theme, set_profile, publish and list_options. A few rules shaped them.

Small and nameable. Each tool maps onto something a person does in the editor. Models pick tools by name and description, and "add_tile" is hard to misuse.

Batch where people batch. People paste a list of links. So add_tile takes inputs, an array of up to 30 strings, and Posy detects what each one is. One call instead of 30 round trips.

Let the model ask instead of guess. list_options returns the allowed looks, palettes, fonts and sizes. Without it, models invent theme names that do not exist.

Return the next step. Responses include a next hint, such as "There are unpublished changes. Call publish when the user is happy." It keeps long conversations on track.

Errors as content. A failed call returns isError: true with a readable message, so the model can recover rather than stall.

#Annotations: read-only, destructive, idempotent

MCP tool annotations are hints clients use to decide when to ask the user for confirmation. Posy defines five sets and gives every tool one.

const READ    = { readOnlyHint: true,  destructiveHint: false, idempotentHint: true,  openWorldHint: false };
const WRITE   = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
const SET     = { readOnlyHint: false, destructiveHint: false, idempotentHint: true,  openWorldHint: false };
const DESTROY = { readOnlyHint: false, destructiveHint: true,  idempotentHint: true,  openWorldHint: false };
const PUBLISH = { readOnlyHint: false, destructiveHint: false, idempotentHint: true,  openWorldHint: true };

get_page and list_options are READ. add_tile is WRITE because calling it twice adds two tiles. set_theme is SET because setting the same theme twice changes nothing. remove_tile and import_page (with replace) are destructive. publish is the interesting one: it only touches the user's own page, but it makes content public, so it is marked open-world.

The other safety net is the draft. Every tool edits the draft, and edits are version-checked, so an AI and a person editing at once cannot silently overwrite each other.

The Night look on a desktop
A page edited over MCP, in the Night look.

#One zod schema, three interfaces

The page format is a zod schema in src/lib/schema.ts. Everything else derives from it.

  • JSON Schema. The CLI writes z.toJSONSchema(Page) to /schema/page.schema.json, and the brief format is served the same way from /schema/brief.schema.json.
  • OpenAPI. The REST description at /openapi.json references that JSON Schema for the page body, so the API docs and the file format cannot drift.
  • MCP inputs. Tool input schemas reuse the same enums (SIZES, FONTS, LAYOUTS, STYLE_IDS), so a look that does not exist is rejected by the schema before any code runs.
const schema = { $id: `${siteUrl()}/schema/brief.schema.json`, ...z.toJSONSchema(Brief, { io: 'input' }) };

Add a look, and the MCP input, the JSON Schema and the API docs all pick it up on the next build.

#Publishing to the MCP registry

A server.json in the repo describes the server for the official MCP registry: name io.github.Himanshu-Sangshetti/posy, one remote of type streamable-http. A GitHub Action runs mcp-publisher on each release and logs in with GitHub OIDC, so no secret is stored.

#Where Posy is not the right reference

  • You need a stdio server. Posy is remote only. Local servers have a simpler auth story.
  • You are not on Next.js or Better Auth. The ideas carry over, but the snippets will not.
  • You need fine-grained scopes. Posy has one write scope, page:write, because a user only has one page. A multi-resource product needs more.

#FAQ

#What is a remote MCP server?

An MCP server reached over HTTP instead of run as a local process. Clients connect with a URL, authenticate with OAuth and call its tools over Streamable HTTP.

#Do I need dynamic client registration?

If you want any MCP client to connect without you creating a client ID for it first, yes, or client ID metadata documents. Posy supports both.

#How do clients find the authorization server?

The 401 response points to /.well-known/oauth-protected-resource, which names the authorization server. The client then reads its metadata and registers.

#Can I try Posy's server?

Yes. Add https://posy.page/api/mcp as a connector in Claude, ChatGPT, Cursor or VS Code. The connect guide and tool reference have the details, and link in bio for developers shows it in a workflow.