Render Tutorials
Build and host a full-featured, secure MCP server on Render

Start from Render's MCP template

⏱ 8 min

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.

Terminal
gh repo clone <your-username>/notes-mcp
cd notes-mcp

This 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:

Terminal
git clone --depth 1 https://github.com/render-examples/mcp-server-typescript notes-mcp
cd notes-mcp
rm -rf.git && git init -q

You’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:

Terminal
npm install
npm run build
npm start
Terminal
pnpm install
pnpm build
pnpm start

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

Terminal
yarn install
yarn build
yarn start
Expected output
WARNING: MCP_API_TOKEN is not set. The server is running without authentication.
MCP server listening on port 10000

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

Smoke-test the template
$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.

src/app.ts (template - abridged)
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:

ChoiceWhy 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 factoryReturns a fresh McpServer per request. Required for stateless mode.
sessionIdGenerator: undefinedStateless 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 + timingSafeEqualShared 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_HOSTNAMEDNS 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.

  1. Push the repo If you used Use this template, it’s already on GitHub. Otherwise gh repo create notes-mcp --public --source=. --push.
  2. Open the Render Dashboard New -> Blueprint, connect the repo, hit Apply. Render reads the included render.yaml and provisions a free web service with MCP_API_TOKEN auto-generated.
  3. Wait ~60 seconds First build is npm install --include=dev && npm run build. Watch the logs in the Events tab.
  4. Grab the token Environment tab on the new service, copy MCP_API_TOKEN.
  5. Call the live URL curl the deployed /mcp with Authorization: Bearer <token> and confirm hello works 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.

FileStepChange
src/app.ts3Add notes.create tool and notes resources next to hello.
src/store.ts (new)3, 4NoteStore interface + in-memory then Postgres implementation.
src/migrations/*.sql (new)4Schema.
src/auth/provider.ts (new)5OAuth 2.1 provider, GitHub upstream, JWT minting.
src/app.ts5Replace MCP_API_TOKEN middleware with mcpAuthRouter + requireBearerAuth.
src/logger.ts (new)6Pino structured logs.
src/health.ts (new)6DB-backed /health.
render.yaml7Add 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.

The template uses `sessionIdGenerator: undefined` to put the transport in stateless mode. What capability does that intentionally sacrifice in exchange for trivial horizontal scaling?

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