Render maintains a template repo that already does the boring parts of running an MCP server on the platform: TypeScript, the official SDK, Streamable HTTP transport, a Blueprint, and one example tool. Forking it shaves ~30 minutes off the start of any MCP project, and it’s the path Render recommends in the LLM-support docs.
By the end of this step you’ll have it running on http://localhost:10000 and (if you want) deployed to a free Render URL. Everything after this is bolted on top.
1. Get the template
Open render-examples/mcp-server-typescript, click Use this template -> Create a new repository, name it notes-mcp, then clone your fork.
gh repo clone <your-username>/notes-mcpcd notes-mcpThis is the recommended path - you end up with a fresh git history, your own remote, and render.yaml deploys cleanly against the repo you own.
If you just want to poke at the template first, shallow-clone it and reset the git history:
git clone --depth 1 https://github.com/render-examples/mcp-server-typescript notes-mcpcd notes-mcprm -rf.git && git init -qYou’ll need to create a remote and push to your own repo before you can deploy.
The template uses npm (matching the README and the included render.yaml). pnpm and yarn work too - pick your weapon:
npm installnpm run buildnpm startpnpm installpnpm buildpnpm startThe template’s package-lock.json is fine to keep; pnpm will create a pnpm-lock.yaml alongside it. Delete package-lock.json if you’re going pnpm-only.
yarn installyarn buildyarn startWARNING: MCP_API_TOKEN is not set. The server is running without authentication.MCP server listening on port 10000The warning is informational - the template lets you run without a token in dev. You’ll replace that whole auth model with OAuth in step 5, so the warning will go away.
2. Smoke-test the handshake
In another terminal, walk through the two requests it takes to prove the server speaks MCP. Click Next in the panel below to step through them.
$curl -s http://localhost:10000/health | jq{ "status": "ok" }$curl -s -X POST http://localhost:10000/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0.0.1"}}}' | jq{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": {}, "logging": {} }, "serverInfo": { "name": "my-mcp-server", "version": "1.0.0" } } }$curl -s -X POST http://localhost:10000/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hello","arguments":{"name":"Render"}}}' | jq{ "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "Hello, Render!" }] } }
That sequence confirms three things: the process is up, the MCP handshake works, and the hello tool the template ships with executes end-to-end. Everything else this tutorial adds slots in alongside that pattern.
curl returns `406 Not Acceptable`?
You forgot the Accept header. Streamable HTTP requires the client to declare it’ll accept both application/json and text/event-stream (the SSE upgrade path). The Content-Type: application/json on its own isn’t enough.
3. Read what’s in the box
The interesting code is ~100 lines split across two files. Read them now - it’s the surface area the rest of the tutorial modifies.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";import { Request, Response, NextFunction } from "express";import { timingSafeEqual } from "node:crypto";import { z } from "zod/v4";
const MCP_API_TOKEN = process.env.MCP_API_TOKEN;
export function createServer(): McpServer { const server = new McpServer( { name: "my-mcp-server", version: "1.0.0" }, { capabilities: { logging: {} } }, );
server.registerTool( "hello", { description: "Say hello to someone", inputSchema: { name: z.string() } }, async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }), );
return server;}
const RENDER_EXTERNAL_HOSTNAME = process.env.RENDER_EXTERNAL_HOSTNAME;
export const app = createMcpExpressApp({ host: "0.0.0.0", allowedHosts: RENDER_EXTERNAL_HOSTNAME ? [RENDER_EXTERNAL_HOSTNAME] : undefined,});
app.use((req, res, next) => { if (req.path === "/health" || !MCP_API_TOKEN) return next();
const auth = req.headers.authorization ?? ""; const expected = `Bearer ${MCP_API_TOKEN}`; if (auth.length === expected.length && timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) { return next(); } res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized" }, id: null });});
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.post("/mcp", async (req, res) => { const server = createServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); res.on("close", () => { transport.close(); server.close(); });});Five design choices worth absorbing - they shape every decision in the next seven steps:
| Choice | Why it matters |
|---|---|
createMcpExpressApp(...) | The SDK ships a small helper that returns a pre-configured Express app (CORS for MCP, host validation, JSON parsing). Layer middleware on it just like any Express app. |
createServer() is a factory | Returns a fresh McpServer per request. Required for stateless mode. |
sessionIdGenerator: undefined | Stateless mode. No session map, no server-initiated messages - just request/response. The trade-off is no progress notifications or sampling, but it scales horizontally trivially because no instance affinity is needed. |
MCP_API_TOKEN + timingSafeEqual | Shared bearer token. Works for solo use; you replace it with OAuth in step 5 because multi-user MCP needs per-caller identity. |
allowedHosts from RENDER_EXTERNAL_HOSTNAME | DNS rebinding protection. Render injects RENDER_EXTERNAL_HOSTNAME automatically; locally it’s undefined and the helper allows all hosts. |
4. (Optional) Deploy the unmodified template now
You can deploy the template as-is to see Render’s flow before you add anything. Skip if you’d rather wait - you deploy the fully-built version in step 7 either way.
- Push the repo If you used Use this template, it’s already on GitHub. Otherwise
gh repo create notes-mcp --public --source=. --push. - Open the Render Dashboard New -> Blueprint, connect the repo, hit Apply. Render reads the included
render.yamland provisions a free web service withMCP_API_TOKENauto-generated. - Wait ~60 seconds First build is
npm install --include=dev && npm run build. Watch the logs in the Events tab. - Grab the token Environment tab on the new service, copy
MCP_API_TOKEN. - Call the live URL
curlthe deployed/mcpwithAuthorization: Bearer <token>and confirmhelloworks against the public URL.
If you do this, delete the service after - the rest of the tutorial replaces every piece of that Blueprint, and a duplicate paid plan from step 7 will hit your card.
5. What you’ll change
For orientation, here’s the diff plan for the rest of the tutorial - file by file.
| File | Step | Change |
|---|---|---|
src/app.ts | 3 | Add notes.create tool and notes resources next to hello. |
src/store.ts (new) | 3, 4 | NoteStore interface + in-memory then Postgres implementation. |
src/migrations/*.sql (new) | 4 | Schema. |
src/auth/provider.ts (new) | 5 | OAuth 2.1 provider, GitHub upstream, JWT minting. |
src/app.ts | 5 | Replace MCP_API_TOKEN middleware with mcpAuthRouter + requireBearerAuth. |
src/logger.ts (new) | 6 | Pino structured logs. |
src/health.ts (new) | 6 | DB-backed /health. |
render.yaml | 7 | Add Postgres, project + environment wrapper, OAuth env vars, upgrade plan. |
Don’t touch src/server.ts after this step - the entrypoint stays the template’s three-line app.listen wrapper.
What you learned
- Render maintains an MCP server template; cloning it skips ~30 minutes of boilerplate and gives you a working `render.yaml`
- The template uses stateless mode (`sessionIdGenerator: undefined`) - simpler scaling, no server-initiated messages
- Shared bearer token (`MCP_API_TOKEN`) works for solo use; you replace it with OAuth in step 5
- Port 10000, `/health`, `/mcp` (POST only) are the surfaces the template ships
- Optionally deploy the unmodified template now to see the green path; otherwise you ship the fully-built version in step 7