Back to Guides
Workflow13 minUpdated Sep 19, 2026

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).

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.

RevisionStatus on this serverWhat it changedSource
2026-07-28Not implementedRemoved initialize and sessions, added server/discover, required resultTypeKey changes, 2026-07-28
2025-11-25Newest implementedLast handshake-based revision; tool definitions may carry icons and execution.taskSupportTools, 2025-11-25
2025-06-18AnsweredRemoved JSON-RPC batching; required the MCP-Protocol-Version request headerKey changes, 2025-06-18
2025-03-26AnsweredStreamable HTTP replaced the HTTP+SSE transport from 2024-11-05Transports, 2025-11-25
2024-11-05AnsweredThe revision that defined HTTP+SSE, which Streamable HTTP replacedTransports, 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:

ToolAI-backedBackend callInput
audit_scriptyesPOST /api/analyze, then polls GET /api/analysis/:idvideo_title, script_text, optional email
get_auditnoGET /api/analysis/:idanalysis_id
audit_hookyesPOST /api/tools/hook-analyzerhook_text, optional niche
check_authenticityyesPOST /api/tools/authenticity-checkvideo_title, script_text
policy_preflightyesPOST /api/tools/policy-preflightscript (minimum 200 characters)
script_runtimenonone; arithmetic over local dataword_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 keepsWhat it gives up
One request and response type across the whole handler, Web Request/ResponseThe SDK's StreamableHTTPServerTransport, so no SSE streaming, no resumability, no session management
Five methods to implement, each visible in one switchRevision tracking that arrives as a dependency update instead of as your own work
Statelessness by construction, no session store to operateServer-initiated requests: sampling, elicitation and roots are out of reach
Full control of the text a model reads, including the refusal sentencesSDK conveniences this server does not use: outputSchema validation, pagination cursors, resources and prompts
No server SDK in the web app's dependency treeThe 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 }
}
LimitDefaultVariableKeyed byWhat the caller receives
Global daily cap100 calls per UTC dayMCP_DAILY_CALL_CAPthe endpoint as a wholetool result, isError: true, reset time named
Per-caller burst6 calls per 10 minutesMCP_CALLER_CALL_LIMITbearer token, else x-forwarded-for, else mcp-session-id, else a random keytool 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/)
TestWhat it pins down
Handshake and identityserver name, tools capability, the retention sentence surviving in the instructions
Tool discoverythe exact sorted list of six names, a description longer than 120 characters, an object inputSchema with properties
Required fieldsaudit_script requires exactly script_text and video_title; the local tool must not claim retention prediction
Both conversion directions1,810 words becomes a 10 minute median, 10 minutes becomes 1,810 words, rates are 201/181/160
Empty argumentsisError: true with the sentence naming the three accepted inputs
Unknown toolthe SDK client rejects the call, so a typo is distinguishable from a refusal
Bare GETserver card has the name, streamable-http, a protocol list containing 2025-06-18, and six tools
Batchingerror -32600 with the reason
NotificationHTTP 202 and an empty body
Older revisionrequesting 2024-11-05 returns 2024-11-05 rather than the newest
Unsupported revisionrequesting 1999-01-01 falls back to 2025-11-25
One AI-backed callskipped 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.

  1. Origin validation is absent. The transport section says servers MUST validate the Origin header and MUST answer HTTP 403 when it is present and invalid. A POST carrying Origin: https://evil.example returned HTTP 200 with access-control-allow-origin: *. The endpoint holds no cookies and no session, which is a real mitigation, but it is not what the text requires.
  2. 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.
  3. An unsupported protocol version header is not rejected. The text says a server MUST answer 400 for an invalid MCP-Protocol-Version. A request sending 1999-01-01 returned HTTP 200 with a normal result, because the route negotiates down instead of failing.
  4. Two negotiators can disagree. The response header and the initialize body select a version from different inputs, so one response carried header 2024-11-05 and body 2025-11-25 on 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 structuredContent and no outputSchema, so a client cannot validate a result against a schema it fetched.
  • Supporting 2026-07-28 would mean adding server/discover, reading _meta, emitting Mcp-Method and Mcp-Name, and adding resultType to 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.

SituationBetter choice
You need sampling, elicitation, roots or progress notificationsSDK server transport, since those are server-initiated messages this route cannot send
You want revision tracking to arrive as a dependency updateSDK, 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 statusSDK plus an authorization server, or a hosted MCP platform that already operates one
You run more than one replicaExternal counters in Valkey or Redis, with either server approach
You serve a handful of tools from an existing app and want one deployA route handler like this one, with the caps and the conformance suite it ships
The client is a desktop app with local filesA stdio server, with no HTTP endpoint at all
You want argument validation and structured output without writing itSDK, 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

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.