Build a Remote MCP Server on the Next.js App Router
Prepublish serves a remote MCP server at https://prepublish.ai/mcp from one Next.js App Router route handler and no server-side SDK. A GET to that URL returned a 1,749-byte JSON server card on 19 September 2026, and the handler, the caps, the bridge and the conformance suite are quoted here from the files that run it.
TL;DR
Prepublish runs a remote MCP server at https://prepublish.ai/mcp from a single Next.js App Router route handler, with no server-side SDK. The route speaks Web Request and Response; lib/mcp/protocol.ts answers five JSON-RPC methods by hand. A GET to that URL returned a 1,749-byte JSON server card on 19 September 2026.
Try it on your own script
Paste your draft below. You get your hook, structure, and pacing scores, a script-level attention-risk map, and the single biggest issue quoted from your own lines. Free, no login.
Free · No login · See a sample audit first if you prefer.
Key Takeaways
- Prepublish serves a remote MCP server at https://prepublish.ai/mcp from one Next.js App Router route handler and no server-side SDK
- The handler speaks Web Request and Response in POST, GET and OPTIONS, and lib/mcp/protocol.ts answers five JSON-RPC methods by hand
- A GET to that URL returned a 1,749-byte JSON server card on 19 September 2026
- Six files make up the surface, 1,274 lines: the route handler, the JSON-RPC core, the tool definitions, the spend guardrails, the stdio bridge and the conformance suite
- The server negotiates protocol revision 2025-11-25 as its newest while the current specification revision is 2026-07-28
- MCP removed JSON-RPC batching in revision 2025-06-18, so this endpoint answers a batched array with error -32600 instead of half-handling it
Key Statistics
- •A GET to https://prepublish.ai/mcp returned HTTP 200 with a 1,749-byte JSON server card on 19 September 2026 (live request against the deployment).
- •The free MCP endpoint caps AI-backed calls at 100 per UTC day across the whole endpoint and 6 per caller per 10 minutes, both defaults read from prepublish-fe/lib/mcp/limits.ts.
- •The server negotiates protocol revision 2025-11-25 as its newest while the current specification revision is 2026-07-28, per prepublish-fe/lib/mcp/protocol.ts and the MCP specification changelog.
- •Its script_runtime tool converts words to runtime with speaking rates of 160, 181 and 201 words per minute measured across 349 videos, stored in prepublish-fe/lib/seo/wpm-data.ts.
- •MCP removed JSON-RPC batching in revision 2025-06-18, so this endpoint answers a batched array with error -32600 instead of half-handling it (specification changelog, re-verified against the deployment on 19 September 2026).
In This Guide
- Build a Remote MCP Server on the Next.js App Router
- Which MCP specification revision does this server implement?
- The request lifecycle, from POST to tool result
- Why hand-roll JSON-RPC instead of using the MCP SDK
- Why a remote MCP server still ships a stdio bridge
- How to rate limit an anonymous public MCP endpoint
- How to test a hand-rolled server against the official SDK client
- What this design does badly
- When an SDK or a dedicated service is the better call
Build a Remote MCP Server on the Next.js App Router
Prepublish serves a remote MCP server at https://prepublish.ai/mcp from one Next.js App Router route handler and no server-side SDK. The handler speaks Web Request and Response in POST, GET and OPTIONS. prepublish-fe/lib/mcp/protocol.ts answers five JSON-RPC methods by hand. A GET to that URL returned a 1,749-byte JSON server card on 19 September 2026.
Six files make up the surface, 1,274 lines: the route handler at prepublish-fe/app/mcp/route.ts (201 lines), the JSON-RPC core (238), the tool definitions (455), the spend guardrails (113), the stdio bridge at prepublish-mcp/src/bridge.ts (52), and the conformance suite at prepublish-mcp/test/conformance.test.ts (174). Every excerpt below comes from one of those files or from a request against the deployment.
Which MCP specification revision does this server implement?
The server implements up to revision 2025-11-25 and answers three older revisions. The tuple sits at the top of protocol.ts:
/** Newest revision this server implements, then older revisions it still answers. */
export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'] as const
export const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
The current specification revision is 2026-07-28, which removed the initialize handshake, removed protocol-level sessions and the Mcp-Session-Id header, and made a resultType field required on every result. A client on that revision treats this server's results as "complete" because they omit the field, which the 2026-07-28 changelog instructs clients to do.
| Revision | Status on this server | What it changed | Source |
|---|---|---|---|
| 2026-07-28 | Not implemented | Removed initialize and sessions, added server/discover, required resultType | Key changes, 2026-07-28 |
| 2025-11-25 | Newest implemented | Last handshake-based revision; tool definitions may carry icons and execution.taskSupport | Tools, 2025-11-25 |
| 2025-06-18 | Answered | Removed JSON-RPC batching; required the MCP-Protocol-Version request header | Key changes, 2025-06-18 |
| 2025-03-26 | Answered | Streamable HTTP replaced the HTTP+SSE transport from 2024-11-05 | Transports, 2025-11-25 |
| 2024-11-05 | Answered | The revision that defined HTTP+SSE, which Streamable HTTP replaced | Transports, 2025-11-25 |
The registry entry for this server does not carry a protocol revision. prepublish-mcp/server.json in full:
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "ai.prepublish/script-audit",
"title": "PrePublish - YouTube script QA",
"description": "Audit a YouTube script before recording: hook, pacing, drop-off risk, copy-paste rewrites.",
"version": "1.0.1",
"websiteUrl": "https://prepublish.ai/mcp-server",
"remotes": [
{
"type": "streamable-http",
"url": "https://mcp.prepublish.ai"
}
]
}
That $schema is the registry's own server schema, dated 2025-12-11, and its definitions declare name, description, version, remotes, packages and repository, with no protocol version field. The revision a deployment speaks is discoverable from protocol.ts or from an initialize response, not from the registry claim. Both URLs answered a GET with HTTP 200 on 19 September 2026.
The request lifecycle, from POST to tool result
A POST carries one JSON-RPC message. The route parses it, derives a caller key, calls handleMessage, writes one log line and returns a JSON body or an empty 202. The handler opens like this:
export async function POST(request: NextRequest): Promise<Response> {
const headers: Record<string, string> = {
'content-type': 'application/json',
'cache-control': 'no-store',
'mcp-protocol-version': negotiatedVersion(request),
...CORS_HEADERS,
}
let message: unknown
try {
message = await request.json()
} catch {
return new Response(
JSON.stringify({ jsonrpc: JSONRPC_VERSION, id: null, error: { code: ERROR_PARSE, message: 'Request body is not valid JSON' } }),
{ status: 400, headers },
)
}
Malformed JSON never reaches the dispatcher. It becomes JSON-RPC error -32700 with HTTP 400, measured live on 19 September 2026. Two module-level exports tell Next how to run the file:
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
runtime = 'nodejs' is not a default. The file header gives the reason: the tools talk to the API over the internal Docker network through API_INTERNAL_URL, and they hold a request open while an audit completes. dynamic = 'force-dynamic' stops Next from caching a handler that must run per request.
Before dispatch the route derives a caller key, and the preference order is the part worth copying:
function callerKey(request: NextRequest, token?: string): string {
// The `mcp:` prefix is load-bearing, not decoration: this string is sent to
// the API as `anonymous_user_id`, so it is the only thing that separates an
// audit started from an assistant from one started on the website.
// `SELECT count(*) FROM analyses WHERE anonymous_user_id LIKE 'mcp:%'`.
if (token) return `mcp:token:${token.slice(0, 16)}`
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
if (forwarded) return `mcp:ip:${forwarded}`
const session = request.headers.get('mcp-session-id')
if (session) return `mcp:session:${session}`
return `mcp:anon:${randomUUID()}`
}
A token identifies one account, and a forwarded client IP identifies one user behind a shared connector. The random fallback limits one request rather than claiming to know who called.
Then the route dispatches and logs:
const token = bearer(request)
const caller = callerKey(request, token)
const started = Date.now()
const response = await handleMessage(message, {
info: SERVER,
tools: TOOLS,
context: { callerKey: caller, token },
})
handleMessage in protocol.ts rejects arrays before anything else, because batching was removed from the protocol in revision 2025-06-18 and half-handling it would be worse than refusing it:
export async function handleMessage(message: unknown, options: HandleOptions): Promise<JsonRpcResponse | undefined> {
if (Array.isArray(message)) {
return {
jsonrpc: JSONRPC_VERSION,
id: null,
error: {
code: ERROR_INVALID_REQUEST,
message:
'JSON-RPC batching is not supported; it was removed in protocol revision 2025-06-18. Send one request per POST.',
},
}
}
A live POST of that array returned code -32600 on 19 September 2026. Notifications leave the dispatcher before any method runs, because answering one would be a protocol violation:
// Notifications carry no id and must never be answered.
if (method.startsWith('notifications/') || id === undefined || id === null) return undefined
const requestId: JsonRpcId = id
The route turns that undefined into the empty 202 the transport requires:
// A notification gets no body. 202 is what the spec expects here.
if (!response) return new Response(null, { status: 202, headers })
return new Response(JSON.stringify(response), { status: 200, headers })
A live notifications/initialized POST returned HTTP 202 with a zero-length body on 19 September 2026, which the transport rules require for an accepted notification.
Version negotiation happens in the initialize case, from the parameters rather than from a header:
case 'initialize': {
const requested = InitializeParams.safeParse(params)
const version = requested.success ? requested.data.protocolVersion : undefined
const negotiated =
version && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(version)
? version
: LATEST_PROTOCOL_VERSION
return {
jsonrpc: JSONRPC_VERSION,
id: requestId,
result: {
protocolVersion: negotiated,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: options.info.name, title: options.info.title, version: options.info.version },
instructions: options.info.instructions,
},
}
}
A measured consequence: an initialize asking for 2026-07-28 returned "protocolVersion": "2025-11-25", and one asking for 2024-11-05 returned 2024-11-05. The route negotiates the response header from the request header while the core negotiates the body from the parameters, so a request sending header 2024-11-05 with no protocolVersion in the body received header 2024-11-05 alongside body 2025-11-25. Clients read the body field, so nothing breaks, but the header is unreliable for debugging.
Tool dispatch ends in a try block, and the failure shape is the design decision most worth copying:
const refusal = options.guard ? await options.guard(tool, options.context) : undefined
if (refusal) {
// A refusal is a tool-level error, not a protocol error: the model
// should read it and tell the user, not retry the transport.
return {
jsonrpc: JSONRPC_VERSION,
id: requestId,
result: { content: [{ type: 'text', text: refusal }], isError: true },
}
}
try {
const result = await tool.run(call.data.arguments ?? {}, options.context)
return { jsonrpc: JSONRPC_VERSION, id: requestId, result }
} catch (error) {
return {
jsonrpc: JSONRPC_VERSION,
id: requestId,
result: {
content: [
{
type: 'text',
text: `Prepublish tool "${tool.name}" failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
},
}
}
A refusal and a thrown exception both arrive as a result with isError: true, not as a JSON-RPC error object. The tool error handling section draws the same line: protocol errors cover malformed requests, tool execution errors carry actionable text the model can act on. An unknown tool name is the exception, returned as protocol error -32602 so a client can distinguish a typo from a refusal. Capabilities the server does not declare, resources/list and prompts/list among them, answer with empty arrays.
The guard hook exists in the interface and this route does not pass one, because each tool bills itself through billable(). One log line is written per call with the method, tool name, client name, latency and whether a bearer token was present. No script text, no email and no IP, because the script is the user's unpublished work.
The six tools the dispatcher can reach, and the backend routes they call:
| Tool | AI-backed | Backend call | Input |
|---|---|---|---|
audit_script | yes | POST /api/analyze, then polls GET /api/analysis/:id | video_title, script_text, optional email |
get_audit | no | GET /api/analysis/:id | analysis_id |
audit_hook | yes | POST /api/tools/hook-analyzer | hook_text, optional niche |
check_authenticity | yes | POST /api/tools/authenticity-check | video_title, script_text |
policy_preflight | yes | POST /api/tools/policy-preflight | script (minimum 200 characters) |
script_runtime | no | none; arithmetic over local data | word_count, script_text or target_minutes |
script_runtime never leaves the process, which is why it consumes no budget. It prices a draft from speaking rates of 160, 181 and 201 words per minute measured across 349 videos, published in prepublish-fe/lib/seo/wpm-data.ts. Every description of an AI-backed tool repeats the constraint the conformance suite asserts is still present: a check of an unrecorded script does not measure or predict published YouTube retention. The web versions of the same checks live at /tools.
Why hand-roll JSON-RPC instead of using the MCP SDK
The rationale is in the first paragraph of protocol.ts, and it is a type mismatch rather than a preference:
/**
* Minimal, spec-faithful MCP server core for a Next route handler.
*
* Why hand-rolled instead of `@modelcontextprotocol/sdk`: the SDK's
* `StreamableHTTPServerTransport` speaks Node's `IncomingMessage`/`ServerResponse`,
* while a Next App Router route handler speaks Web `Request`/`Response`. Shimming
* one onto the other is more code, and more fragile code, than handling the five
* JSON-RPC methods a tools-only server actually needs. Conformance is not assumed:
* the tests drive this module with the official SDK *client* over Streamable HTTP,
* so the wire format is checked against the reference implementation rather than
* against my reading of the spec.
*
* Stateless by design. No session id is issued, so there is nothing to lose when
* the web container restarts and a hosted client may round-robin freely.
*
* JSON-RPC batching is deliberately unsupported: it was removed in protocol
* revision 2025-06-18, so arrays are rejected rather than half-handled.
*/
That claim about the SDK is checkable. prepublish-mcp/package.json pins @modelcontextprotocol/sdk at 1.30.0, and in that package dist/esm/server/streamableHttp.js line 5 describes its Node compatibility as compatibility with Node.js HTTP server (IncomingMessage/ServerResponse), with handleRequest documented as taking a Node IncomingMessage and ServerResponse. The choice is between an adapter and five methods.
| What the hand-rolled core keeps | What it gives up |
|---|---|
One request and response type across the whole handler, Web Request/Response | The SDK's StreamableHTTPServerTransport, so no SSE streaming, no resumability, no session management |
| Five methods to implement, each visible in one switch | Revision tracking that arrives as a dependency update instead of as your own work |
| Statelessness by construction, no session store to operate | Server-initiated requests: sampling, elicitation and roots are out of reach |
| Full control of the text a model reads, including the refusal sentences | SDK conveniences this server does not use: outputSchema validation, pagination cursors, resources and prompts |
| No server SDK in the web app's dependency tree | The SDK's argument validation, which some servers lean on instead of their own |
The cost of the last row in each column falls on the maintainer. A hand-rolled core means reading the specification changelog yourself, and this deployment sits one revision behind the current one.
Why a remote MCP server still ships a stdio bridge
Some clients can only launch a local command, and the bridge exists so those users are not excluded while a remote connector is unavailable to them. The file header states the rule that keeps the two in sync:
/**
* stdio bridge to the hosted PrePublish MCP server.
*
* The tools themselves live in the web app and are served over Streamable HTTP
* at https://prepublish.ai/mcp. That is the single source of truth: this process
* adds no tools of its own, it forwards. It exists for clients that can only
* speak stdio to a local command (Claude Desktop bundles, some editors and
* local agent runtimes), so those users are not left out while a remote
* connector is unavailable to them.
*
* The tool list is fetched from the remote server at startup, so adding a tool
* in the app requires no release here.
*
* Usage:
* npx prepublish-mcp # bridges to https://mcp.prepublish.ai
* PREPUBLISH_MCP_URL=... npx prepublish-mcp # bridges to another deployment
* PREPUBLISH_TOKEN=... npx prepublish-mcp # forwards a bearer token upstream
*/
The code path is short, and the startup fetch is the part that removes the release step:
const remoteUrl = process.env.PREPUBLISH_MCP_URL ?? 'https://mcp.prepublish.ai'
const token = process.env.PREPUBLISH_TOKEN
const upstream = new Client({ name: 'prepublish-stdio-bridge', version: '1.0.0' })
await upstream.connect(
new StreamableHTTPClientTransport(new URL(remoteUrl), {
requestInit: token ? { headers: { authorization: `Bearer ${token}` } } : undefined,
}),
)
const { tools } = await upstream.listTools()
const local = new Server(
{ name: 'prepublish', version: '1.0.0' },
{ capabilities: { tools: { listChanged: false } } },
)
local.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }))
local.setRequestHandler(CallToolRequestSchema, async (request) =>
upstream.callTool({ name: request.params.name, arguments: request.params.arguments ?? {} }),
)
One statement comes between the handler registration and the connect call: a process.stderr.write banner naming the upstream URL, the tool count and whether a bearer token is set. That choice is required, not stylistic: the stdio transport forbids writing anything to stdout that is not a valid MCP message, and a banner there would corrupt the first frame.
The transport starts on the last statement, await local.connect(new StdioServerTransport()). The package entry point is dist/bridge.js, the module type is esm and the engine floor is Node 20, which allows top-level await in a file a client launches with npx. The limitation is that the bridge can only forward: it cannot expose a tool the remote does not have, it cannot run offline, and it adds the SDK as a dependency of the npm package. prepublish-mcp/test/bridge.test.ts spawns the built bridge as a child process and asserts two properties. The tool list it serves is identical to the hosted list, so nothing is added or hidden. A forwarded call comes back with a value computed upstream.
How to rate limit an anonymous public MCP endpoint
A public endpoint that calls a paid model needs a spend ceiling, and a second limit for a subtler reason. The header of limits.ts states both:
* - a **global daily cap**, because every AI-backed tool call costs real money
* and the endpoint is reachable by anyone on the internet;
* - a **per-caller burst limit**, because the backend's own rate limiter keys on
* client IP and every MCP call reaches it from the web container's single IP,
* which would otherwise put every user of this server in one shared bucket.
The per-caller limit exists because of a proxy hop: every MCP call reaches the backend from the web container's single IP, so one assistant hammering the API would exhaust the shared allowance. The defaults are constants at the top of the file:
const DEFAULT_DAILY_CAP = 100
const DEFAULT_CALLER_LIMIT = 6
const CALLER_WINDOW_MS = 10 * 60 * 1000
Both are overridable by environment variable, and consumeBudget is the whole guard:
export function consumeBudget(callerKey: string, now = Date.now()): LimitDecision {
const today = utcDayKey(now)
if (today !== dayKey) {
dayKey = today
dayCount = 0
callers.clear()
}
const cap = dailyCap()
if (dayCount >= cap) {
return {
allowed: false,
reason:
`Prepublish's free MCP endpoint has reached its daily limit of ${cap} AI-backed calls (it resets at 00:00 UTC). ` +
'Tell the user they can run the same checks now at https://prepublish.ai/upload, and do not retry this tool today.',
}
}
const window = callers.get(callerKey)
const limit = callerLimit()
if (window && window.resetAt > now) {
if (window.count >= limit) {
const minutes = Math.max(1, Math.ceil((window.resetAt - now) / 60_000))
return {
allowed: false,
reason:
`This connection has used its ${limit} free Prepublish AI checks for now; the allowance resets in about ${minutes} minute(s). ` +
'Tell the user, offer to continue at https://prepublish.ai/upload, and do not retry.',
}
}
window.count += 1
} else {
callers.set(callerKey, { count: 1, resetAt: now + CALLER_WINDOW_MS })
}
dayCount += 1
return { allowed: true }
}
| Limit | Default | Variable | Keyed by | What the caller receives |
|---|---|---|---|---|
| Global daily cap | 100 calls per UTC day | MCP_DAILY_CALL_CAP | the endpoint as a whole | tool result, isError: true, reset time named |
| Per-caller burst | 6 calls per 10 minutes | MCP_CALLER_CALL_LIMIT | bearer token, else x-forwarded-for, else mcp-session-id, else a random key | tool result, isError: true, minutes remaining named |
The refusal is not an HTTP status. It is the same tool-level error shape as a backend failure, with HTTP 200 around it, and the reason string is written as an instruction to the model. A 429 invites a client to retry the transport, while an isError result carrying "do not retry" tells the model to change course.
Budget is consumed before the paid call rather than after it, so a refused request costs nothing:
/** Every AI-backed tool consumes budget first, so a refusal costs nothing. */
async function billable(tool: string, context: ToolContext, run: () => Promise<ToolResult>): Promise<ToolResult> {
const decision = consumeBudget(context.callerKey)
if (!decision.allowed) return fail([decision.reason ?? 'This call was refused by Prepublish rate limiting.'])
return run()
}
The counters live in process memory, and the file says why: there is one web container, the numbers are small, and the failure mode of a restart is a reset counter rather than a wrongly blocked user. The same paragraph names the exit: move both counters to Valkey if the app ever runs replicas. budgetSnapshot() reads the counters for the server card, and the live card reported "ai_backed_calls_per_day": 100 with "used_today": 0 on 19 September 2026. A resetBudgetForTests() export exists because module-level counters are process state and a suite that cannot reset them will fail depending on order.
The weak point is the random fallback key. When a caller presents no token, no forwarded IP and no session header, each request mints mcp:anon:<uuid>, so the per-caller window never fills and the daily cap is the only bound. The deployment answers through Cloudflare, so the forwarded header is present. A server behind no trusted proxy should reject the anonymous case instead.
How to test a hand-rolled server against the official SDK client
A conformance suite written with hand-made JSON tests your reading of the specification, which is the thing most likely to be wrong. This suite uses the official SDK as the client, so the wire format is judged by the reference implementation. The harness is seven lines:
const MCP_URL = process.env.MCP_URL ?? 'http://127.0.0.1:4910/mcp'
async function connect(): Promise<Client> {
const client = new Client({ name: 'prepublish-conformance', version: '1.0.0' })
await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL)))
return client
}
Handshake, identity and the instructions text are asserted first, which catches a server that works but describes itself as something else:
test('the official SDK client completes the handshake and reports server identity', async () => {
const client = await connect()
const info = client.getServerVersion()
assert.equal(info?.name, 'prepublish')
assert.ok(client.getServerCapabilities()?.tools, 'server must advertise the tools capability')
assert.match(client.getInstructions() ?? '', /never present a score as a prediction of published retention/)
await client.close()
})
The protocol edge cases the SDK would hide are tested with raw fetch:
const response = await fetch(MCP_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify([{ jsonrpc: '2.0', id: 1, method: 'tools/list' }]),
})
const body = await response.json()
assert.equal(body.error.code, -32600)
assert.match(body.error.message, /batching is not supported/)
| Test | What it pins down |
|---|---|
| Handshake and identity | server name, tools capability, the retention sentence surviving in the instructions |
| Tool discovery | the exact sorted list of six names, a description longer than 120 characters, an object inputSchema with properties |
| Required fields | audit_script requires exactly script_text and video_title; the local tool must not claim retention prediction |
| Both conversion directions | 1,810 words becomes a 10 minute median, 10 minutes becomes 1,810 words, rates are 201/181/160 |
| Empty arguments | isError: true with the sentence naming the three accepted inputs |
| Unknown tool | the SDK client rejects the call, so a typo is distinguishable from a refusal |
| Bare GET | server card has the name, streamable-http, a protocol list containing 2025-06-18, and six tools |
| Batching | error -32600 with the reason |
| Notification | HTTP 202 and an empty body |
| Older revision | requesting 2024-11-05 returns 2024-11-05 rather than the newest |
| Unsupported revision | requesting 1999-01-01 falls back to 2025-11-25 |
| One AI-backed call | skipped unless MCP_TEST_BILLABLE=1, because a real call costs money and writes a row |
The default run touches nothing billable, which makes it usable against production. MCP_URL=https://prepublish.ai/mcp npm run test:conformance verifies a deployment from a laptop, and the three protocol behaviours above were re-checked against the live endpoint on 19 September 2026. Nothing requiring a second process is covered: the suite says nothing about restart behaviour or concurrent load. The bridge test fills part of that gap by spawning the built bridge as a child process.
What this design does badly
Three of these are deviations from the specification text and one is an inconsistency inside the router. All four were measured against the deployment on 19 September 2026.
- Origin validation is absent. The transport section says servers MUST validate the
Originheader and MUST answer HTTP 403 when it is present and invalid. A POST carryingOrigin: https://evil.examplereturned HTTP 200 withaccess-control-allow-origin: *. The endpoint holds no cookies and no session, which is a real mitigation, but it is not what the text requires. - GET returns a server card instead of 405. The same section says a GET must either open an SSE stream or return 405. This route returns 200 with the JSON card, which is friendlier to humans and crawlers and wrong for a client that uses the 405 to detect that no stream exists.
- An unsupported protocol version header is not rejected. The text says a server MUST answer 400 for an invalid
MCP-Protocol-Version. A request sending1999-01-01returned HTTP 200 with a normal result, because the route negotiates down instead of failing. - Two negotiators can disagree. The response header and the initialize body select a version from different inputs, so one response carried header
2024-11-05and body2025-11-25on 19 September 2026. Clients read the body, so this is a reporting inconsistency rather than a breakage.
The operational limits are separate from the protocol ones.
- The per-caller window is ineffective without a token or a forwarded IP.
- Counters are process state, so a restart resets the daily count and a second replica would double the effective cap. The file names Valkey as the exit.
- There is no OAuth and no per-user API key. A bearer token raises limits, and authenticated use means an existing magic-link token.
- No SSE stream means no progress notifications, no elicitation, no sampling and no roots. A long audit returns one JSON body, or a handle to poll.
- Tool output is a text block containing JSON, with no
structuredContentand nooutputSchema, so a client cannot validate a result against a schema it fetched. - Supporting
2026-07-28would mean addingserver/discover, reading_meta, emittingMcp-MethodandMcp-Name, and addingresultTypeto every result.
None of these stop a tools-only server from working, and all of them are the kind of thing an SDK handles for you.
When an SDK or a dedicated service is the better call
The hand-rolled route earned its place because the surface is small, the deployment is a single container and the handler needed Web types. Those conditions are narrow.
| Situation | Better choice |
|---|---|
| You need sampling, elicitation, roots or progress notifications | SDK server transport, since those are server-initiated messages this route cannot send |
| You want revision tracking to arrive as a dependency update | SDK, whose StreamableHTTPServerTransport is named in protocol.ts as the reason for this whole decision |
| You need OAuth 2.1, per-user identity or verified directory status | SDK plus an authorization server, or a hosted MCP platform that already operates one |
| You run more than one replica | External counters in Valkey or Redis, with either server approach |
| You serve a handful of tools from an existing app and want one deploy | A route handler like this one, with the caps and the conformance suite it ships |
| The client is a desktop app with local files | A stdio server, with no HTTP endpoint at all |
| You want argument validation and structured output without writing it | SDK, and return structuredContent rather than a JSON string inside a text block |
The test is what the server has to send back. If every answer is one response to one request, the switch in protocol.ts is enough. If the server needs to speak first, keep a stream open or authenticate a specific person, the SDK is the shorter path. Prepublish documents its own deployment at /mcp-server, including the free-tier numbers the card reports.
Frequently asked questions
How do you build a remote MCP server with Next.js App Router?
Export a POST handler from app/mcp/route.ts. Parse the body with await request.json(), switch on the method string, and return a JSON-RPC object with HTTP 200, or a zero-length body with HTTP 202 for notifications. Export runtime = 'nodejs' if tools reach an internal API or hold a request open, and dynamic = 'force-dynamic' so the handler is never cached. GET and OPTIONS are separate exports on the same path.
Do you need the official MCP SDK to build an MCP server?
No. A tools-only server needs a small set of methods, in this build initialize, ping, tools/list and tools/call, plus empty lists for resources and prompts because some clients probe them. The wire format is JSON-RPC 2.0 over HTTP. What you give up is the SDK's transport, session and capability plumbing, and you take on revision tracking yourself. Keep the SDK as a test client, which is what the conformance suite does.
Can an MCP server run on the Vercel edge runtime?
Only when every tool finishes quickly and nothing needs a long-lived connection. This deployment sets runtime = 'nodejs' because the tools call an internal API over the Docker network through API_INTERNAL_URL, and audit_script polls an asynchronous audit for up to 90 seconds. Edge runtimes suit stateless lookup tools that answer from one upstream request.
How do you rate limit a public MCP server?
Use two counters keyed differently. A global daily cap bounds spend on billable calls across the whole endpoint. A per-caller window, keyed on the bearer token first, then x-forwarded-for, bounds one client. Return refusals as tool results with isError true so the model relays the reason instead of retrying. Keep the counters outside process memory once you run more than one replica.
What does an MCP client receive when the server is rate limited?
A JSON-RPC result with HTTP 200, not an HTTP 429. The body carries result.content[0].text with the refusal sentence and result.isError true. The specification calls this a tool execution error and expects clients to pass it to the model for self-correction. The sentence names the reset time or the remaining minutes plus the alternative, because a protocol error would tell the model to retry the transport, which is the wrong instruction.
Does a Streamable HTTP MCP server need sessions?
No. Sessions are optional in revision 2025-11-25 and removed in 2026-07-28. This server issues no Mcp-Session-Id, keeps no per-connection state, and answers each POST on its own. Statelessness means a container restart cannot orphan a client and a load balancer can round-robin. The cost is that there is no resumable SSE stream and no server-initiated messages.
Why does a remote MCP server still ship a stdio bridge?
For clients that can only launch a local command. The bridge starts an official SDK client against the remote endpoint, fetches the tool list at startup, and re-exports it over stdio. Tools stay in one place, so adding a tool upstream needs no new npm release. It adds the SDK as a dependency of the bridge package, and it hides nothing, which bridge.test.ts asserts by comparing both tool lists.
How do you test an MCP server for conformance?
Drive it with the official SDK client rather than hand-written JSON, because the SDK encodes the current reading of the specification. Point the suite at a local build first, then at production with MCP_URL=https://prepublish.ai/mcp. Keep error paths in the default run, and gate any call that costs money or writes a row behind an environment flag, as MCP_TEST_BILLABLE=1 does here.
Related Guides
Free tools to put this into practice
Hook Analyzer
Score your first 1-3 sentences
Title Analyzer
Writing rubric + 5 rewrites
Words to Minutes
Script length calculator
Word Counter
Count, reading time, duration
Want to see how this reads on real channels? Browse the channel breakdowns. Each one compares script patterns across a channel's own higher-viewed and lower-viewed uploads, quoted from the transcripts.
See where your next script leaks viewers
Paste your script, get your scores and the biggest leak for free. No login.