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

stdioStreamable HTTP
Who starts the serverHost spawns your processYou run a long-lived HTTP service
Client configcommand / argsurl (+ headers for auth)
Auth day-oneOS user / local trustBearer token (or API key) on every request
Fan-outOne 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/server
  • toNodeHandler(handler) from @modelcontextprotocol/node
  • createMcpExpressApp() 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:

  1. HTTPS only on the public URL (terminate TLS at the PaaS).
  2. Require MCP_TOKEN (or stronger); reject missing auth before tool dispatch.
  3. Prefer read-only tools on the public deployment; keep mutating tools off or behind a separate locked-down URL.
  4. Rate-limit at the edge if the platform offers it.
  5. No secrets in tool output; stderr logs only.
  6. 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.