Local stdio is the right place to learn. Public use means a URL other clients can reach. This page teaches that path end to end: same bookmarks tools, Streamable HTTP transport, one deploy shape, Cursor url / headers, and a public-safety floor.
Before you start
If Tool, stdio, or registerTool are unfamiliar, read Part 1 and Part 3 first — this post will not re-teach those basics.
Refreshers (optional): How MCP Talks for transports; Hardening before you expose writes on the internet.
Bootstrap from a tools-capable tag:
git clone https://github.com/SRVivek1/geekmonks-mcp-bookmarks.git
cd geekmonks-mcp-bookmarks
git checkout part-7 # or part-4 / part-6
npm install
You will end on tag part-8.
Deploy path locked for this post: a single Node HTTP service on Fly.io (or any one PaaS that runs a container/Node process the same way). No multi-cloud comparison.
What changes vs stdio
| stdio | Streamable HTTP | |
|---|---|---|
| Who starts the server | Host spawns your process | You run a long-lived HTTP service |
| Client config | command / args | url (+ headers for auth) |
| Auth day-one | OS user / local trust | Bearer token (or API key) on every request |
| Fan-out | One client per process (typical) | Many clients → one deployment |
Primitives stay the same: tools (and optional resources/prompts). Only the pipe changes — see Part 5.
Extract a server factory
Part 3 registered tools inside createServer() in src/index.ts. HTTP needs that factory on every request. Rename it, export it, and keep stdio as a thin wrapper.
src/server.ts — same registrations as Part 3, exported:
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { addBookmark, loadBookmarks, searchBookmarks } from './bookmarks.js';
export function createBookmarksServer(): McpServer {
const server = new McpServer({
name: 'geekmonks-mcp-bookmarks',
version: '0.8.0',
});
server.registerTool(
'list_bookmarks',
{
title: 'List bookmarks',
description: 'List all saved bookmarks from the local JSON store.',
},
async () => {
const bookmarks = await loadBookmarks();
return {
content: [{ type: 'text' as const, text: JSON.stringify(bookmarks, null, 2) }],
};
},
);
server.registerTool(
'add_bookmark',
{
title: 'Add bookmark',
description: 'Add a bookmark with a title and URL to the local JSON store.',
inputSchema: z.object({
title: z.string().min(1).describe('Short title for the bookmark'),
url: z.string().url().describe('Full URL to save'),
}),
},
async ({ title, url }) => {
const entry = await addBookmark(title, url);
return {
content: [{ type: 'text' as const, text: JSON.stringify(entry, null, 2) }],
};
},
);
server.registerTool(
'search_bookmarks',
{
title: 'Search bookmarks',
description: 'Search bookmarks by substring match on title or URL.',
inputSchema: z.object({
query: z.string().describe('Text to find in title or URL'),
}),
},
async ({ query }) => {
const matches = await searchBookmarks(query);
return {
content: [{ type: 'text' as const, text: JSON.stringify(matches, null, 2) }],
};
},
);
return server;
}
Tag part-7 already has this factory if you would rather copy than retype. src/index.ts becomes:
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createBookmarksServer } from './server.js';
void serveStdio(createBookmarksServer);
console.error('geekmonks-mcp-bookmarks (tools) running on stdio');
Add a Streamable HTTP entrypoint
Keep tool registration in a factory (createBookmarksServer) so each HTTP request gets a fresh McpServer (stateless Streamable HTTP). SDK v2 helpers:
createMcpHandler(factory)from@modelcontextprotocol/servertoNodeHandler(handler)from@modelcontextprotocol/nodecreateMcpExpressApp()from@modelcontextprotocol/express
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import type { NextFunction, Request, Response } from 'express';
import { createBookmarksServer } from './server.js';
const PORT = Number(process.env.PORT ?? 3000);
const MCP_TOKEN = process.env.MCP_TOKEN;
if (!MCP_TOKEN) {
console.error('Refusing to start: set MCP_TOKEN');
process.exit(1);
}
const mcpHandler = createMcpHandler(createBookmarksServer);
const nodeHandler = toNodeHandler(mcpHandler);
const app = createMcpExpressApp();
function requireBearer(req: Request, res: Response, next: NextFunction): void {
if (req.header('authorization') !== `Bearer ${MCP_TOKEN}`) {
res.status(401).json({ error: 'unauthorized' });
return;
}
next();
}
app.all('/mcp', requireBearer, (req, res) => {
void nodeHandler(req, res);
});
app.listen(PORT, () => {
console.error(`bookmarks MCP (HTTP) on :${PORT}/mcp`);
});
Install the HTTP helpers:
npm install @modelcontextprotocol/express @modelcontextprotocol/node express
npm install -D @types/express
Scripts (companion package.json): "start:http": "node dist/http.js".
You should see locally:
export MCP_TOKEN=dev-secret
npm run build && npm run start:http
# stderr: geekmonks-mcp-bookmarks (HTTP) listening on :3000/mcp
Prove with MCP Inspector against http://127.0.0.1:3000/mcp (send the bearer header). List tools; call list_bookmarks.
Official serving guide: Serve over HTTP.
Public-safety checklist
Before a public DNS name:
- HTTPS only on the public URL (terminate TLS at the PaaS).
- Require
MCP_TOKEN(or stronger); reject missing auth before tool dispatch. - Prefer read-only tools on the public deployment; keep mutating tools off or behind a separate locked-down URL.
- Rate-limit at the edge if the platform offers it.
- No secrets in tool output; stderr logs only.
- Least privilege on the store (one data file / managed volume — not your laptop home directory).
OAuth / multi-tenant IdP: out of scope — use bearer for day one; graduate when you need per-user delegated access.
One PaaS path (Fly.io)
A Dockerfile that builds the TypeScript project and runs the HTTP entrypoint:
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm ci && npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY --from=build /app/dist ./dist
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/http.js"]
Deploy:
fly launch --name geekmonks-mcp-bookmarks --no-deploy
fly secrets set MCP_TOKEN="$(openssl rand -hex 24)"
fly deploy
Fly sets PORT for you; the HTTP entrypoint already reads process.env.PORT. After deploy, the MCP URL is https://geekmonks-mcp-bookmarks.fly.dev/mcp (use the hostname fly launch printed).
Any PaaS that runs the same Node process works — keep one path in your own notes so the post stays copy-pasteable.
Wire Cursor to the URL
Project or global MCP config (field names follow current Cursor docs — prefer url + headers):
{
"mcpServers": {
"bookmarks-remote": {
"url": "https://YOUR-APP.fly.dev/mcp",
"headers": {
"Authorization": "Bearer ${env:MCP_TOKEN}"
}
}
}
}
Set MCP_TOKEN in the environment Cursor inherits (or your OS secret mechanism). Reload MCP.
You should see: the remote server Connected; one approved list_bookmarks (or equivalent) returns JSON. If you get 401, fix the bearer token before debugging tools.
Wrap-up
You served the same MCP tools over Streamable HTTP, locked the endpoint with a bearer token, deployed one public URL, and pointed Cursor at url + headers. Series hub: MCP Without the Hype. Transport refresher: Part 5. Distribution without hosting: Part 7. Code: tag part-8 on geekmonks-mcp-bookmarks.