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.
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.
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:
| Detail | Why |
|---|---|
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 structuredContent | Modern 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
npm run build && npm startpnpm build && pnpm startyarn build && yarn startimport { 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;}
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(); });});Then drive the new endpoints with the MCP Inspector - the same workflow the template’s README recommends.
npx @modelcontextprotocol/inspectorIn the connection form:
Hit Connect, then:
- Open the Tools tab You should see
hello,notes.create, and - if you registered them earlier in the file - the template’s defaults. - Call notes.create Fill in any title/body and click Run Tool. The response pane shows the text result and the structured
Noteobject. - Open the Resources tab
notes://recentis listed under static resources, andnotes://by-id/\{id\}shows up as a template. - Read notes://recent Click it; the inspector calls
resources/readand 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:
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "notes.create", "arguments": { "title": "First note", "body": "hello from MCP" } }}{ "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
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