mcp-outline
CLAUDE.md158 starsChanged 11 months agoMITView on GitHub
This guide helps you implement and modify the MCP Outline server effectively. This MCP server bridges AI assistants with Outline's document management platform: - REST API integration for Outline services - Tools for documents, collections, attachments, and comments - MCP resources via outline:// URI scheme - API key authentication with rate limiting - Docker and local development support - Health check endpoints for container orchestration For dynamic tool list architecture and scope matching details, see docs/dynamic-tool-list.md. Both reference MCP tools…
- Reads credentials
- Commits and pushes
In this repositoryOne other file it gives its agents.
- outlineSkill
The file
Show its rules and text
We split this CLAUDE.md into the rules it gives an agent
25 rules. About 0 are common, 13 are distinctive, and 12 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈13
Distinctive · estimate
This author's own choices. Worth a look.
12
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 20
Reading: Read content (with pagination), TOC, section reading, in-document search (grep-style), export markdown
- Distinctive: line 23
Content: Create, update, comment (supports templates)
- Distinctive: line 27
Collections: Create, update, delete, export
- Distinctive: line 264
Import order: stdlib -> third-party -> local
- Distinctive: line 313
ALL environment variables it touches.
- Very specific: line 315
Use AsyncMock for client mocking, not manual mocks
- Very specific: line 316
Test names: test_<method>_<scenario> (e.g., test_create_document_as_template)
- Very specific: line 317
Every new parameter needs at least two tests: one with value set, one verifying it's not sent when None/default
- Distinctive: line 322
Run against a real Outline instance via Docker Compose:
- Distinctive: line 382
Read-only mode takes precedence: If both are set, server operates in read-only mode
- Distinctive: line 386
No stdout/stderr logging (MCP uses stdio)
- Very specific: line 388
Use async def for ALL tool functions
- Very specific: line 389
Use await for ALL client method calls
- Very specific: line 390
Always use await get_outline_client() to get client instance
- Very specific: line 392
Use ToolAnnotations on all tools (read_only_hint, destructive_hint, etc.)
- Distinctive: line 397
IMPORTANT: Before committing, run all CI checks locally to ensure they pass:
- Distinctive: line 424
After pushing, verify all GitHub Actions checks pass.
- Very specific: line 449
If checks are still running (in_progress or queued), wait and re-run the command.
- Very specific: line 470
Pagination: Use offset and limit parameters for large result sets
- Very specific: line 476
Tool Annotations: All tools should include ToolAnnotations with appropriate hints
- Distinctive: line 478
Conditional Registration: Use environment variables to control which tools are registered
- Distinctive: line 484
Look at changes since last version.
- Very specific: line 487
Only fix: commits → patch version bump
- Very specific: line 491
Use uv run poe bump-version <new_version> to update all version files:
- Distinctive: line 502
Commit and create PR with the version bump
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
MCP Outline Server Guide
This guide helps you implement and modify the MCP Outline server effectively.
Purpose
This MCP server bridges AI assistants with Outline's document management platform:
- REST API integration for Outline services
- Tools for documents, collections, attachments, and comments
- MCP resources via
outline://URI scheme - API key authentication with rate limiting
- Docker and local development support
- Health check endpoints for container orchestration
Architecture
Tool Categories
- Search: Find documents, collections, hierarchies
- Reading: Read content (with pagination), TOC, section reading, in-document search (grep-style), export markdown
- Editing: String-match editing with optional staging
- Attachments: Resolve URLs, fetch content, list attachments
- Content: Create, update, comment (supports templates)
- Organization: Move documents between collections
- Lifecycle: Archive, delete, restore operations
- Collaboration: Comments, backlinks
- Collections: Create, update, delete, export
- Batch Operations: Bulk create, update, move, archive, delete
- AI: Natural language queries
Feature Registration Flow
register_all(mcp)
|- health.register_routes(mcp) # Always
|- documents.register(mcp)
| |- document_search.register_tools() # Always (recent-changes tool: if not OUTLINE_DISABLE_RECENT_DOCUMENTS)
| |- document_reading.register_tools() # Always
| |- document_navigation.register_tools() # Always
| |- document_attachments.register_tools() # Always
| |- document_collaboration.register_tools() # Always
| |- collection_tools.register_tools() # Always (exports always, writes conditional)
| |- ai_tools.register_tools() # If not OUTLINE_DISABLE_AI_TOOLS
| |- document_content.register_tools() # If not OUTLINE_READ_ONLY
| |- document_editing.register_tools() # If not OUTLINE_READ_ONLY
| |- document_lifecycle.register_tools() # If not OUTLINE_READ_ONLY
| |- document_organization.register_tools() # If not OUTLINE_READ_ONLY
| |- batch_operations.register_tools() # If not OUTLINE_READ_ONLY
|- resources.register(mcp) # Always
install_dynamic_tool_list(mcp) # If OUTLINE_DYNAMIC_TOOL_LIST=trueFor dynamic tool list architecture and scope matching details, see docs/dynamic-tool-list.md.
Plugin Components (Claude Code)
agents/outline-explorer.md— fast read-only exploration agent (search, TOC-first reading, synthesis)skills/outline/SKILL.md— Outline conventions skill (mermaidjs fences, structure, editing/staging workflows, search status filters)
Both reference MCP tools by name. When renaming tools or changing
parameters, update the agent, the skill, and the server instructions
(_build_instructions in server.py) together.
MCP Resources (outline:// URI scheme)
outline://document/{document_id}- Full markdown contentoutline://document/{document_id}/backlinks- Documents linking to this oneoutline://collection/{collection_id}- Collection metadataoutline://collection/{collection_id}/tree- Hierarchical document treeoutline://collection/{collection_id}/documents- List documents in collection
Health Check Endpoints
GET /health- Liveness probe (always returns 200)GET /ready- Readiness probe (verifies API connectivity, returns 503 if not ready)
Core Concepts
Outline Objects
- Documents: Markdown content with title, URL, and metadata
- Collections: Grouping with name, description, color
- Comments: Threaded discussions with replies and anchor text
- Attachments: Binary files referenced in document content
- Hierarchy: Parent-child document relationships
- Templates: Documents marked as templates appear in "New from template" picker
- Lifecycle: Draft -> Published -> Archived -> Deleted
API Client
OutlineClient in utils/outline_client.py handles async REST API interactions:
Operations (all async):
- Documents: get, search, list, create, update, move, archive, unarchive, delete, restore
- Collections: list, get, get_documents, create, update, delete, export, export_all
- Comments: create, list, get
- Attachments: get_redirect_url, fetch_content
- AI: answer questions
- API Keys: list_api_keys (scope introspection for dynamic tool list)
Connection Pooling:
- Uses httpx with class-level connection pool
- Shared across all OutlineClient instances
- Automatic connection reuse for better performance
- Configurable limits via environment variables
Rate Limiting:
- Tracks
RateLimit-RemainingandRateLimit-Resetheaders, waits proactively when exhausted - Uses asyncio.Lock for thread-safe rate limiting in concurrent scenarios
- Automatic retry with exponential backoff (max 3 attempts)
- Respects
Retry-Afterheader on HTTP 429 responses - Enabled by default, no configuration required
Error Handling:
- Raises
OutlineErrorfor API failures - Tools catch exceptions and return error strings
- Supports httpx exceptions (RequestError, HTTPStatusError, TimeoutException)
Common Utilities (features/documents/common.py)
get_outline_client()- Async function that creates an OutlineClient. Checks for a per-user Outline API key from thex-outline-api-keyHTTP header first (SSE/streamable-http), then falls back toOUTLINE_API_KEYenv var.get_resolved_api_key()- Returns the resolved API key (header > env var) as a string. Used by both the client factory and the document cache for keying._get_header_api_key()- Reads thex-outline-api-keyheader throughrequest_context.get_request_header(). ReturnsNonefor stdio or when the header is absent.OutlineClientError- Exception class for client-related errors
Document Cache (utils/document_cache.py)
In-memory LRU cache with configurable TTL for document content. Reduces Outline API calls and enables staged edits.
- Key:
(api_key, document_id)tuple for multi-tenant isolation - Implementation:
OrderedDict+asyncio.Lock - TTL:
OUTLINE_CACHE_TTLenv var (default30— absorbs same-task read bursts;0disables caching, higher values save more API calls) - Max size:
OUTLINE_CACHE_MAX_SIZEenv var (default 100) - Dirty tracking: Staged edits are stored via
stage_text()(dirty upsert); LRU eviction,evict_document(), andput()all preserve dirty entries so staged work is never silently lost - Singleton:
get_document_cache()returns module-level instance;reset_document_cache()for tests
Reading tools (read_document, get_document_toc, read_document_section) cache on read via get_cached_or_fetch() and append an unsaved-changes notice when serving staged (dirty) text.
Edit tools (edit_document) operate on cached text; save=True (default) pushes to Outline immediately. edit_document(edits=[], save=True) flushes staged changes.
Writers (update_document, batch_update_documents, delete_document) explicitly evict the caller's own entry (evict()) plus all clean copies (evict_document()) after a successful API call; other users' staged edits survive and surface a conflict at their own save time.
Server Middleware
server.py passes these to MCPServer(middleware=[...]), outermost first:
request_context.capture_request_headersstores the HTTP request headers of each message in a ContextVar.get_request_header()reads them. The value isNonefor stdio.patches.coerce_empty_tool_argumentsturns the""arguments that GitHub Copilot CLI sends for a tool with no parameters into{}, before the SDK validates them.
install_dynamic_tool_list appends a third middleware that filters the tools/list result.
Implementation Patterns
Module Structure
Feature modules follow this pattern:
# 1. Imports (standard lib -> third-party -> local)
import os
from typing import Any, Optional
from mcp.server.mcpserver import MCPServer
from mcp.types import ToolAnnotations
from mcp_outline.features.documents.common import (
get_outline_client,
OutlineClientError,
)
# 2. Helper formatters (private functions)
def _format_search_results(data: dict) -> str:
"""Format API response for user display."""
# Clean, readable output formatting
pass
# 3. Tool registration function
def register_tools(mcp: MCPServer) -> None:
"""Register all tools in this module."""
@mcp.tool(
annotations=ToolAnnotations(
read_only_hint=True,
destructive_hint=False,
idempotent_hint=True,
open_world_hint=True,
)
)
async def search_documents(
query: str,
collection_id: Optional[str] = None,
) -> str:
"""
Search for documents by keywords.
Args:
query: Search keywords
collection_id: Optional collection filter
Returns:
Formatted search results
"""
try:
client = await get_outline_client()
result = await client.search_documents(query, collection_id)
return _format_search_results(result)
except OutlineClientError as e:
return f"Outline API error: {str(e)}"
except Exception as e:
return f"Error: {str(e)}"Adding New Tools
Client Method (if new endpoint needed):
async def new_operation(self, param: str) -> dict:
"""Docstring describing operation."""
response = await self.post("endpoint", {"param": param})
return response.get("data", {})Tool Function:
@mcp.tool(
annotations=ToolAnnotations(
read_only_hint=False,
destructive_hint=False,
),
meta={"endpoint": "namespace.method", "min_role": "member"},
)
async def new_tool_name(param: str) -> str:
"""Clear description."""
try:
client = await get_outline_client()
result = await client.new_operation(param)
return _format_result(result)
except OutlineClientError as e:
return f"Outline API error: {str(e)}"
except Exception as e:
return f"Error: {str(e)}"The meta dict requires two fields:
"endpoint"— the Outline API endpoint (e.g.documents.create,collections.list). Used for scope matching."min_role"— minimum Outline role:"viewer","member", or"admin". Used for role-based filtering. Verified against Outline route handlers (collections.ts,documents.ts) andAuthenticationHelper.ts.
The endpoint map and role-blocked map are derived automatically from
tool metadata by introspect.py — no separate map files need updating.
Testing: Mock OutlineClient, test success and error cases
Technical Requirements
Code Style
- PEP 8 conventions
- Type hints for all functions
- Max line length: 79 characters (ruff enforced)
- Google-style docstrings
- Import order: stdlib -> third-party -> local
- Single responsibility per function
Error Handling
# In OutlineClient methods
try:
response = await self._client_pool.post(url, headers=headers, json=data)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise OutlineError(f"Rate limited")
raise OutlineError(f"HTTP {e.response.status_code}: {e.response.text}")
except httpx.TimeoutException as e:
raise OutlineError(f"Request timeout: {str(e)}")
except httpx.RequestError as e:
raise OutlineError(f"API request failed: {str(e)}")
# In tool functions
try:
client = await get_outline_client()
result = await client.operation()
return format_result(result)
except OutlineClientError as e:
return f"Outline API error: {str(e)}"
except Exception as e:
return f"Error: {str(e)}"Testing
Mock OutlineClient in async tests:
@pytest.mark.asyncio
async def test_tool():
with patch("module.get_outline_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.method.return_value = {"data": "value"}
mock_get_client.return_value = mock_client
result = await tool_function("param")
assert "expected" in resultTest Conventions:
TestOutlineClientusessetup_method/teardown_methodto save and restore ALL environment variables it touches. New env vars MUST be added to both methods.- Tool test classes use
MockMCPfixture andregister_*_toolspattern - Use
AsyncMockfor client mocking, not manual mocks - Test names:
test_<method>_<scenario>(e.g.,test_create_document_as_template) - Every new parameter needs at least two tests: one with value set, one verifying
it's not sent when
None/default
E2E Tests
Run against a real Outline instance via Docker Compose:
uv run poe test-e2e- Marker:
@pytest.mark.e2e— excluded from normalpytestruns - Fixture chain:
outline_stack(Docker lifecycle) →_outline_credentials(OIDC login) →outline_api_key(API key creation) /outline_access_token(session token) →mcp_server_params→mcp_session(stdio client factory) - OIDC fixture: Uses manual cookie management (
_parse_set_cookies) to prevent httpx's cookie jar from leaking Outline cookies to Dex (both run on localhost but on different ports) - Attachment tests: Upload a real file via the Outline REST API
(
attachments.create+files.create) using the API key, then test the read-only MCP attachment tools against it - Skipped tools:
- AI tool (
ask_ai_about_documents): Disabled viaOUTLINE_DISABLE_AI_TOOLS
- AI tool (
Configuration
.env file:
# Outline API (optional — if unset, every request must include x-outline-api-key header)
OUTLINE_API_KEY=<your_key>
# Outline API (optional)
OUTLINE_API_URL=<custom_url> # Default: https://app.getoutline.com/api
OUTLINE_VERIFY_SSL=true # Default: true (set false for self-signed certs)
# Connection pooling (optional)
OUTLINE_MAX_CONNECTIONS=100 # Max concurrent connections
OUTLINE_MAX_KEEPALIVE=20 # Max idle connections in pool
OUTLINE_TIMEOUT=30.0 # Read timeout in seconds
OUTLINE_CONNECT_TIMEOUT=5.0 # Connection timeout in seconds
OUTLINE_WRITE_TIMEOUT=30.0 # Write timeout in seconds
# Document cache (optional)
OUTLINE_CACHE_TTL=30 # Cache TTL in seconds (default: 30; 0 disables)
OUTLINE_CACHE_MAX_SIZE=100 # Max cached documents (default: 100)
# Feature flags (optional)
OUTLINE_DISABLE_AI_TOOLS=true # Disable AI tools
OUTLINE_READ_ONLY=true # Disable all write operations
OUTLINE_DISABLE_DELETE=true # Disable delete operations only
OUTLINE_DISABLE_RECENT_DOCUMENTS=true # Disable the recent-changes tool only
OUTLINE_DYNAMIC_TOOL_LIST=true # Enable per-user tool filtering (requires apiKeys.list scope)
# MCP server (optional)
MCP_TRANSPORT=stdio # Transport: stdio, sse, streamable-http
MCP_HOST=127.0.0.1 # Server host (use 0.0.0.0 for Docker)
MCP_PORT=3000 # Server portAccess Control Notes:
OUTLINE_READ_ONLY: Blocks entire write modules at registration (content, lifecycle, organization, batch_operations)OUTLINE_DISABLE_DELETE: Conditionally registers delete tools within document_lifecycle and collection_toolsOUTLINE_DISABLE_RECENT_DOCUMENTS: Conditionally registers thelist_recently_updated_documentstool within document_search (opt-out; registered by default)OUTLINE_DYNAMIC_TOOL_LIST: Off by default. UsesapiKeys.listto introspect API key scopes and filters tools per-user based on scope matching. Scoped API keys must includeapiKeys.listin their scope array for introspection to work. Fail-open: if scope introspection fails, all tools are shown. Set totrueto enable.- Read-only mode takes precedence: If both are set, server operates in read-only mode
Critical Requirements
- No stdout/stderr logging (MCP uses stdio)
- Tools return strings, not dicts
- Use
async deffor ALL tool functions - Use
awaitfor ALL client method calls - Always use
await get_outline_client()to get client instance - Catch exceptions, return error strings
- Use
ToolAnnotationson all tools (read_only_hint, destructive_hint, etc.) - Follow KISS principle
Pre-Commit Checks
IMPORTANT: Before committing, run all CI checks locally to ensure they pass:
# Format code
uv run ruff format .
# Check formatting
uv run ruff format --check .
# Lint code
uv run ruff check .
# Type check
uv run pyright src/
# Run tests
uv run poe test-unit
# Run integration tests
uv run poe test-integration
# Run E2E tests (requires Docker)
uv run poe test-e2eVerifying CI on GitHub
After pushing, verify all GitHub Actions checks pass. E2E tests run in CI and cannot be fully replicated locally without the Docker Compose E2E stack. Use the GitHub API to check status:
# Get status of all check runs for a commit
curl -s "https://api.github.com/repos/Vortiago/mcp-outline/commits/<SHA>/check-runs" \
-H "Accept: application/vnd.github+json" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for cr in data.get('check_runs', []):
print(f'{cr[\"name\"]}: {cr[\"status\"]}/{cr[\"conclusion\"]}')
print(f'Total: {data.get(\"total_count\", 0)}')
"Replace <SHA> with the full or abbreviated commit hash. Expected
checks (all must show completed/success):
- Unit Tests (Python 3.10, 3.11, 3.12, 3.13)
- CodeQL (actions + python analyses)
- E2E Tests + E2E Test Report
- Build
If checks are still running (in_progress or queued),
wait and re-run the command. E2E tests typically take 2-4 minutes.
To get failure details (test annotations) for a specific check run:
# List failed test annotations for a check run
curl -s "https://api.github.com/repos/Vortiago/mcp-outline/check-runs/<CHECK_RUN_ID>/annotations" \
-H "Accept: application/vnd.github+json" \
| python3 -c "
import sys, json
for a in json.load(sys.stdin):
print(f'{a[\"path\"]}:{a[\"start_line\"]} - {a[\"message\"]}')
"The <CHECK_RUN_ID> is available in the check-runs response
(cr["id"]).
Common Patterns
Pagination: Use offset and limit parameters for large result sets
Tree Formatting: Recursive formatting with indentation for hierarchies
Document ID Resolution: get_document_id_from_title for user-friendly lookups
Tool Annotations: All tools should include ToolAnnotations with appropriate hints
Conditional Registration: Use environment variables to control which tools are registered
Version Tagging & Release
Version Number Rules
Look at changes since last version. First match wins (left to right):
- Any
feat!:commit → major version bump - Any
feat:commit → minor version bump - Only
fix:commits → patch version bump
Bump Script
Use uv run poe bump-version <new_version> to update all version files:
server.json(top-level and packages version).claude-plugin/plugin.json.claude-plugin/marketplace.json.mcp.json(pinned uvx version in args)
The script validates that the new version is a valid semver bump (patch, minor, or major) from the current version. It rejects invalid formats, downgrades, and arbitrary jumps.
Release Workflow
uv run poe bump-version <version>— update all version files- Commit and create PR with the version bump
- Merge the PR
- Tag the merged commit:
git tag -a v<version> -m "summary" - Push the tag:
git push origin v<version> - CI handles: PyPI publish, GitHub release, Docker build
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

