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

Add your first real tool and resource

⏱ 10 min

The template ships with one tool (hello) so you can see the registration pattern. Time to add the real ones for the rest of the tutorial: a notes.create tool and a notes://recent resource (plus a templated notes://by-id/{id} for variety).

The thing that makes this slightly more interesting than a copy-paste from the SDK docs is the template’s per-request server. createServer() runs every time a request hits /mcp, so any state you want to persist between requests has to live outside the factory.

1. Carve out a store module

Putting storage behind an interface from day one means the Postgres swap in the next step is mechanical. The store is a module-level singleton - created once at import time, shared across every createServer() call.

src/store.ts
import { randomUUID } from "node:crypto";
export type Note = {
id: string;
title: string;
body: string;
createdAt: string;
};
export interface NoteStore {
create(input: { title: string; body: string }): Promise<Note>;
recent(limit: number): Promise<Note[]>;
getById(id: string): Promise<Note | null>;
}
export function createMemoryStore(): NoteStore {
const notes: Note[] = [];
return {
async create({ title, body }) {
const note: Note = {
id: randomUUID(),
title,
body,
createdAt: new Date().toISOString(),
};
notes.unshift(note);
return note;
},
async recent(limit) {
return notes.slice(0, limit);
},
async getById(id) {
return notes.find((n) => n.id === id) ?? null;
},
};
}

The async signatures look like overkill for a Map - they are. They’re load-bearing for step 4, where every method becomes a Postgres round-trip.

2. Register the tool and resources inside createServer()

Open src/app.ts and extend the template’s factory. Keep hello so you can see both side by side; remove it once you’re confident.

src/app.ts - imports + factory
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 { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Request, Response, NextFunction } from "express";
import { timingSafeEqual } from "node:crypto";
import { z } from "zod/v4";
import { createMemoryStore, type NoteStore } from "./store.js";
const MCP_API_TOKEN = process.env.MCP_API_TOKEN;
const store: NoteStore = createMemoryStore();
export function createServer(): McpServer {
const server = new McpServer(
{ name: "notes-mcp", version: "0.1.0" },
{ capabilities: { logging: {} } },
);
server.registerTool(
"hello",
{ description: "Say hello to someone", inputSchema: { name: z.string() } },
async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }),
);
server.registerTool(
"notes.create",
{
description: "Persist a short note. Returns the new note's id and createdAt.",
inputSchema: {
title: z.string().min(1).max(200),
body: z.string().min(1).max(10_000),
},
},
async ({ title, body }) => {
const note = await store.create({ title, body });
return {
content: [{ type: "text", text: `Created note ${note.id} at ${note.createdAt}.` }],
structuredContent: note,
};
},
);
server.registerResource(
"recent-notes",
"notes://recent",
{ title: "Recent notes", description: "The 10 most recent notes.", mimeType: "application/json" },
async (uri) => {
const notes = await store.recent(10);
return {
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(notes, null, 2) }],
};
},
);
server.registerResource(
"note-by-id",
new ResourceTemplate("notes://by-id/{id}", { list: undefined }),
{ title: "Note by id", description: "Fetch a single note by id.", mimeType: "application/json" },
async (uri, { id }) => {
const note = await store.getById(String(id));
if (!note) throw new Error(`Note ${id} not found`);
return {
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(note, null, 2) }],
};
},
);
return server;
}

The rest of src/app.ts (the bearer-token middleware, the /mcp handler, the /health route) stays exactly as the template wrote it. You’re only changing what the factory builds.

Two details worth highlighting:

DetailWhy
Tool names use dots (notes.create)Reads better in client UIs than camelCase or kebab-case. Most clients show the raw name in their tool picker.
Tool returns both content and structuredContentModern clients prefer structuredContent when the tool advertises a schema; older clients fall back to content. Returning both is the broadest-compatible answer.

3. Build and run

Terminal
npm run build && npm start
Terminal
pnpm build && pnpm start
Terminal
yarn build && yarn start

Then drive the new endpoints with the MCP Inspector - the same workflow the template’s README recommends.

Terminal - new tab
npx @modelcontextprotocol/inspector

In the connection form:

Hit Connect, then:

  1. Open the Tools tab You should see hello, notes.create, and - if you registered them earlier in the file - the template’s defaults.
  2. Call notes.create Fill in any title/body and click Run Tool. The response pane shows the text result and the structured Note object.
  3. Open the Resources tab notes://recent is listed under static resources, and notes://by-id/\{id\} shows up as a template.
  4. Read notes://recent Click it; the inspector calls resources/read and prints the JSON array containing the note you just created.

4. What the protocol looks like on the wire

Two requests, one for each interaction you just did:

POST /mcp - tool call
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "notes.create",
"arguments": { "title": "First note", "body": "hello from MCP" }
}
}
POST /mcp - resource read
{
"jsonrpc": "2.0",
"id": 8,
"method": "resources/read",
"params": { "uri": "notes://recent" }
}

Each request gets a fresh McpServer and StreamableHTTPServerTransport (that’s what the template’s handler does). The module-level store is the single piece of state shared across all of them.

flowchart LR
  req["POST /mcp"] --> handler["app.post('/mcp',...)"]
  handler --> mk["createServer()"]
  mk --> tools["notes.create / hello"]
  mk --> res["notes://recent / by-id"]
  tools --> store[(module-level<br/>NoteStore)]
  res --> store
The template's `app.post('/mcp',...)` handler calls `createServer()` per request. Where does the `NoteStore` need to live so notes survive between requests?

What you learned

  • Tools, resources, and prompts are MCP's three building blocks; this server uses one tool and two resources
  • Storage lives at module scope so it survives the template's per-request `createServer()` factory
  • Tool handlers return `content` (text) and `structuredContent` (typed) for broadest client compatibility
  • The MCP Inspector is the fastest way to exercise a local server before pointing a real client at it