How to Create an MCP: A Developer’s Guide
Your Step-by-Step Guide to Building a Powerful MCP from Scratch
If you’ve spent any time trying to wire Notion into MCP-aware tools (Cursor, Claude Desktop, your own bots), you’ve probably touched all three of these ideas in quick succession:
- “Ah! Notion has a hosted MCP server at
https://mcp.notion.com/mcp__. I’ll just use that.” - “…wait, it wants OAuth per user, but I need a single workspace integration token for a bot.”
- “Fine. I’ll self-host the open-source server. But how do I make that safe, reachable, and future-proof without rewriting half the stack?”
This post walks through that journey end-to-end. We’ll demystify MCP transport types, explain why the Notion hosted endpoint cannot (and should not) accept your workspace integration token, and show the exact setup we landed on: a Dockerized MCP hub that runs the official Notion server over stdio internally, exposes HTTP/SSE externally via a lightweight mcp-proxy, and tucks neatly behind Cloudflare for secure access. Along the way we’ll hit design trade-offs, security guardrails, and practical “watch-outs” you won’t find in glossy overviews.
It’s a long read, but if you want to truly understand the moving pieces—and adopt a pattern that scales beyond Notion to other MCP servers—this is for you.
MCP in a nutshell, and why transports matter
The Model Context Protocol (MCP) is a standard that lets AI apps and agents talk to external systems in a consistent way. Think of it like a USB-C port for your AI: on one side, tools and data sources; on the other, the client (agent/IDE/assistant). The protocol defines what messages look like (tools, prompts, resources) and leaves room for different ways to move those messages back and forth—called transports. (Model Context Protocol)
Why you should care: transports determine where your server runs, how clients connect, and what auth patterns are feasible. You’ll see three names repeatedly:
- stdio — the client launches a server as a subprocess and talks over standard input/output. Great for local development and tightly coupled setups.
- Streamable HTTP — the server listens on an HTTP endpoint, and clients talk to it over request/response, including streaming. Enables remote, multi-client scenarios.
- SSE (Server-Sent Events) — essentially streaming over HTTP using the SSE mechanism; many clients treat this as the “live” flavor of streamable HTTP. (MCP Protocol)
There are other deployment shapes (like “Remote MCP” patterns that package auth and routing), but at heart you’re picking local (stdio) versus remote (HTTP/SSE) and then layering on security, scaling, and ergonomics.
The Notion twist: hosted convenience vs. integration control
When you visit Notion’s official MCP docs, you’ll see a clean pitch: connect your AI tool to Notion’s hosted MCP server, and you’re off to the races. And it truly is smooth—for user OAuth. Their flow is designed for individuals to authorize Notion MCP, and behind the scenes the hosted server stores the API token granted by that OAuth exchange to make tool calls on their behalf. It’s elegant for personal use and orgs that want user-level consent and scoping. (developers.notion.com)
But here’s the rub. If you’re building an integration bot, you likely want:
- A single Notion integration token (
ntn_…) that represents a service identity. - Deterministic access: the bot sees exactly the pages/databases you’ve granted to that integration, independent of who uses the client.
- Easy rotation and environment control via infrastructure (secrets, compose, CI/CD), not per-user OAuth logins.
Notion’s hosted URL https://mcp.notion.com/mcp is not intended to accept your integration token in place of OAuth. It’s explicitly built around the OAuth story. If you need service-level access, the path is to self-host the Notion MCP server (or implement equivalent tools elsewhere) and feed it your integration token securely. (developers.notion.com)
Fortunately, Notion publishes an open-source server (@notionhq/notion-mcp-server) that can run via stdio or streamable HTTP and supports bearer auth as well as reading your NOTION_TOKEN from the environment. In HTTP mode you can even let the server auto-generate a strong auth token for clients, or set one yourself for production. (GitHub)
The fork in the road: “pure remote” vs. “stdio inside, HTTP outside”
Once you decide to self-host for integration-token control, you have two viable architectures:
- Pure streamable HTTP: run Notion’s server natively in HTTP mode on a port, protect it with a reverse proxy and auth, and point your clients at it. Clean, minimal, and close to the repo’s examples.
- Stdio inside, HTTP outside (what we shipped): let the Notion server run in its natural stdio mode, and place a thin mcp-proxy in front that speaks HTTP/SSE to the outside world and manages the child process. This gives you a single “hub” that can spawn multiple stdio servers by name, with health checks and config in one place.
Both are good. We chose #2 because:
- It keeps the server in the most battle-tested mode (stdio) while delivering interoperability for HTTP/SSE clients.
- It sets up a hub pattern: today “notion”, tomorrow “github”, “pagerduty”, etc. One URL, many servers.
- Process supervision and health become uniform: the proxy owns the lifecycle, token injection, and status surface.
The working design (what we actually run)
Here’s the essence of the “MCP Hub for Notion” we built and now use in production:
- A single Docker image bundling Node (for
@notionhq/notion-mcp-server) and Python (for themcp-proxy). - A
servers.jsonthat tells the proxy which stdio servers to spawn (start with “notion”, add more later). - A /status endpoint and Docker healthcheck so orchestration knows when the hub is ready.
- A default SSE path like
/servers/notion/sseso any MCP client that understands remote transports can connect without caring what’s behind the curtain.
The result: clients speak HTTP/SSE to the proxy; the proxy speaks stdio to the Notion server; your **NOTION_TOKEN**lives in .env (mounted securely into the container); and you can put Cloudflare in front for public access, mTLS/Access, rate-limits, and logging.
If you want the full text of our README and compose snippets, scroll to the “Deep dive: files & commands” section below—we’ll show the moving parts and how to extend them to multiple servers.
A fast but thorough tour of MCP transports (with real consequences)
Before we dig into config, a quick field guide to transports—because your debugging and security posture depends on knowing which wire you’re actually on.
stdio (local, simplest, least surprises)
- How it works: the client (e.g., Claude Desktop) launches the server as a child process and writes/reads JSON messages on stdin/stdout.
- Pros: dead simple; no ports, no proxies, no CORS, no internet exposure; perfect for local development.
- Cons: doesn’t scale for multi-client or remote; you can’t easily centralize secrets or share a single server among a team.
Streamable HTTP (remote-friendly, stateless request/response plus streaming)
- How it works: the server exposes an HTTP endpoint (
/mcp,/sse, etc.). The client sends JSON; the server streams partials or full responses. - Pros: supports remote connections, multiple clients, and “hosted MCP” experiences; easy to put behind CDNs and reverse proxies.
- Cons: you now own security (auth, DoS protection), observability, and compatibility (CORS, chunked encoding, timeouts). (MCP Protocol)
SSE (a streaming flavor of HTTP that plays well with proxies)
- How it works: the server keeps an HTTP connection open and pushes events in text/event-stream format; the client reads them as they arrive.
- Pros: widely supported; many MCP clients already know how to speak SSE; streams traverse CDNs pretty well.
- Cons: same security obligations as HTTP; sometimes trickier timeout semantics at layers you don’t control. (MCP Protocol)
In our hub, we bridge these: stdio inside (predictable), SSE outside (interoperable). It’s the “best of both” for a team bot.
Why the Cloudflare angle keeps popping up
You’ll see a lot of talk about “one-click MCP servers on Cloudflare” because Workers and Tunnels make public, secure, authenticated access comparatively easy: you deploy a tiny function or run a local service and put Cloudflare Access in front for identity. Cloudflare has even formalized remote MCP server guides and templates (with and without OAuth). That’s handy whether you proxy out to your hub or write tools directly in a Worker. (The Cloudflare Blog)
In our case, we run the hub wherever we like (a small VM or container platform), then turn on a Cloudflare Tunnel and Access policy so only the identities we approve can reach /servers/notion/sse. It’s a great middle ground: we keep Docker-grade ergonomics and local ops, but users connect as if it were a neat hosted MCP.
The unlock: a single hub that fans out to many servers
Even if “Notion” is the catalyst, building a hub saves you a redo later. You’ll almost certainly want GitHub, PagerDuty, a secrets vault, an internal knowledge base, etc. With our shape:
- Add another entry in
servers.json. - Restart the stack.
- Tell your clients there’s a new named server at
/servers/{name}/sse.
No redeploys of Workers, no fiddly ports, no mixed security models. One pattern, many servers.
Deep dive: files & commands (annotated)
Below is the essence of our repo layout—the one we described in the summary you shared. I’ll re-explain what each piece does and include pragmatic tips.
Security first: keep secrets (especially NOTION_TOKEN) out of source control. Use an .env file mounted at runtime or your orchestrator’s secret store. If you later front this with Cloudflare Access, you’ll have identity on the edge and an internal auth token from the MCP server for defense in depth.
Dockerfile — a combined Node + Python image
- Installs Node (for
@notionhq/notion-mcp-servervianpx) and Python (formcp-proxy). - Keeps the image single-purpose but multi-language, so compose stays simple.
- Pin Node/Python minor versions and cache layers for fast rebuilds during upgrades.
docker-compose.yaml — your hub service and health
- Defines an
mcp-hubservice that:- Maps port 8083 to the host (feel free to change).
- Mounts config (so
servers.jsonand.envlive outside the image). - Runs the proxy entrypoint (something like
mcp-proxy --config /app/servers.json --port 8083 ...). - Sets a healthcheck hitting
/statusso orchestration knows when to route traffic.
Practical tip: when you add CORS controls to the proxy, start permissive to verify your clients can connect (some IDEs use file:// origins or native schemes), then lock to exact origins you use in production frontends.
servers.json — named servers, one of which is “notion”
- A
mcpServersobject with a key like"notion". - Command is
npx, args are["-y", "@notionhq/notion-mcp-server"]. - Environment injects your
NOTION_TOKEN(the Notion integration key that starts withntn_…). - Optionally add other env vars that upstream supports (like
OPENAI_API_KEYfor specific tools, if you later expand).
Because mcp-proxy is responsible for process management, it can also restart crashed servers and surface errors on /status. That takes a lot of guesswork out of readiness probes.
.env — secrets and runtime knobs
- Keep it small:
NOTION_TOKEN=ntn_…and any proxy secrets (e.g., a client API key the proxy expects in a header). - Use
docker compose --env-fileif you don’t want to keep.envin the same directory.
Quick start (recap with commentary)
- Configure secrets
Add
NOTION_TOKENto.env. Grant the integration only the specific pages/databases the bot needs (least privilege = safer blast radius). Notion’s authorization model differentiates between internal vs. public integrations; for a service bot, internal with page-level access is ideal. (developers.notion.com) - Build
docker compose build— this pulls the base images, installsmcp-proxy, and gets you ready to run. - Launch
docker compose up -d— the hub boots, spawns the Notion server via stdio, and starts listening on port8083. - Verify
curl http://localhost:8083/status— look for JSON indicating the “notion” server is configured and alive. - Connect a client
Point an MCP client at
http://localhost:8083/servers/notion/sse. Most modern clients can take a “remote server” entry with a URL; some still prefer a local proxy command—both work. (Cloudflare remote MCP docs and common IDEs illustrate both patterns.) (Cloudflare Docs)
Cloudflare front-door: from localhost to a safe public endpoint
When you’re ready to expose this beyond your machine:
- Spin up a Cloudflare Tunnel that maps
mcp.yourdomain.com→http://127.0.0.1:8083. - Add a Cloudflare Access policy for that hostname. Start with your company identity provider; for service-to-service, use Access Service Tokens.
- (Optional) Add rate limiting and caching rules. SSE should not be cached, but it’s worth setting explicit “bypass cache” on your SSE path to avoid surprises.
This yields a production-grade remote MCP experience—minus the complexity of managing inbound firewall rules and TLS yourself. Cloudflare has written at length about deploying remote MCP servers and the trade-offs of auth/no-auth endpoints; we recommend starting with Access on day one. (The Cloudflare Blog)
The puzzle that tripped us (and probably you): “Why can’t I just hit the Notion URL with my token?”
Because the hosted Notion MCP is designed for user OAuth end-to-end. The server manages sessions and stores the token obtained during that OAuth exchange; it isn’t intended to be a generic gateway that accepts arbitrary NOTION_TOKENs. From their own engineering write-ups and developer docs, the mental model is: users authorizing a central MCP integration with the same permissions they have in the app, not bots supplying workspace-level credentials. That’s why the “one URL” experience is terrific for individuals—and a mismatch for service bots. (Notion)
The good news is that the open-source Notion MCP server is happy to run in either stdio or HTTP mode with your integration token and even supports your own auth token for clients. If you wanted to skip our proxy pattern and run it directly as HTTP, you could:
# Development: server generates a bearer token and prints it
npx @notionhq/notion-mcp-server --transport http
# Production: bring your own token
npx @notionhq/notion-mcp-server --transport http --auth-token "your-secret-token"
# or via env:
AUTH_TOKEN="your-secret-token" npx @notionhq/notion-mcp-server --transport http
Clients would then connect with a Streamable HTTP config and include Authorization: Bearer your-secret-token. We just prefer the stdio-inside / HTTP-outside hub, because it scales to multiple servers and keeps process management centralized. (GitHub)
Observability and operability: don’t skip this
Health checks: Your /status endpoint should report:
- whether each server is configured,
- process up/down state,
- last start time and restarts, and
- a short error if spawn failed (bad path, missing env, etc.).
Logs: Split logs into:
- proxy logs (HTTP access, auth decisions, upstream failures), and
- server logs (stdio from child process).
Timeouts: SSE can run long; ensure your proxy and any edge/CDN layer share a consistent idle timeout that won’t cut off streaming prematurely. If you use Cloudflare, set an application or route policy that allows long-lived connections for your SSE path.
Back-pressure: Notion search tools can return hefty payloads. Prefer server tools that accept pagination or page_sizearguments. Keep streaming lines reasonably sized to avoid exceeding client buffer limits.
Security hardening checklist
- Least privilege in Notion: grant your integration access only to the pages/databases the bot truly needs; keep it internal unless you specifically need public OAuth flows. (developers.notion.com)
- Secret hygiene: store
NOTION_TOKENoutside the image (.env or secret store). Don’t log it. Rotate on a schedule. - Edge identity: put Cloudflare Access (or equivalent) in front of your hub; make clients present identity before they even reach your proxy. (The Cloudflare Blog)
- Defense in depth: even behind Access, keep a server-side auth token (e.g., a header the proxy enforces) so a misconfigured route can’t expose your hub directly.
- Rate limiting: clients can loop; protect downstream systems (and your API rate limits).
- Audit: log which named server and which tool were invoked, by which identity, with how many tokens (roughly). Redact inputs that may contain secrets.
Troubleshooting the real issues
“The client can’t connect; CORS error”
Your proxy’s Access-Control-Allow-Origin is too strict (or too permissive and conflicting). For IDEs, sometimes you need to allow chrome-extension://… origins or file://—or skip CORS for native clients. Start permissive during initial wiring, then enumerate exact origins.
“/status says ready, but Notion calls fail”
Double-check the integration’s access inside Notion. You must share specific pages/databases with the integration. If your Notion token works in curl but fails via MCP, confirm that NOTION_TOKEN is truly in the child process environment (print process.env on spawn during debugging or temporarily log the first few characters of the token).
“Large searches stall or time out”
Prefer paginated tools or server options like page_size. Your client’s MCP configuration might also have a request timeout; bump it for long listings.
“After deploying behind Cloudflare, long streams cut off”
Check your SSE path’s idle timeout at the edge and any proxies in between. Some defaults are < 60s. Packet captures or Cloudflare logs will reveal whether the edge or origin closed the connection.
When you should use Notion’s hosted MCP
Despite our bot-centric focus here, there are great reasons to use the official hosted Notion MCP:
- You want user-level permissions: each user should only see what they can already see in Notion.
- You don’t want to handle secrets or servers at all.
- Your client app (e.g., IDE) supports the OAuth dance and remote MCP out of the box.
That hosted flow is purpose-built for OAuth and comes with all the ergonomics of “one URL to rule them all.” We use it ourselves for personal workflows; we just don’t use it for service bots that centralize workspace automations. (developers.notion.com)
Beyond Notion: turning your hub into a platform
Once the hub exists, you’ll naturally want to add more servers. A few patterns we’ve found useful:
- Standardize tool naming: e.g.,
notion.search,github.search,pagerduty.incidents.list. Consistency helps your agents prompt themselves. - Global config: move shared knobs (timeouts, logging levels, rate limits) to the proxy and only use server-specific env for what’s truly unique.
- Per-server auth: even behind Access, keep an internal auth header that differs per server if your proxy supports it. That way, a bug exposing one route doesn’t unlock everything.
And if you really want to go serverless, you can translate some tools into a Remote MCP Worker on Cloudflare—great for simple reads and writes where you’d rather run zero containers. Cloudflare’s “Build a Remote MCP server” docs and templates cover both no-auth and OAuth modes when you’re ready. (Cloudflare Docs)
A narrative recap: what we tried, what failed, what worked
- First try: point clients at
https://mcp.notion.com/mcpwith our integration token.- Outcome: doesn’t work—hosted MCP is designed for OAuth per user, not workspace tokens. Great for personal setups, not for bots. (developers.notion.com)
- Second try: run the OSS Notion server in HTTP mode directly, protect it, and call it a day.
- Outcome: totally viable; the server supports a generated or custom bearer auth token and works well under a reverse proxy. We kept this in our back pocket. (GitHub)
- Final shape: build a hub where the open-source server runs via stdio, and a small proxy exposes SSE and health externally.
- Outcome: stable, extensible, future-proof. We can add more servers by editing
servers.json, standardize observability, and front the whole thing with Cloudflare Access for identity. (The Cloudflare Blog)
- Outcome: stable, extensible, future-proof. We can add more servers by editing
Frequently asked questions
Can I mix stdio and HTTP servers behind the same proxy?
Yes. Your proxy only cares that it can launch a command (stdio) or forward to an upstream (HTTP). Name them distinctly and make their SSE endpoints predictable.
Do I still need OAuth anywhere?
Not for the Notion integration token path described here. If you later add servers that only support OAuth (e.g., a hosted remote MCP requiring user auth), put those behind the same domain but route to a Worker template that handles OAuth.
Is this approach “MCP-pure”?
Yes. The proxy is transport glue; it doesn’t alter MCP semantics. Clients still discover tools, call them, and receive streamed responses. We’ve tested with multiple MCP-aware clients without changes.
What about performance?
Stdio servers are typically fast; the proxy adds minimal overhead. For heavy workloads, focus on the tool implementations (paging, selective fields) and network placement (keep the hub close to Notion’s API regions if you can).
Copy-ready examples (adapting to your environment)
Here are a few practical snippets you can adapt immediately:
Client configuration (remote SSE)
{
"mcpServers": {
"notion": {
"url": "https://mcp.yourdomain.com/servers/notion/sse",
"transport": "sse",
"headers": {
"X-Client-Key": "your-proxy-key-if-configured"
}
}
}
}
Server launch (pure HTTP mode alternative, if you ever want it)
AUTH_TOKEN="rotate-me-regularly" \
NOTION_TOKEN="ntn_..." \
npx @notionhq/notion-mcp-server --transport http
# Clients: set Authorization: Bearer rotate-me-regularly
Cloudflare Access: minimal service token curl test
curl "https://mcp.yourdomain.com/status" \
-H "CF-Access-Client-Id: <id>" \
-H "CF-Access-Client-Secret: <secret>"
If that returns 200 with your hub status, your tunnel + Access policy is wired correctly; now your MCP client should connect to /servers/notion/sse with those Access headers (many clients let you add custom headers per server).
Where the ecosystem is heading (and how this design stays relevant)
A few trends are converging:
- Remote MCP is getting first-class treatment across providers. Cloudflare, for example, is shipping guides and templates for remote servers with and without auth, which means “hosted MCP” is moving from DIY to turnkey. (Cloudflare Docs)
- Auth variety will persist. Some endpoints are user-centric (OAuth), others service-centric (tokens/keys), and enterprises will layer SSO/Access on the edge regardless.
- Multi-server hubs are becoming the norm. Teams want one place to wire IDEs, agents, and automations to many systems without redeploying each time.
Our hub design remains compatible with all of that. You can keep using stdio servers behind a proxy, swap in direct HTTP servers where it makes sense, or incrementally port select tools into a Worker for zero-infrastructure use cases.
Final takeaway
If your goal is a Notion integration bot that uses a workspace integration token, the cleanest path is to self-host. Use Notion’s open-source MCP server, but run it in the environment you control—with a thin proxy that turns stdio into a remote-friendly SSE endpoint. Wrap it in Docker, put Cloudflare in front, and you’ll have:
- a single URL your MCP clients can hit,
- a single place to add more servers,
- secrets that live in infra (not laptops), and
- a deployment you can explain to security in one whiteboard.
And when someone inevitably asks why you didn’t just paste your token into https://mcp.notion.com/mcp, you’ll be able to say, “Because that endpoint is for OAuth—and our bot needs service-level access.” Then you can point to your hub and say, “We have that covered.”
Happy building.
Sources & references
- What MCP is and how it frames transports, from the official site. (Model Context Protocol)
- Transport concepts (stdio, SSE/streamable HTTP) in MCP docs and language guides. (MCP Protocol)
- Notion’s hosted MCP overview and connection model (OAuth-centric). (developers.notion.com)
- Notion’s engineering blog explanation of how the hosted server handles OAuth tokens/sessions. (Notion)
- Open-source
@notionhq/notion-mcp-serverREADME showing streamable HTTP and auth token options. (GitHub) - Cloudflare’s remote MCP guidance for deploying and securing remote servers. (Cloudflare Docs)