Architecture
Layered Design
The server follows a modular with clean principles approach, separating concerns across three layers:
┌─────────────────────────────────────┐
│ Transport Layer (src/index.ts) │ MCP stdio JSON-RPC
├─────────────────────────────────────┤
│ Tool Layer (src/tools/) │ Zod schemas + LLM descriptions
├─────────────────────────────────────┤
│ Service Layer (src/services/) │ Notion REST API wrapper
├─────────────────────────────────────┤
│ Common Layer (src/common/) │ Types + data transformation
└─────────────────────────────────────┘
Transport Layer (src/index.ts)
Handles the MCP stdio connection, registers tool schemas, and routes incoming tool calls to the appropriate handler. Reads NOTION_API_KEY and optional NOTION_VERSION from environment variables at startup.
Tool Layer (src/tools/)
Each file defines:
- Schemas — Zod-validated input schemas with descriptions that help the LLM understand when and how to use each tool
- Handlers — thin functions that call the service layer and return MCP-formatted responses
Service Layer (src/services/)
Wraps the Notion REST API. All HTTP calls, error handling, and pagination logic lives here. The tool layer never touches the Notion API directly. Includes structured error messages for common failure modes (401, 403, 404, 429).
Common Layer (src/common/)
Shared utilities for response transformation:
types.ts— shared TypeScript interfacesutils.ts— rich text flattening, block rendering, metadata stripping
Rich Text Flattening
Notion stores text as arrays of rich text segments with annotations, mentions, and equations. The server flattens these into plain text strings optimized for LLM consumption:
Before: [{"type":"text","text":{"content":"Hello "}},{"type":"mention","mention":{"type":"user","user":{"name":"Alice"}}}]
After: "Hello @Alice"
Supported segment types:
- text — plain content extraction
- mention — rendered as
@name,[page mention],[database mention], or date values - equation — rendered as the raw expression
Recursive Block Fetching
Notion pages store content as a tree of blocks. Many block types (toggles, columns, synced blocks) can contain nested children. The server recursively fetches child blocks up to a configurable depth (max_depth, default 3) with a safety cap of 100 blocks total.
Each block is rendered to a markdown-like plain text line with indentation reflecting nesting depth:
# Heading 1
- Bullet item
1. Nested numbered item
> Toggle content
[x] Completed to-do
Supported block types include paragraphs, headings (1-3), bulleted/numbered lists, to-dos, toggles, code blocks, quotes, dividers, callouts, images, bookmarks, table rows, child pages, child databases, embeds, and synced blocks.
When the block count exceeds the safety cap, a truncation marker is appended indicating how many blocks were omitted.
Response Transformation
All Notion API responses pass through a metadata stripping pipeline that removes internal fields:
object— type discriminator (e.g.,"object": "page")request_id— internal request trackingdeveloper_survey— Notion internal metadatapublic_url— typically null for private content
This reduces token usage and keeps responses focused on meaningful data.
Security Model
- Read-only by design — no write operations are exposed
- Integration-scoped access — Notion integrations can only access pages and databases explicitly shared with them via the "Connect to" menu
- No data persistence — the server is stateless and does not store any Notion data
- Stdio transport — communication happens over stdin/stdout, no network ports opened