This is a submission for the Notion MCP Challenge
What I Built
Codebase Cortex is a multi-agent AI system that automatically keeps engineering documentation in sync with code. You commit code — your Notion docs update themselves.
Why not just use Claude Code / Cursor / Copilot for docs?
Coding AI tools are great at writing documentation — when you prompt them. The problem is you have to keep prompting. Every commit, you need to remember to ask, check if the right pages were updated, and verify nothing was overwritten. It's still a manual process — just with better writing.
Cortex is different because it's autonomous:
| Coding AI Tools | Codebase Cortex | |
|---|---|---|
| Trigger | You prompt it manually | Runs automatically on git commit |
| Scope | Updates what you point it to | Finds related docs via semantic search across the entire codebase |
| Consistency | Different output each time | Deterministic section-level merge — unchanged sections stay identical |
| Memory | Forgets between sessions | FAISS index + page cache persist across runs |
| Multi-page | You manage which pages to update | Agents discover which pages need updating |
| Tracking | No audit trail | Sprint reports, task creation, content hashes |
| Workflow | Interactive (needs your attention) | Background process (runs after commit, logs results) |
Think of it like the difference between manually running a linter versus having it in your CI pipeline. Both do the same job — only one actually happens consistently.
The Agents
The system uses five specialized LangGraph agents that work in sequence:
- CodeAnalyzer — Reads git diffs and produces a structured analysis of what changed
- SemanticFinder — Searches a FAISS vector index to find semantically related code across the entire codebase
- DocWriter — Fetches current Notion pages via MCP, generates section-level updates, and merges them deterministically
- TaskCreator — Identifies undocumented areas and creates task pages in Notion
- SprintReporter — Generates weekly sprint summaries and appends them to a Sprint Log page
Key Features
- Automatic doc sync — Post-commit git hook triggers the pipeline with zero human effort
- Section-level updates — Only changed sections are rewritten; unchanged sections are preserved exactly as-is
- Semantic search — FAISS embeddings (all-MiniLM-L6-v2, 384-dim) find related code, not just filename matches
-
Natural language prompts —
cortex prompt "Make the API docs more detailed"for directed updates - Multi-page intelligence — Agents understand relationships across all doc pages
- Confirmation flow — Shows planned changes and asks for approval before writing
- Pluggable LLMs — Works with Google Gemini, Anthropic Claude, or any OpenRouter model
-
Published on PyPI —
pip install codebase-cortex
The Pipeline
Git Commit
↓
CodeAnalyzer (parse diff, LLM analysis)
↓
SemanticFinder (embed analysis, FAISS search for top-10 related chunks)
↓
DocWriter (fetch Notion pages via MCP → LLM generates section_updates → deterministic merge → write via MCP)
↓
TaskCreator (LLM identifies gaps → create task pages in Notion via MCP)
↓
SprintReporter (LLM generates summary → append to Sprint Log via MCP)
↓
Notion Workspace (updated docs, new tasks, sprint report)
Section-Level Merging (the core innovation)
Instead of rewriting entire pages (which causes "LLM drift"), DocWriter:
- Fetches the existing page from Notion via
notion-fetch - Parses it into sections split by markdown headings
- Asks the LLM to return ONLY the sections that changed
- Merges the changes into the existing structure deterministically
- Writes the full merged content back via
notion-update-page
This means a page with 10 sections where 1 section needs updating gets exactly 1 section changed. The other 9 sections are byte-for-byte identical to before.
Video Demo
Install and try it:
pip install codebase-cortex
cd your-project
cortex init # Interactive setup (LLM, Notion OAuth, starter pages)
cortex run --once # Run the full pipeline
Project structure
codebase-cortex/
├── src/codebase_cortex/
│ ├── cli.py # Click CLI (init, run, status, prompt, scan, embed)
│ ├── config.py # Settings, get_llm() factory
│ ├── state.py # CortexState TypedDict (shared pipeline state)
│ ├── graph.py # LangGraph StateGraph with conditional routing
│ ├── mcp_client.py # Notion MCP connection (Streamable HTTP + OAuth)
│ ├── agents/
│ │ ├── base.py # BaseAgent ABC with LLM invocation
│ │ ├── code_analyzer.py # Git diff / full codebase analysis
│ │ ├── semantic_finder.py # FAISS similarity search
│ │ ├── doc_writer.py # Section-level Notion page updates
│ │ ├── task_creator.py # Task page creation
│ │ └── sprint_reporter.py # Sprint summary generation
│ ├── auth/ # OAuth 2.0 + PKCE for Notion
│ ├── embeddings/ # sentence-transformers, FAISS, HDBSCAN
│ ├── git/ # Diff parsing, GitHub client
│ ├── notion/ # Page bootstrap and cache
│ └── utils/ # Rate limiter, section parser, JSON parsing
├── tests/ # 74 tests
├── docs/ # Full documentation with mermaid diagrams
└── pyproject.toml
Key technical decisions
| Decision | Why |
|---|---|
| LangGraph StateGraph | Conditional routing — skip agents when no changes detected, skip sprint report when nothing to report |
| FAISS IndexFlatL2 | Exact nearest-neighbor search, no training required, sub-millisecond for medium codebases |
| Section-level merging | Deterministic merge prevents LLM drift; unchanged sections preserved exactly |
| sentence-transformers | all-MiniLM-L6-v2 is fast, CPU-friendly, 384-dim — good balance of quality and speed |
| HDBSCAN | Auto-determines cluster count, handles noise — better than k-means for code topology |
| Dual rate limiter | Async token bucket (180 req/min general + 30 req/min search) matches Notion's limits exactly |
| OAuth 2.0 + PKCE | Browser-based auth with dynamic client registration — zero manual token setup |
How I Used Notion MCP
Codebase Cortex uses Notion MCP as its primary interface to Notion — every read and write goes through MCP. The system connects via Streamable HTTP to https://mcp.notion.com/mcp with OAuth bearer token authentication.
MCP tools used and how
notion-fetch — Read page content
Used by DocWriter to fetch current page content before generating updates. Also used during init to detect page renames and sync titles back to the local cache.
result = await session.call_tool(
"notion-fetch",
arguments={"id": page_id},
)
content = strip_notion_metadata(result.content[0].text)
notion-update-page — Write page updates
Used by DocWriter to write section-merged content, and by SprintReporter to append sprint summaries.
# Replace entire page content (after local section merge)
await session.call_tool(
"notion-update-page",
arguments={
"page_id": page_id,
"command": "replace_content",
"new_str": merged_content,
},
)
# Append to existing page (sprint reports)
await session.call_tool(
"notion-update-page",
arguments={
"page_id": page_id,
"command": "insert_content_after",
"new_str": sprint_report,
},
)
notion-create-pages — Create new pages
Used by DocWriter for new documentation pages, TaskCreator for task pages, and the bootstrap process for starter pages.
result = await session.call_tool(
"notion-create-pages",
arguments={
"pages": [{
"properties": {"title": "API Reference"},
"content": "# API Reference\n\n..."
}],
"parent": {"page_id": parent_id},
},
)
notion-search — Search workspace
Used by the cortex scan command to discover existing pages and by the page discovery process to find new child pages.
What Notion MCP unlocks
Zero-setup Notion access — OAuth 2.0 with PKCE means users just click "Allow" in their browser. No manual integration creation, no API token copying, no Notion developer portal.
AI-native page interaction — MCP returns content in markdown format, which is exactly what LLMs work best with. No HTML parsing, no block-level API complexity.
Section-level updates — The
replace_contentcommand lets us write full merged content after doing section-level merging locally. This gives us deterministic control over what changes.Stateless connection — Each pipeline run opens a fresh MCP session with auto-refreshed tokens. No persistent connection to manage, no websocket heartbeats.
Rate-limit friendly — The dual token bucket (180/min general, 30/min search) ensures we never hit Notion's limits, even when updating multiple pages in a single pipeline run.
Handling Notion MCP quirks
One non-obvious challenge: the notion-fetch tool returns page content with literal \n (backslash + n) instead of real newline characters. This broke section parsing until we added an unescape step:
def _unescape_notion_text(text: str) -> str:
"""Convert literal \\n and \\t from Notion MCP to real characters."""
result = []
i = 0
while i < len(text):
if text[i] == '\\' and i + 1 < len(text):
next_char = text[i + 1]
if next_char == 'n':
result.append('\n')
i += 2
continue
elif next_char == 't':
result.append('\t')
i += 2
continue
result.append(text[i])
i += 1
return ''.join(result)
This is applied before any content parsing, making the MCP encoding transparent to the rest of the system.
MCP integration architecture
cortex run --once
↓
[Agents run, produce doc_updates]
↓
DocWriter opens MCP session (OAuth token auto-refresh)
↓
For each page to update:
→ rate_limiter.acquire()
→ notion-fetch (read current content)
→ _unescape_notion_text() + strip_notion_metadata()
→ parse_sections() + LLM generates section_updates
→ merge_sections() (deterministic)
→ rate_limiter.acquire()
→ notion-update-page (replace_content)
→ page_cache.upsert() (update content hash)
↓
MCP session closes
Every MCP call goes through an async rate limiter and a LoggingSession wrapper that logs tool names, arguments, and response previews when verbose mode is enabled.
Top comments (0)