Initial commit: Outline MCP server with hybrid light pattern
Some checks failed
Build and Push Docker Image / build (push) Failing after 0s
Some checks failed
Build and Push Docker Image / build (push) Failing after 0s
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Outline MCP Server Configuration
|
||||||
|
|
||||||
|
# Outline instance URL (without trailing slash)
|
||||||
|
OUTLINE_API_URL=https://docs.example.com
|
||||||
|
|
||||||
|
# API token from Outline Settings > API Tokens
|
||||||
|
OUTLINE_API_TOKEN=your_api_token_here
|
||||||
35
.gitea/workflows/build.yaml
Normal file
35
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.ext.ben.io
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gitea.ext.ben.io/b3nw/outline-mcp-custom:latest
|
||||||
|
gitea.ext.ben.io/b3nw/outline-mcp-custom:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
1
BLUEPRINT.md
Symbolic link
1
BLUEPRINT.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../BLUEPRINT.md
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
CMD ["python", "server.py"]
|
||||||
386
IMPLEMENTATION.md
Normal file
386
IMPLEMENTATION.md
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
# Outline MCP Implementation Guide
|
||||||
|
|
||||||
|
This document provides implementation details and comprehensive API reference for AI agents using the Outline MCP server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The server follows the Hybrid MCP Light pattern:
|
||||||
|
- 4 specific tools for common read operations
|
||||||
|
- 1 API passthrough for full coverage
|
||||||
|
- Embedded API reference resource
|
||||||
|
|
||||||
|
## Tools Overview
|
||||||
|
|
||||||
|
### search_documents
|
||||||
|
Full-text search across all documents.
|
||||||
|
```
|
||||||
|
Params: query (required), collection_id, limit (default 25), include_archived
|
||||||
|
Returns: Search results with document titles, IDs, and context snippets
|
||||||
|
```
|
||||||
|
|
||||||
|
### get_document
|
||||||
|
Retrieve a document by ID.
|
||||||
|
```
|
||||||
|
Params: document_id (required), share_id
|
||||||
|
Returns: Document metadata and full Markdown content
|
||||||
|
```
|
||||||
|
|
||||||
|
### list_collections
|
||||||
|
List all collections in the workspace.
|
||||||
|
```
|
||||||
|
Params: limit (default 25), offset
|
||||||
|
Returns: Collection names, IDs, permissions, and metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
### list_collection_documents
|
||||||
|
List documents within a specific collection.
|
||||||
|
```
|
||||||
|
Params: collection_id (required), limit, offset
|
||||||
|
Returns: Document tree structure for the collection
|
||||||
|
```
|
||||||
|
|
||||||
|
### outline_api_call
|
||||||
|
Raw API passthrough for any Outline endpoint.
|
||||||
|
```
|
||||||
|
Params: method (required), params (JSON string)
|
||||||
|
Returns: Raw API response as JSON string
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outline API Reference
|
||||||
|
|
||||||
|
Outline uses an RPC-style API where all requests are POST to /api/<method>.
|
||||||
|
All responses have format: {"ok": true/false, "data": ...}
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
All requests require Bearer token: Authorization: Bearer <token>
|
||||||
|
Get your token from Outline Settings > API Tokens.
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
Most list endpoints accept: limit (default 25, max 100), offset (default 0)
|
||||||
|
Response includes: pagination.nextPath if more results exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
### documents.list
|
||||||
|
List documents. Params: collectionId?, userId?, parentDocumentId?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
### documents.info
|
||||||
|
Get document by ID. Params: id (required), shareId?
|
||||||
|
|
||||||
|
### documents.search
|
||||||
|
Full-text search. Params: query (required), collectionId?, userId?, dateFilter?, includeArchived?, includeDrafts?, limit?, offset?
|
||||||
|
|
||||||
|
### documents.create
|
||||||
|
Create document. Params: title (required), collectionId (required), text?, parentDocumentId?, templateId?, template?, publish?
|
||||||
|
|
||||||
|
### documents.update
|
||||||
|
Update document. Params: id (required), title?, text?, append?, publish?, done?
|
||||||
|
|
||||||
|
### documents.delete
|
||||||
|
Delete document. Params: id (required), permanent?
|
||||||
|
|
||||||
|
### documents.move
|
||||||
|
Move document. Params: id (required), collectionId?, parentDocumentId?
|
||||||
|
|
||||||
|
### documents.archive
|
||||||
|
Archive document. Params: id (required)
|
||||||
|
|
||||||
|
### documents.unarchive
|
||||||
|
Restore archived document. Params: id (required)
|
||||||
|
|
||||||
|
### documents.restore
|
||||||
|
Restore deleted document. Params: id (required), revisionId?
|
||||||
|
|
||||||
|
### documents.export
|
||||||
|
Export document. Params: id (required)
|
||||||
|
Returns Markdown content.
|
||||||
|
|
||||||
|
### documents.import
|
||||||
|
Import document. Multipart form: file, collectionId, parentDocumentId?, template?, publish?
|
||||||
|
|
||||||
|
### documents.star
|
||||||
|
Star a document. Params: id (required)
|
||||||
|
|
||||||
|
### documents.unstar
|
||||||
|
Remove star. Params: id (required)
|
||||||
|
|
||||||
|
### documents.templatize
|
||||||
|
Convert to template. Params: id (required)
|
||||||
|
|
||||||
|
### documents.unpublish
|
||||||
|
Unpublish document. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
### collections.list
|
||||||
|
List all collections. Params: limit?, offset?
|
||||||
|
|
||||||
|
### collections.info
|
||||||
|
Get collection details. Params: id (required)
|
||||||
|
|
||||||
|
### collections.documents
|
||||||
|
Get document structure for collection. Params: id (required), limit?, offset?
|
||||||
|
|
||||||
|
### collections.create
|
||||||
|
Create collection. Params: name (required), description?, color?, permission?, sharing?
|
||||||
|
|
||||||
|
### collections.update
|
||||||
|
Update collection. Params: id (required), name?, description?, color?, permission?, sharing?
|
||||||
|
|
||||||
|
### collections.delete
|
||||||
|
Delete collection. Params: id (required)
|
||||||
|
|
||||||
|
### collections.add_user
|
||||||
|
Add user to collection. Params: id (required), userId (required), permission?
|
||||||
|
|
||||||
|
### collections.remove_user
|
||||||
|
Remove user from collection. Params: id (required), userId (required)
|
||||||
|
|
||||||
|
### collections.memberships
|
||||||
|
List collection members. Params: id (required), query?, permission?, limit?, offset?
|
||||||
|
|
||||||
|
### collections.add_group
|
||||||
|
Add group to collection. Params: id (required), groupId (required), permission?
|
||||||
|
|
||||||
|
### collections.remove_group
|
||||||
|
Remove group. Params: id (required), groupId (required)
|
||||||
|
|
||||||
|
### collections.group_memberships
|
||||||
|
List group memberships. Params: id (required), query?, permission?, limit?, offset?
|
||||||
|
|
||||||
|
### collections.export
|
||||||
|
Export entire collection. Params: id (required), format?
|
||||||
|
|
||||||
|
### collections.export_all
|
||||||
|
Export all collections. Params: format?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
### users.list
|
||||||
|
List workspace users. Params: query?, filter?, sort?, direction?, limit?, offset?
|
||||||
|
Filter options: all, active, invited, suspended, admins
|
||||||
|
|
||||||
|
### users.info
|
||||||
|
Get user by ID. Params: id (required)
|
||||||
|
|
||||||
|
### users.invite
|
||||||
|
Invite users. Params: invites (array of {email, name, role})
|
||||||
|
|
||||||
|
### users.update
|
||||||
|
Update user. Params: id (required), name?, avatarUrl?
|
||||||
|
|
||||||
|
### users.delete
|
||||||
|
Delete user. Params: id (required)
|
||||||
|
|
||||||
|
### users.suspend
|
||||||
|
Suspend user. Params: id (required)
|
||||||
|
|
||||||
|
### users.activate
|
||||||
|
Activate suspended user. Params: id (required)
|
||||||
|
|
||||||
|
### users.promote
|
||||||
|
Promote to admin. Params: id (required)
|
||||||
|
|
||||||
|
### users.demote
|
||||||
|
Remove admin. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
### groups.list
|
||||||
|
List groups. Params: query?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
### groups.info
|
||||||
|
Get group. Params: id (required)
|
||||||
|
|
||||||
|
### groups.create
|
||||||
|
Create group. Params: name (required)
|
||||||
|
|
||||||
|
### groups.update
|
||||||
|
Update group. Params: id (required), name (required)
|
||||||
|
|
||||||
|
### groups.delete
|
||||||
|
Delete group. Params: id (required)
|
||||||
|
|
||||||
|
### groups.memberships
|
||||||
|
List group members. Params: id (required), query?, limit?, offset?
|
||||||
|
|
||||||
|
### groups.add_user
|
||||||
|
Add user to group. Params: id (required), userId (required)
|
||||||
|
|
||||||
|
### groups.remove_user
|
||||||
|
Remove user from group. Params: id (required), userId (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
### comments.list
|
||||||
|
List comments on document. Params: documentId (required), limit?, offset?
|
||||||
|
|
||||||
|
### comments.create
|
||||||
|
Create comment. Params: documentId (required), data (required - ProseMirror JSON)
|
||||||
|
|
||||||
|
### comments.update
|
||||||
|
Update comment. Params: id (required), data (required)
|
||||||
|
|
||||||
|
### comments.delete
|
||||||
|
Delete comment. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revisions
|
||||||
|
|
||||||
|
### revisions.list
|
||||||
|
List document revisions. Params: documentId (required), limit?, offset?
|
||||||
|
|
||||||
|
### revisions.info
|
||||||
|
Get revision. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shares
|
||||||
|
|
||||||
|
### shares.list
|
||||||
|
List document shares. Params: documentId?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
### shares.info
|
||||||
|
Get share by ID or documentId. Params: id?, documentId?
|
||||||
|
|
||||||
|
### shares.create
|
||||||
|
Create share link. Params: documentId (required), published?, urlId?, includeChildDocuments?
|
||||||
|
|
||||||
|
### shares.update
|
||||||
|
Update share. Params: id (required), published?, urlId?, includeChildDocuments?
|
||||||
|
|
||||||
|
### shares.revoke
|
||||||
|
Revoke share. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stars
|
||||||
|
|
||||||
|
### stars.list
|
||||||
|
List starred documents. Params: limit?, offset?
|
||||||
|
|
||||||
|
### stars.create
|
||||||
|
Star an item. Params: documentId?, collectionId?
|
||||||
|
|
||||||
|
### stars.delete
|
||||||
|
Remove star. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### events.list
|
||||||
|
List audit events. Params: name?, actorId?, documentId?, collectionId?, auditLog?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Operations
|
||||||
|
|
||||||
|
### fileOperations.list
|
||||||
|
List file operations. Params: type?, limit?, offset?
|
||||||
|
|
||||||
|
### fileOperations.info
|
||||||
|
Get file operation status. Params: id (required)
|
||||||
|
|
||||||
|
### fileOperations.redirect
|
||||||
|
Get download URL. Params: id (required)
|
||||||
|
|
||||||
|
### fileOperations.delete
|
||||||
|
Delete file operation. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
### attachments.create
|
||||||
|
Create attachment upload URL. Params: name (required), documentId (required), size (required), contentType (required)
|
||||||
|
|
||||||
|
### attachments.delete
|
||||||
|
Delete attachment. Params: id (required)
|
||||||
|
|
||||||
|
### attachments.redirect
|
||||||
|
Get attachment URL. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
### auth.info
|
||||||
|
Get current user and team info. No params.
|
||||||
|
|
||||||
|
### auth.config
|
||||||
|
Get auth configuration. No params.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
### views.list
|
||||||
|
List document views. Params: documentId (required), limit?, offset?
|
||||||
|
|
||||||
|
### views.create
|
||||||
|
Record a view. Params: documentId (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Response Patterns
|
||||||
|
|
||||||
|
### Success
|
||||||
|
```json
|
||||||
|
{"ok": true, "data": {...}, "pagination": {"nextPath": "...", "limit": 25, "offset": 0}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error
|
||||||
|
```json
|
||||||
|
{"ok": false, "error": "message", "status": 401}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
HTTP 429 with Retry-After header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Create a document
|
||||||
|
```
|
||||||
|
method: "documents.create"
|
||||||
|
params: {"title": "My Document", "collectionId": "collection-uuid", "text": "# Hello\n\nDocument content here.", "publish": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update document content
|
||||||
|
```
|
||||||
|
method: "documents.update"
|
||||||
|
params: {"id": "document-uuid", "text": "# Updated Content\n\nNew content here."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a document
|
||||||
|
```
|
||||||
|
method: "documents.delete"
|
||||||
|
params: {"id": "document-uuid"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invite a user
|
||||||
|
```
|
||||||
|
method: "users.invite"
|
||||||
|
params: {"invites": [{"email": "user@example.com", "name": "User Name", "role": "member"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a collection
|
||||||
|
```
|
||||||
|
method: "collections.create"
|
||||||
|
params: {"name": "Engineering", "description": "Engineering documentation", "permission": "read_write"}
|
||||||
|
```
|
||||||
66
README.md
Normal file
66
README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Outline MCP Server
|
||||||
|
|
||||||
|
A lightweight MCP server for interacting with self-hosted [Outline](https://www.getoutline.com/) knowledge bases.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **4 specific tools** for common operations (search, get document, list collections)
|
||||||
|
- **API passthrough** for full Outline API coverage
|
||||||
|
- **Embedded API reference** resource for agent self-service
|
||||||
|
- **Docker-ready** with health checks
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `search_documents` | Full-text search across all documents |
|
||||||
|
| `get_document` | Retrieve a document by ID with Markdown content |
|
||||||
|
| `list_collections` | List all collections in the workspace |
|
||||||
|
| `list_collection_documents` | List documents within a specific collection |
|
||||||
|
| `outline_api_call` | Raw API passthrough for any Outline endpoint |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| URI | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `outline://api-reference` | Comprehensive Outline API documentation |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OUTLINE_API_URL=https://docs.example.com
|
||||||
|
OUTLINE_API_TOKEN=your_api_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your API token from Outline: **Settings > API Tokens**
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Endpoint
|
||||||
|
|
||||||
|
The MCP server is available at `http://localhost:8000/mcp`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
outline-mcp:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8100}:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[project]
|
||||||
|
name = "outline-mcp-custom"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for Outline knowledge base"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.0.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"starlette>=0.38.0",
|
||||||
|
"uvicorn>=0.30.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
559
server.py
Normal file
559
server.py
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Route, Mount
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
OUTLINE_API_URL = os.getenv("OUTLINE_API_URL", "").rstrip("/")
|
||||||
|
OUTLINE_API_TOKEN = os.getenv("OUTLINE_API_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
class OutlineClient:
|
||||||
|
"""HTTP client for Outline API with authentication."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_token: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_token = api_token
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def call(self, method: str, params: dict | None = None) -> dict:
|
||||||
|
"""Execute an Outline API call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: API method (e.g., 'documents.list', 'collections.info')
|
||||||
|
params: Optional parameters to send in request body
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API response as dict
|
||||||
|
"""
|
||||||
|
if params is None:
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.client.post(f"/api/{method}", json=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_body = e.response.text
|
||||||
|
try:
|
||||||
|
error_json = e.response.json()
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": error_json.get("message", str(e)),
|
||||||
|
"status": e.response.status_code,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": error_body or str(e),
|
||||||
|
"status": e.response.status_code,
|
||||||
|
}
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
return {"ok": False, "error": f"Request failed: {str(e)}"}
|
||||||
|
|
||||||
|
async def check_health(self) -> tuple[bool, str]:
|
||||||
|
"""Check API connectivity by calling auth.info."""
|
||||||
|
try:
|
||||||
|
result = await self.call("auth.info")
|
||||||
|
if result.get("ok"):
|
||||||
|
return True, "Connected"
|
||||||
|
return False, result.get("error", "Unknown error")
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._client and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize client
|
||||||
|
outline_client = OutlineClient(OUTLINE_API_URL, OUTLINE_API_TOKEN)
|
||||||
|
|
||||||
|
# --- FastMCP Server ---
|
||||||
|
mcp = FastMCP("Outline MCP")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_documents(
|
||||||
|
query: str,
|
||||||
|
collection_id: str = None,
|
||||||
|
limit: int = 25,
|
||||||
|
include_archived: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Search for documents in Outline using full-text search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
collection_id: Optional collection ID to search within
|
||||||
|
limit: Maximum number of results (default 25, max 100)
|
||||||
|
include_archived: Include archived documents in results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with search results containing document titles, IDs, and context
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"query": query,
|
||||||
|
"limit": min(limit, 100),
|
||||||
|
"includeArchived": include_archived,
|
||||||
|
}
|
||||||
|
if collection_id:
|
||||||
|
params["collectionId"] = collection_id
|
||||||
|
|
||||||
|
result = await outline_client.call("documents.search", params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_document(document_id: str, share_id: str = None) -> str:
|
||||||
|
"""Retrieve a document by ID, returning its full Markdown content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_id: The document UUID
|
||||||
|
share_id: Optional share ID for accessing shared documents
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with document metadata and Markdown content
|
||||||
|
"""
|
||||||
|
params = {"id": document_id}
|
||||||
|
if share_id:
|
||||||
|
params["shareId"] = share_id
|
||||||
|
|
||||||
|
result = await outline_client.call("documents.info", params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_collections(limit: int = 25, offset: int = 0) -> str:
|
||||||
|
"""List all collections (folders) in the Outline workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of results (default 25)
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with collection names, IDs, and metadata
|
||||||
|
"""
|
||||||
|
params = {"limit": limit, "offset": offset}
|
||||||
|
result = await outline_client.call("collections.list", params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_collection_documents(
|
||||||
|
collection_id: str, limit: int = 25, offset: int = 0
|
||||||
|
) -> str:
|
||||||
|
"""List all documents within a specific collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_id: The collection UUID
|
||||||
|
limit: Maximum number of results (default 25)
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with document tree structure for the collection
|
||||||
|
"""
|
||||||
|
params = {"id": collection_id, "limit": limit, "offset": offset}
|
||||||
|
result = await outline_client.call("collections.documents", params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def outline_api_call(method: str, params: str = "{}") -> str:
|
||||||
|
"""Execute a raw Outline API call for any endpoint.
|
||||||
|
|
||||||
|
Use the 'outline://api-reference' resource to discover available methods
|
||||||
|
and their parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: API method name (e.g., 'documents.create', 'users.list')
|
||||||
|
params: JSON string of parameters to send in request body
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string with raw API response
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- method='documents.create', params='{"title": "New Doc", "collectionId": "uuid"}'
|
||||||
|
- method='users.list', params='{"limit": 10}'
|
||||||
|
- method='documents.delete', params='{"id": "document-uuid"}'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed_params = json.loads(params) if params else {}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return json.dumps({"ok": False, "error": f"Invalid JSON params: {str(e)}"})
|
||||||
|
|
||||||
|
result = await outline_client.call(method, parsed_params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- API Reference Resource ---
|
||||||
|
API_REFERENCE = """# Outline API Reference
|
||||||
|
|
||||||
|
Outline uses an RPC-style API where all requests are POST to /api/<method>.
|
||||||
|
All responses have format: {"ok": true/false, "data": ...}
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
All requests require Bearer token: Authorization: Bearer <token>
|
||||||
|
Get your token from Outline Settings > API Tokens.
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
Most list endpoints accept: limit (default 25, max 100), offset (default 0)
|
||||||
|
Response includes: pagination.nextPath if more results exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
### documents.list
|
||||||
|
List documents. Params: collectionId?, userId?, parentDocumentId?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
### documents.info
|
||||||
|
Get document by ID. Params: id (required), shareId?
|
||||||
|
|
||||||
|
### documents.search
|
||||||
|
Full-text search. Params: query (required), collectionId?, userId?, dateFilter?, includeArchived?, includeDrafts?, limit?, offset?
|
||||||
|
|
||||||
|
### documents.create
|
||||||
|
Create document. Params: title (required), collectionId (required), text?, parentDocumentId?, templateId?, template?, publish?
|
||||||
|
|
||||||
|
### documents.update
|
||||||
|
Update document. Params: id (required), title?, text?, append?, publish?, done?
|
||||||
|
|
||||||
|
### documents.delete
|
||||||
|
Delete document. Params: id (required), permanent?
|
||||||
|
|
||||||
|
### documents.move
|
||||||
|
Move document. Params: id (required), collectionId?, parentDocumentId?
|
||||||
|
|
||||||
|
### documents.archive
|
||||||
|
Archive document. Params: id (required)
|
||||||
|
|
||||||
|
### documents.unarchive
|
||||||
|
Restore archived document. Params: id (required)
|
||||||
|
|
||||||
|
### documents.restore
|
||||||
|
Restore deleted document. Params: id (required), revisionId?
|
||||||
|
|
||||||
|
### documents.export
|
||||||
|
Export document. Params: id (required)
|
||||||
|
Returns Markdown content.
|
||||||
|
|
||||||
|
### documents.import
|
||||||
|
Import document. Multipart form: file, collectionId, parentDocumentId?, template?, publish?
|
||||||
|
|
||||||
|
### documents.star
|
||||||
|
Star a document. Params: id (required)
|
||||||
|
|
||||||
|
### documents.unstar
|
||||||
|
Remove star. Params: id (required)
|
||||||
|
|
||||||
|
### documents.templatize
|
||||||
|
Convert to template. Params: id (required)
|
||||||
|
|
||||||
|
### documents.unpublish
|
||||||
|
Unpublish document. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
### collections.list
|
||||||
|
List all collections. Params: limit?, offset?
|
||||||
|
|
||||||
|
### collections.info
|
||||||
|
Get collection details. Params: id (required)
|
||||||
|
|
||||||
|
### collections.documents
|
||||||
|
Get document structure for collection. Params: id (required), limit?, offset?
|
||||||
|
|
||||||
|
### collections.create
|
||||||
|
Create collection. Params: name (required), description?, color?, permission?, sharing?
|
||||||
|
|
||||||
|
### collections.update
|
||||||
|
Update collection. Params: id (required), name?, description?, color?, permission?, sharing?
|
||||||
|
|
||||||
|
### collections.delete
|
||||||
|
Delete collection. Params: id (required)
|
||||||
|
|
||||||
|
### collections.add_user
|
||||||
|
Add user to collection. Params: id (required), userId (required), permission?
|
||||||
|
|
||||||
|
### collections.remove_user
|
||||||
|
Remove user from collection. Params: id (required), userId (required)
|
||||||
|
|
||||||
|
### collections.memberships
|
||||||
|
List collection members. Params: id (required), query?, permission?, limit?, offset?
|
||||||
|
|
||||||
|
### collections.add_group
|
||||||
|
Add group to collection. Params: id (required), groupId (required), permission?
|
||||||
|
|
||||||
|
### collections.remove_group
|
||||||
|
Remove group. Params: id (required), groupId (required)
|
||||||
|
|
||||||
|
### collections.group_memberships
|
||||||
|
List group memberships. Params: id (required), query?, permission?, limit?, offset?
|
||||||
|
|
||||||
|
### collections.export
|
||||||
|
Export entire collection. Params: id (required), format?
|
||||||
|
|
||||||
|
### collections.export_all
|
||||||
|
Export all collections. Params: format?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
### users.list
|
||||||
|
List workspace users. Params: query?, filter?, sort?, direction?, limit?, offset?
|
||||||
|
Filter options: all, active, invited, suspended, admins
|
||||||
|
|
||||||
|
### users.info
|
||||||
|
Get user by ID. Params: id (required)
|
||||||
|
|
||||||
|
### users.invite
|
||||||
|
Invite users. Params: invites (array of {email, name, role})
|
||||||
|
|
||||||
|
### users.update
|
||||||
|
Update user. Params: id (required), name?, avatarUrl?
|
||||||
|
|
||||||
|
### users.delete
|
||||||
|
Delete user. Params: id (required)
|
||||||
|
|
||||||
|
### users.suspend
|
||||||
|
Suspend user. Params: id (required)
|
||||||
|
|
||||||
|
### users.activate
|
||||||
|
Activate suspended user. Params: id (required)
|
||||||
|
|
||||||
|
### users.promote
|
||||||
|
Promote to admin. Params: id (required)
|
||||||
|
|
||||||
|
### users.demote
|
||||||
|
Remove admin. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
### groups.list
|
||||||
|
List groups. Params: query?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
### groups.info
|
||||||
|
Get group. Params: id (required)
|
||||||
|
|
||||||
|
### groups.create
|
||||||
|
Create group. Params: name (required)
|
||||||
|
|
||||||
|
### groups.update
|
||||||
|
Update group. Params: id (required), name (required)
|
||||||
|
|
||||||
|
### groups.delete
|
||||||
|
Delete group. Params: id (required)
|
||||||
|
|
||||||
|
### groups.memberships
|
||||||
|
List group members. Params: id (required), query?, limit?, offset?
|
||||||
|
|
||||||
|
### groups.add_user
|
||||||
|
Add user to group. Params: id (required), userId (required)
|
||||||
|
|
||||||
|
### groups.remove_user
|
||||||
|
Remove user from group. Params: id (required), userId (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
### comments.list
|
||||||
|
List comments on document. Params: documentId (required), limit?, offset?
|
||||||
|
|
||||||
|
### comments.create
|
||||||
|
Create comment. Params: documentId (required), data (required - ProseMirror JSON)
|
||||||
|
|
||||||
|
### comments.update
|
||||||
|
Update comment. Params: id (required), data (required)
|
||||||
|
|
||||||
|
### comments.delete
|
||||||
|
Delete comment. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revisions
|
||||||
|
|
||||||
|
### revisions.list
|
||||||
|
List document revisions. Params: documentId (required), limit?, offset?
|
||||||
|
|
||||||
|
### revisions.info
|
||||||
|
Get revision. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shares
|
||||||
|
|
||||||
|
### shares.list
|
||||||
|
List document shares. Params: documentId?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
### shares.info
|
||||||
|
Get share by ID or documentId. Params: id?, documentId?
|
||||||
|
|
||||||
|
### shares.create
|
||||||
|
Create share link. Params: documentId (required), published?, urlId?, includeChildDocuments?
|
||||||
|
|
||||||
|
### shares.update
|
||||||
|
Update share. Params: id (required), published?, urlId?, includeChildDocuments?
|
||||||
|
|
||||||
|
### shares.revoke
|
||||||
|
Revoke share. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stars
|
||||||
|
|
||||||
|
### stars.list
|
||||||
|
List starred documents. Params: limit?, offset?
|
||||||
|
|
||||||
|
### stars.create
|
||||||
|
Star an item. Params: documentId?, collectionId?
|
||||||
|
|
||||||
|
### stars.delete
|
||||||
|
Remove star. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### events.list
|
||||||
|
List audit events. Params: name?, actorId?, documentId?, collectionId?, auditLog?, sort?, direction?, limit?, offset?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Operations
|
||||||
|
|
||||||
|
### fileOperations.list
|
||||||
|
List file operations. Params: type?, limit?, offset?
|
||||||
|
|
||||||
|
### fileOperations.info
|
||||||
|
Get file operation status. Params: id (required)
|
||||||
|
|
||||||
|
### fileOperations.redirect
|
||||||
|
Get download URL. Params: id (required)
|
||||||
|
|
||||||
|
### fileOperations.delete
|
||||||
|
Delete file operation. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
### attachments.create
|
||||||
|
Create attachment upload URL. Params: name (required), documentId (required), size (required), contentType (required)
|
||||||
|
|
||||||
|
### attachments.delete
|
||||||
|
Delete attachment. Params: id (required)
|
||||||
|
|
||||||
|
### attachments.redirect
|
||||||
|
Get attachment URL. Params: id (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
### auth.info
|
||||||
|
Get current user and team info. No params.
|
||||||
|
|
||||||
|
### auth.config
|
||||||
|
Get auth configuration. No params.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
### views.list
|
||||||
|
List document views. Params: documentId (required), limit?, offset?
|
||||||
|
|
||||||
|
### views.create
|
||||||
|
Record a view. Params: documentId (required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Response Patterns
|
||||||
|
|
||||||
|
Success:
|
||||||
|
{"ok": true, "data": {...}, "pagination": {"nextPath": "...", "limit": 25, "offset": 0}}
|
||||||
|
|
||||||
|
Error:
|
||||||
|
{"ok": false, "error": "message", "status": 401}
|
||||||
|
|
||||||
|
Rate Limiting:
|
||||||
|
HTTP 429 with Retry-After header
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("outline://api-reference")
|
||||||
|
def get_api_reference() -> str:
|
||||||
|
"""Returns comprehensive Outline API documentation for use with outline_api_call tool."""
|
||||||
|
return API_REFERENCE
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health Check Endpoint ---
|
||||||
|
async def health(request):
|
||||||
|
"""Health check endpoint for Docker/load balancer."""
|
||||||
|
if not OUTLINE_API_URL or not OUTLINE_API_TOKEN:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"status": "degraded",
|
||||||
|
"error": "Missing OUTLINE_API_URL or OUTLINE_API_TOKEN",
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
healthy, message = await outline_client.check_health()
|
||||||
|
if healthy:
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
return JSONResponse({"status": "degraded", "error": message}, status_code=503)
|
||||||
|
|
||||||
|
|
||||||
|
# --- ASGI Application ---
|
||||||
|
def create_app():
|
||||||
|
"""Create the ASGI application with health check and MCP routes."""
|
||||||
|
mcp_app = mcp.http_app()
|
||||||
|
|
||||||
|
routes = [
|
||||||
|
Route("/health", health, methods=["GET"]),
|
||||||
|
Mount("/", app=mcp_app),
|
||||||
|
]
|
||||||
|
|
||||||
|
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
# Create the app instance for uvicorn
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
Reference in New Issue
Block a user