# Directory Structure ``` ├── .gitignore ├── .vscode │ ├── settings.json │ └── tasks.json ├── CLAUDE.md ├── Dockerfile ├── docs │ └── mcp_inspector_guide.png ├── LICENSE ├── pyproject.toml ├── README.md ├── src │ └── mcp_outline │ ├── __init__.py │ ├── __main__.py │ ├── features │ │ ├── __init__.py │ │ └── documents │ │ ├── __init__.py │ │ ├── ai_tools.py │ │ ├── collection_tools.py │ │ ├── common.py │ │ ├── document_collaboration.py │ │ ├── document_content.py │ │ ├── document_lifecycle.py │ │ ├── document_organization.py │ │ ├── document_reading.py │ │ └── document_search.py │ ├── server.py │ └── utils │ ├── __init__.py │ └── outline_client.py ├── start_server.sh ├── tests │ ├── __init__.py │ ├── features │ │ ├── __init__.py │ │ └── documents │ │ ├── __init__.py │ │ ├── test_document_collaboration.py │ │ ├── test_document_content.py │ │ ├── test_document_reading.py │ │ └── test_document_search.py │ ├── test_server.py │ └── utils │ ├── __init__.py │ └── test_outline_client.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | .aider* 176 | repomix-output.xml 177 | docs/external 178 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Outline Server 2 | 3 | A Model Context Protocol (MCP) server enabling AI assistants to interact with Outline (https://www.getoutline.com) 4 | 5 | ## Overview 6 | 7 | This project implements a Model Context Protocol (MCP) server that allows AI assistants (like Claude) to interact with Outline document services, providing a bridge between natural language interactions and Outline's document management capabilities. 8 | 9 | ## Features 10 | 11 | Currently implemented: 12 | 13 | - **Document Search**: Search for documents by keywords 14 | - **Collection Management**: List collections and view document structure 15 | - **Document Reading**: Read document content, export as markdown 16 | - **Comment Management**: View and add comments on documents 17 | - **Document Creation**: Create new documents in collections 18 | - **Document Editing**: Update document content and move documents 19 | - **Backlink Management**: View documents that link to a specific document 20 | 21 | ## Add to Cursor with Docker 22 | 23 | We recommend running this python MCP server using Docker to avoid having to install dependencies on your machine. 24 | 25 | 1. Install and run Docker (or Docker Desktop) 26 | 2. Build the Docker image `docker buildx build -t mcp-outline .` 27 | 3. In Cursor, go to the "MCP Servers" tab and click "Add Server" 28 | ```json 29 | { 30 | "mcpServers": { 31 | "mcp-outline": { 32 | "command": "docker", 33 | "args": [ 34 | "run", 35 | "-i", 36 | "--rm", 37 | "--init", 38 | "-e", 39 | "DOCKER_CONTAINER=true", 40 | "-e", 41 | "OUTLINE_API_KEY", 42 | "-e", 43 | "OUTLINE_API_URL", 44 | "mcp-outline" 45 | ], 46 | "env": { 47 | "OUTLINE_API_KEY": "<YOUR_OUTLINE_API_KEY>", 48 | "OUTLINE_API_URL": "<YOUR_OUTLINE_API_URL>" 49 | } 50 | } 51 | } 52 | } 53 | ``` 54 | > OUTLINE_API_URL is optional, defaulting to https://app.getoutline.com/api 55 | 4. Debug the docker image by using MCP inspector and passing the docker image to it: 56 | ```bash 57 | npx @modelcontextprotocol/inspector docker run -i --rm --init -e DOCKER_CONTAINER=true --env-file .env mcp-outline 58 | ``` 59 | 60 | ## Development 61 | 62 | ### Prerequisites 63 | 64 | - Python 3.10+ 65 | - Outline account with API access 66 | - Outline API key (get this from your Outline account settings) 67 | 68 | ### Installation 69 | 70 | ```bash 71 | # Clone the repository 72 | git clone https://github.com/Vortiago/mcp-outline.git 73 | cd mcp-outline 74 | 75 | # Install in development mode 76 | uv pip install -e ".[dev]" 77 | ``` 78 | 79 | ### Configuration 80 | 81 | Create a `.env` file in the project root with the following variables: 82 | 83 | ``` 84 | # Outline API Configuration 85 | OUTLINE_API_KEY=your_outline_api_key_here 86 | 87 | # For cloud-hosted Outline (default) 88 | # OUTLINE_API_URL=https://app.getoutline.com/api 89 | 90 | # For self-hosted Outline 91 | # OUTLINE_API_URL=https://your-outline-instance.example.com/api 92 | ``` 93 | 94 | ### Running the Server 95 | 96 | ```bash 97 | # Development mode with the MCP Inspector 98 | mcp dev src/mcp_outline/server.py 99 | 100 | # Or use the provided script 101 | ./start_server.sh 102 | 103 | # Install in Claude Desktop (if available) 104 | mcp install src/mcp_outline/server.py --name "Document Outline Assistant" 105 | ``` 106 | 107 | When running the MCP Inspector, go to Tools > Click on a tool > it appears on the right side so that you can query it. 108 |  109 | 110 | ## Usage Examples 111 | 112 | ### Search for Documents 113 | 114 | ``` 115 | Search for documents containing "project planning" 116 | ``` 117 | 118 | ### List Collections 119 | 120 | ``` 121 | Show me all available collections 122 | ``` 123 | 124 | ### Read a Document 125 | 126 | ``` 127 | Get the content of document with ID "docId123" 128 | ``` 129 | 130 | ### Create a New Document 131 | 132 | ``` 133 | Create a new document titled "Research Report" in collection "colId456" with content "# Introduction\n\nThis is a research report..." 134 | ``` 135 | 136 | ### Add a Comment 137 | 138 | ``` 139 | Add a comment to document "docId123" saying "This looks great, but we should add more details to the methodology section." 140 | ``` 141 | 142 | ### Move a Document 143 | 144 | ``` 145 | Move document "docId123" to collection "colId789" 146 | ``` 147 | 148 | ## Contributing 149 | 150 | Contributions are welcome! Please feel free to submit a Pull Request. 151 | 152 | ## Development 153 | 154 | ```bash 155 | # Run tests 156 | uv run pytest tests/ 157 | 158 | # Format code 159 | uv run ruff format . 160 | ``` 161 | 162 | ## License 163 | 164 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 165 | 166 | ## Acknowledgments 167 | 168 | - Built with [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 169 | - Uses [Outline API](https://getoutline.com) for document management 170 | ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Outline Server Guide 2 | 3 | This guide helps Claude implement and modify the MCP Outline server codebase effectively. 4 | 5 | ## 1. Purpose & Overview 6 | 7 | This MCP server enables AI assistants to interact with Outline by: 8 | - Connecting to Outline services via REST API 9 | - Exposing Outline data (documents, collections, comments) 10 | - Providing tools to create and modify Outline objects 11 | - Using API key authentication for secure interactions 12 | 13 | ## 2. Core Concepts 14 | 15 | ### Outline & MCP Integration 16 | 17 | This project bridges two systems: 18 | 19 | 1. **Outline Objects**: 20 | - Documents (content, metadata) 21 | - Collections (grouping of documents) 22 | - Comments on documents 23 | - Document structure and hierarchy 24 | 25 | 2. **MCP Components**: 26 | - **Tools**: Functions that interact with Outline API 27 | 28 | ## 3. Implementation Guidelines 29 | 30 | ### Tools Implementation 31 | - Create tool functions for each Outline API endpoint 32 | - Follow existing patterns in the `features/documents/` directory 33 | - Keep functions simple with clear purposes 34 | - Handle authentication and errors properly 35 | - Example implementations: `search_documents`, `create_document` 36 | 37 | ### Development Workflow 38 | 1. Review the Outline API documentation 39 | 2. Use simple HTTP requests to the API 40 | 3. Follow existing patterns and code style 41 | 4. Keep the KISS principle in mind 42 | 43 | ## 4. Technical Requirements 44 | 45 | ### Code Style 46 | - PEP 8 conventions 47 | - Type hints for all functions 48 | - Line length: 79 characters 49 | - Small, focused functions 50 | 51 | ### Development Tools 52 | - Install: `uv pip install -e ".[dev]"` 53 | - Run server: `mcp dev src/mcp_outline/server.py` 54 | - Run tests: `uv run pytest tests/` 55 | - Format: `uv run ruff format .` 56 | 57 | ### Critical Requirements 58 | - No logging to stdout/stderr 59 | - Import sorting: standard library → third-party → local 60 | - Proper error handling with specific exceptions 61 | - Follow the KISS principle 62 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /tests/features/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Features test package 2 | ``` -------------------------------------------------------------------------------- /tests/features/documents/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Document features test package 2 | ``` -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Test utilities for mcp-outline. 3 | """ 4 | ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Tests for MCP Outline 2 | """ 3 | Test package for MCP Outline 4 | """ 5 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # MCP Outline package 2 | """ 3 | MCP server for document outlines 4 | """ 5 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/__main__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Main module for MCP Outline server 3 | """ 4 | from mcp_outline.server import main 5 | 6 | if __name__ == "__main__": 7 | main() 8 | ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Document Outline MCP features package 2 | from mcp_outline.features import documents 3 | 4 | 5 | def register_all(mcp): 6 | """ 7 | Register all features with the MCP server. 8 | 9 | Args: 10 | mcp: The FastMCP server instance 11 | """ 12 | documents.register(mcp) 13 | ``` -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the MCP Outline server. 3 | """ 4 | import pytest 5 | 6 | from mcp_outline.server import mcp 7 | 8 | 9 | @pytest.mark.anyio 10 | async def test_server_initialization(): 11 | """Test that the server initializes correctly.""" 12 | assert mcp.name == "Document Outline" 13 | assert len(await mcp.list_tools()) > 0 # Ensure functions are registered 14 | ``` -------------------------------------------------------------------------------- /start_server.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | # Start the MCP Outline server. 3 | # Primarily used during development to make it easier for Claude to access the server living inside WSL2. 4 | 5 | # Activate virtual environment if it exists 6 | if [ -d ".venv" ]; then 7 | source .venv/bin/activate 8 | fi 9 | 10 | export $(grep -v '^#' .env | xargs) 11 | 12 | # Run the server in development mode 13 | python src/mcp_outline/server.py 14 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/server.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Outline MCP Server 3 | 4 | A simple MCP server that provides document outline capabilities. 5 | """ 6 | from mcp.server.fastmcp import FastMCP 7 | 8 | from mcp_outline.features import register_all 9 | 10 | # Create a FastMCP server instance with a name 11 | mcp = FastMCP("Document Outline") 12 | 13 | # Register all features 14 | register_all(mcp) 15 | 16 | 17 | def main(): 18 | # Start the server 19 | mcp.run() 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | ``` -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "docker build", 8 | "type": "shell", 9 | "command": "sudo docker buildx build -t mcp-outline .", 10 | "problemMatcher": [] 11 | }, 12 | { 13 | "label": "MCP Inspector", 14 | "type": "shell", 15 | "command": "npx @modelcontextprotocol/inspector docker run -i --rm --init -e DOCKER_CONTAINER=true --env-file .env mcp-outline", 16 | "problemMatcher": [] 17 | } 18 | ] 19 | } 20 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Document management features for MCP Outline 2 | from typing import Optional 3 | 4 | from mcp_outline.features.documents import ( 5 | ai_tools, 6 | collection_tools, 7 | document_collaboration, 8 | document_content, 9 | document_lifecycle, 10 | document_organization, 11 | document_reading, 12 | document_search, 13 | ) 14 | 15 | 16 | def register( 17 | mcp, api_key: Optional[str] = None, api_url: Optional[str] = None 18 | ): 19 | """ 20 | Register document management features with the MCP server. 21 | 22 | Args: 23 | mcp: The FastMCP server instance 24 | api_key: Optional API key for Outline 25 | api_url: Optional API URL for Outline 26 | """ 27 | # Register all the tools from each module 28 | document_search.register_tools(mcp) 29 | document_reading.register_tools(mcp) 30 | document_content.register_tools(mcp) 31 | document_organization.register_tools(mcp) 32 | document_lifecycle.register_tools(mcp) 33 | document_collaboration.register_tools(mcp) 34 | collection_tools.register_tools(mcp) 35 | ai_tools.register_tools(mcp) 36 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/common.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Common utilities for document outline features. 3 | 4 | This module provides shared functionality used by both tools and resources. 5 | """ 6 | import os 7 | 8 | from mcp_outline.utils.outline_client import OutlineClient, OutlineError 9 | 10 | 11 | class OutlineClientError(Exception): 12 | """Exception raised for errors in document outline client operations.""" 13 | pass 14 | 15 | def get_outline_client() -> OutlineClient: 16 | """ 17 | Get the document outline client. 18 | 19 | Returns: 20 | OutlineClient instance 21 | 22 | Raises: 23 | OutlineClientError: If client creation fails 24 | """ 25 | try: 26 | # Get API credentials from environment variables 27 | api_key = os.getenv("OUTLINE_API_KEY") 28 | api_url = os.getenv("OUTLINE_API_URL") 29 | 30 | # Create an instance of the outline client 31 | client = OutlineClient(api_key=api_key, api_url=api_url) 32 | 33 | # Test the connection by attempting to get auth info 34 | _ = client.auth_info() 35 | 36 | return client 37 | except OutlineError as e: 38 | raise OutlineClientError(f"Outline client error: {str(e)}") 39 | except Exception as e: 40 | raise OutlineClientError(f"Unexpected error: {str(e)}") 41 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mcp-outline" 7 | description = "A Model Context Protocol (MCP) server for Outline (https://www.getoutline.com)" 8 | version = "0.2.2" 9 | authors = [ 10 | {name = "Atle H. Havsø", email = "[email protected]"}, 11 | ] 12 | requires-python = ">=3.10" 13 | readme = "README.md" 14 | license-files = ["LICENSE"] 15 | dependencies = [ 16 | "mcp[cli]>=0.1.0", 17 | "requests>=2.25.0", 18 | ] 19 | 20 | [project.scripts] 21 | mcp-outline = "mcp_outline.server:main" 22 | 23 | [project.urls] 24 | "Homepage" = "https://github.com/Vortiago/mcp-outline" 25 | "Bug Tracker" = "https://github.com/Vortiago/mcp-outline/issues" 26 | 27 | [project.optional-dependencies] 28 | dev = [ 29 | "mcp[cli]>=0.1.0", 30 | "pytest>=7.0.0", 31 | "pytest-asyncio>=0.21.0", 32 | "ruff>=0.0.267", 33 | "anyio>=3.6.2", 34 | "pyright>=1.1.398", 35 | "trio>=0.22.0", 36 | ] 37 | 38 | [tool.setuptools] 39 | package-dir = {"" = "src"} 40 | 41 | [tool.pytest.ini_options] 42 | testpaths = ["tests"] 43 | python_files = "test_*.py" 44 | python_functions = "test_*" 45 | 46 | [tool.ruff] 47 | line-length = 79 48 | target-version = "py310" 49 | extend-exclude = ["docs"] 50 | 51 | [tool.ruff.lint] 52 | select = ["E", "F", "I"] 53 | 54 | [tool.pyright] 55 | exclude = [ 56 | "**/node_modules", 57 | "**/__pycache__", 58 | "**/.*", 59 | "docs/" 60 | ] 61 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Stage 1: Dependency installation using uv 2 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv 3 | 4 | # Create non-root user and group 5 | ARG APP_UID=1000 6 | ARG APP_GID=1000 7 | RUN addgroup --gid $APP_GID appgroup && \ 8 | adduser --disabled-password --gecos "" --uid $APP_UID --gid $APP_GID appuser 9 | USER appuser:appgroup 10 | 11 | WORKDIR /app 12 | 13 | # Copy metadata and ensure src exists to satisfy setuptools 14 | COPY --chown=appuser:appgroup pyproject.toml README.md LICENSE uv.lock /app/ 15 | RUN mkdir -p /app/src && chown appuser:appgroup /app/src 16 | 17 | # Install dependencies, cache to temp directory 18 | RUN mkdir -p /tmp/uv_cache && chown appuser:appgroup /tmp/uv_cache 19 | RUN --mount=type=cache,target=/tmp/uv_cache \ 20 | --mount=type=bind,source=uv.lock,target=uv.lock \ 21 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 22 | --mount=type=bind,source=README.md,target=README.md \ 23 | --mount=type=bind,source=LICENSE,target=LICENSE \ 24 | uv sync --frozen --no-dev --no-editable 25 | 26 | COPY --chown=appuser:appgroup ./src/mcp_outline /app/mcp_outline 27 | 28 | # Stage 2: Final runtime image 29 | FROM python:3.12-slim-bookworm 30 | 31 | # Create non-root user and group 32 | ARG APP_UID=1000 33 | ARG APP_GID=1000 34 | RUN addgroup --gid $APP_GID appgroup && \ 35 | adduser --disabled-password --gecos "" --uid $APP_UID --gid $APP_GID appuser 36 | 37 | WORKDIR /app 38 | 39 | # Copy the installed virtual environment and code from builder stage 40 | COPY --chown=appuser:appgroup --from=uv /app/.venv /app/.venv 41 | COPY --chown=appuser:appgroup --from=uv /app /app 42 | 43 | # Set environment 44 | ENV PATH="/app/.venv/bin:$PATH" 45 | ENV PYTHONPATH=/app 46 | 47 | # Switch to non-root user 48 | USER appuser:appgroup 49 | 50 | ENTRYPOINT ["mcp-outline"] 51 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/document_reading.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Document reading tools for the MCP Outline server. 3 | 4 | This module provides MCP tools for reading document content. 5 | """ 6 | from typing import Any, Dict 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def _format_document_content(document: Dict[str, Any]) -> str: 15 | """Format document content into readable text.""" 16 | title = document.get("title", "Untitled Document") 17 | text = document.get("text", "") 18 | 19 | return f"""# {title} 20 | 21 | {text} 22 | """ 23 | 24 | def register_tools(mcp) -> None: 25 | """ 26 | Register document reading tools with the MCP server. 27 | 28 | Args: 29 | mcp: The FastMCP server instance 30 | """ 31 | @mcp.tool() 32 | def read_document(document_id: str) -> str: 33 | """ 34 | Retrieves and displays the full content of a document. 35 | 36 | Use this tool when you need to: 37 | - Access the complete content of a specific document 38 | - Review document information in detail 39 | - Quote or reference document content 40 | - Analyze document contents 41 | 42 | Args: 43 | document_id: The document ID to retrieve 44 | 45 | Returns: 46 | Formatted string containing the document title and content 47 | """ 48 | try: 49 | client = get_outline_client() 50 | document = client.get_document(document_id) 51 | return _format_document_content(document) 52 | except OutlineClientError as e: 53 | return f"Error reading document: {str(e)}" 54 | except Exception as e: 55 | return f"Unexpected error: {str(e)}" 56 | 57 | @mcp.tool() 58 | def export_document(document_id: str) -> str: 59 | """ 60 | Exports a document as plain markdown text. 61 | 62 | Use this tool when you need to: 63 | - Get clean markdown content without formatting 64 | - Extract document content for external use 65 | - Process document content in another application 66 | - Share document content outside Outline 67 | 68 | Args: 69 | document_id: The document ID to export 70 | 71 | Returns: 72 | Document content in markdown format without additional formatting 73 | """ 74 | try: 75 | client = get_outline_client() 76 | response = client.post("documents.export", {"id": document_id}) 77 | return response.get("data", "No content available") 78 | except OutlineClientError as e: 79 | return f"Error exporting document: {str(e)}" 80 | except Exception as e: 81 | return f"Unexpected error: {str(e)}" 82 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/document_organization.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Document organization for the MCP Outline server. 3 | 4 | This module provides MCP tools for organizing documents. 5 | """ 6 | from typing import Optional 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def register_tools(mcp) -> None: 15 | """ 16 | Register document organization tools with the MCP server. 17 | 18 | Args: 19 | mcp: The FastMCP server instance 20 | """ 21 | @mcp.tool() 22 | def move_document( 23 | document_id: str, 24 | collection_id: Optional[str] = None, 25 | parent_document_id: Optional[str] = None 26 | ) -> str: 27 | """ 28 | Relocates a document to a different collection or parent document. 29 | 30 | IMPORTANT: When moving a document that has child documents (nested 31 | documents), all child documents will move along with it, maintaining 32 | their hierarchical structure. You must specify either collection_id or 33 | parent_document_id (or both). 34 | 35 | Use this tool when you need to: 36 | - Reorganize your document hierarchy 37 | - Move a document to a more relevant collection 38 | - Change a document's parent document 39 | - Restructure content organization 40 | 41 | Args: 42 | document_id: The document ID to move 43 | collection_id: Target collection ID (if moving between collections) 44 | parent_document_id: Optional parent document ID (for nesting) 45 | 46 | Returns: 47 | Result message confirming the move operation 48 | """ 49 | try: 50 | client = get_outline_client() 51 | 52 | # Require at least one destination parameter 53 | if collection_id is None and parent_document_id is None: 54 | return ( 55 | "Error: You must specify either a collection_id or " 56 | "parent_document_id." 57 | ) 58 | 59 | data = {"id": document_id} 60 | 61 | if collection_id: 62 | data["collectionId"] = collection_id 63 | 64 | if parent_document_id: 65 | data["parentDocumentId"] = parent_document_id 66 | 67 | response = client.post("documents.move", data) 68 | 69 | # Check for successful response 70 | if response.get("data"): 71 | return "Document moved successfully." 72 | else: 73 | return "Failed to move document." 74 | except OutlineClientError as e: 75 | return f"Error moving document: {str(e)}" 76 | except Exception as e: 77 | return f"Unexpected error: {str(e)}" 78 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/ai_tools.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | AI-powered tools for interacting with documents. 3 | 4 | This module provides MCP tools for AI-powered features in Outline. 5 | """ 6 | from typing import Any, Dict, Optional 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def _format_ai_answer(response: Dict[str, Any]) -> str: 15 | """Format AI answer into readable text.""" 16 | # Check if the search field exists (indicates AI answer is available) 17 | if "search" not in response: 18 | return ( 19 | "AI answering is not enabled for this workspace or " 20 | "no relevant information was found." 21 | ) 22 | 23 | search = response.get("search", {}) 24 | answer = search.get("answer", "") 25 | 26 | if not answer: 27 | return "No answer was found for your question." 28 | 29 | # Format the answer 30 | output = "# AI Answer\n\n" 31 | output += f"{answer}\n\n" 32 | 33 | # Add source documents 34 | documents = response.get("documents", []) 35 | if documents: 36 | output += "## Sources\n\n" 37 | for i, doc in enumerate(documents, 1): 38 | title = doc.get("title", "Untitled") 39 | doc_id = doc.get("id", "") 40 | output += f"{i}. {title} (ID: {doc_id})\n" 41 | 42 | return output 43 | 44 | def register_tools(mcp) -> None: 45 | """ 46 | Register AI tools with the MCP server. 47 | 48 | Args: 49 | mcp: The FastMCP server instance 50 | """ 51 | @mcp.tool() 52 | def ask_ai_about_documents( 53 | question: str, 54 | collection_id: Optional[str] = None, 55 | document_id: Optional[str] = None 56 | ) -> str: 57 | """ 58 | Queries document content using natural language questions. 59 | 60 | Use this tool when you need to: 61 | - Find specific information across multiple documents 62 | - Get direct answers to questions about document content 63 | - Extract insights from your knowledge base 64 | - Answer questions like "What is our vacation policy?" or "How do we 65 | onboard new clients?" 66 | 67 | Args: 68 | question: The natural language question to ask 69 | collection_id: Optional collection to limit the search to 70 | document_id: Optional document to limit the search to 71 | 72 | Returns: 73 | AI-generated answer based on document content with sources 74 | """ 75 | try: 76 | client = get_outline_client() 77 | response = client.answer_question( 78 | question, collection_id, document_id 79 | ) 80 | return _format_ai_answer(response) 81 | except OutlineClientError as e: 82 | return f"Error getting answer: {str(e)}" 83 | except Exception as e: 84 | return f"Unexpected error: {str(e)}" 85 | ``` -------------------------------------------------------------------------------- /tests/utils/test_outline_client.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for the Outline API client. 3 | """ 4 | import os 5 | from unittest.mock import MagicMock, patch 6 | 7 | import pytest 8 | import requests 9 | 10 | from mcp_outline.utils.outline_client import OutlineClient, OutlineError 11 | 12 | # Test data 13 | MOCK_API_KEY = "test_api_key" 14 | MOCK_API_URL = "https://test.outline.com/api" 15 | 16 | class TestOutlineClient: 17 | """Test suite for OutlineClient.""" 18 | 19 | def setup_method(self): 20 | """Set up test environment.""" 21 | # Save original environment variables 22 | self.original_api_key = os.environ.get("OUTLINE_API_KEY") 23 | self.original_api_url = os.environ.get("OUTLINE_API_URL") 24 | 25 | # Set test environment variables 26 | os.environ["OUTLINE_API_KEY"] = MOCK_API_KEY 27 | os.environ["OUTLINE_API_URL"] = MOCK_API_URL 28 | 29 | def teardown_method(self): 30 | """Restore original environment.""" 31 | # Restore original environment variables 32 | if self.original_api_key is not None: 33 | os.environ["OUTLINE_API_KEY"] = self.original_api_key 34 | else: 35 | os.environ.pop("OUTLINE_API_KEY", None) 36 | 37 | if self.original_api_url is not None: 38 | os.environ["OUTLINE_API_URL"] = self.original_api_url 39 | else: 40 | os.environ.pop("OUTLINE_API_URL", None) 41 | 42 | def test_init_from_env_variables(self): 43 | """Test initialization from environment variables.""" 44 | client = OutlineClient() 45 | assert client.api_key == MOCK_API_KEY 46 | assert client.api_url == MOCK_API_URL 47 | 48 | def test_init_from_arguments(self): 49 | """Test initialization from constructor arguments.""" 50 | custom_key = "custom_key" 51 | custom_url = "https://custom.outline.com/api" 52 | 53 | client = OutlineClient(api_key=custom_key, api_url=custom_url) 54 | assert client.api_key == custom_key 55 | assert client.api_url == custom_url 56 | 57 | def test_init_missing_api_key(self): 58 | """Test error when API key is missing.""" 59 | os.environ.pop("OUTLINE_API_KEY", None) 60 | 61 | with pytest.raises(OutlineError): 62 | OutlineClient(api_key=None) 63 | 64 | @patch("requests.post") 65 | def test_post_request(self, mock_post): 66 | """Test POST request method.""" 67 | # Setup mock response 68 | mock_response = MagicMock() 69 | mock_response.status_code = 200 70 | mock_response.json.return_value = {"data": {"test": "value"}} 71 | mock_post.return_value = mock_response 72 | 73 | # Create client and make request 74 | client = OutlineClient() 75 | data = {"param": "value"} 76 | result = client.post("test_endpoint", data) 77 | 78 | # Verify request was made correctly 79 | mock_post.assert_called_once_with( 80 | f"{MOCK_API_URL}/test_endpoint", 81 | headers={ 82 | "Authorization": f"Bearer {MOCK_API_KEY}", 83 | "Content-Type": "application/json", 84 | "Accept": "application/json" 85 | }, 86 | json=data 87 | ) 88 | 89 | assert result == {"data": {"test": "value"}} 90 | 91 | @patch("requests.post") 92 | def test_error_handling(self, mock_post): 93 | """Test error handling for request exceptions.""" 94 | # Setup mock to raise an exception 95 | error_msg = "Connection error" 96 | mock_post.side_effect = requests.exceptions.RequestException(error_msg) 97 | 98 | # Create client and test exception handling 99 | client = OutlineClient() 100 | 101 | with pytest.raises(OutlineError) as exc_info: 102 | client.post("test_endpoint") 103 | 104 | assert "API request failed" in str(exc_info.value) 105 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/document_content.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Document content management for the MCP Outline server. 3 | 4 | This module provides MCP tools for creating and updating document content. 5 | """ 6 | from typing import Any, Dict, Optional 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def register_tools(mcp) -> None: 15 | """ 16 | Register document content tools with the MCP server. 17 | 18 | Args: 19 | mcp: The FastMCP server instance 20 | """ 21 | @mcp.tool() 22 | def create_document( 23 | title: str, 24 | collection_id: str, 25 | text: str = "", 26 | parent_document_id: Optional[str] = None, 27 | publish: bool = True 28 | ) -> str: 29 | """ 30 | Creates a new document in a specified collection. 31 | 32 | Use this tool when you need to: 33 | - Add new content to a knowledge base 34 | - Create documentation, guides, or notes 35 | - Add a child document to an existing parent 36 | - Start a new document thread or topic 37 | 38 | Args: 39 | title: The document title 40 | collection_id: The collection ID to create the document in 41 | text: Optional markdown content for the document 42 | parent_document_id: Optional parent document ID for nesting 43 | publish: Whether to publish the document immediately (True) or 44 | save as draft (False) 45 | 46 | Returns: 47 | Result message with the new document ID 48 | """ 49 | try: 50 | client = get_outline_client() 51 | 52 | data = { 53 | "title": title, 54 | "text": text, 55 | "collectionId": collection_id, 56 | "publish": publish 57 | } 58 | 59 | if parent_document_id: 60 | data["parentDocumentId"] = parent_document_id 61 | 62 | response = client.post("documents.create", data) 63 | document = response.get("data", {}) 64 | 65 | if not document: 66 | return "Failed to create document." 67 | 68 | doc_id = document.get("id", "unknown") 69 | doc_title = document.get("title", "Untitled") 70 | 71 | return f"Document created successfully: {doc_title} (ID: {doc_id})" 72 | except OutlineClientError as e: 73 | return f"Error creating document: {str(e)}" 74 | except Exception as e: 75 | return f"Unexpected error: {str(e)}" 76 | 77 | @mcp.tool() 78 | def update_document( 79 | document_id: str, 80 | title: Optional[str] = None, 81 | text: Optional[str] = None, 82 | append: bool = False 83 | ) -> str: 84 | """ 85 | Modifies an existing document's title or content. 86 | 87 | IMPORTANT: This tool replaces the document content rather than just 88 | adding to it. 89 | To update a document with changed data, you need to first read the 90 | document, 91 | add your changes to the content, and then send the complete document 92 | with your changes. 93 | 94 | Use this tool when you need to: 95 | - Edit or update document content 96 | - Change a document's title 97 | - Append new content to an existing document 98 | - Fix errors or add information to documents 99 | 100 | Args: 101 | document_id: The document ID to update 102 | title: New title (if None, keeps existing title) 103 | text: New content (if None, keeps existing content) 104 | append: If True, adds text to the end of document instead of 105 | replacing 106 | 107 | Returns: 108 | Result message confirming update 109 | """ 110 | try: 111 | client = get_outline_client() 112 | 113 | # Only include fields that are being updated 114 | data: Dict[str, Any] = {"id": document_id} 115 | 116 | if title is not None: 117 | data["title"] = title 118 | 119 | if text is not None: 120 | data["text"] = text 121 | data["append"] = append 122 | 123 | response = client.post("documents.update", data) 124 | document = response.get("data", {}) 125 | 126 | if not document: 127 | return "Failed to update document." 128 | 129 | doc_title = document.get("title", "Untitled") 130 | 131 | return f"Document updated successfully: {doc_title}" 132 | except OutlineClientError as e: 133 | return f"Error updating document: {str(e)}" 134 | except Exception as e: 135 | return f"Unexpected error: {str(e)}" 136 | 137 | @mcp.tool() 138 | def add_comment( 139 | document_id: str, 140 | text: str, 141 | parent_comment_id: Optional[str] = None 142 | ) -> str: 143 | """ 144 | Adds a comment to a document or replies to an existing comment. 145 | 146 | Use this tool when you need to: 147 | - Provide feedback on document content 148 | - Ask questions about specific information 149 | - Reply to another user's comment 150 | - Collaborate with others on document development 151 | 152 | Args: 153 | document_id: The document to comment on 154 | text: The comment text (supports markdown) 155 | parent_comment_id: Optional ID of a parent comment (for replies) 156 | 157 | Returns: 158 | Result message with the new comment ID 159 | """ 160 | try: 161 | client = get_outline_client() 162 | 163 | data = { 164 | "documentId": document_id, 165 | "text": text 166 | } 167 | 168 | if parent_comment_id: 169 | data["parentCommentId"] = parent_comment_id 170 | 171 | response = client.post("comments.create", data) 172 | comment = response.get("data", {}) 173 | 174 | if not comment: 175 | return "Failed to create comment." 176 | 177 | comment_id = comment.get("id", "unknown") 178 | 179 | if parent_comment_id: 180 | return f"Reply added successfully (ID: {comment_id})" 181 | else: 182 | return f"Comment added successfully (ID: {comment_id})" 183 | except OutlineClientError as e: 184 | return f"Error adding comment: {str(e)}" 185 | except Exception as e: 186 | return f"Unexpected error: {str(e)}" 187 | ``` -------------------------------------------------------------------------------- /tests/features/documents/test_document_reading.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for document reading tools. 3 | """ 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from mcp_outline.features.documents.common import OutlineClientError 9 | from mcp_outline.features.documents.document_reading import ( 10 | _format_document_content, 11 | ) 12 | 13 | 14 | # Mock FastMCP for registering tools 15 | class MockMCP: 16 | def __init__(self): 17 | self.tools = {} 18 | 19 | def tool(self): 20 | def decorator(func): 21 | self.tools[func.__name__] = func 22 | return func 23 | return decorator 24 | 25 | # Sample document data 26 | SAMPLE_DOCUMENT = { 27 | "id": "doc123", 28 | "title": "Test Document", 29 | "text": "This is a test document with some content.", 30 | "updatedAt": "2023-01-01T12:00:00Z" 31 | } 32 | 33 | # Sample export response 34 | SAMPLE_EXPORT_RESPONSE = { 35 | "data": "# Test Document\n\nThis is a test document with some content." 36 | } 37 | 38 | @pytest.fixture 39 | def mcp(): 40 | """Fixture to provide mock MCP instance.""" 41 | return MockMCP() 42 | 43 | @pytest.fixture 44 | def register_reading_tools(mcp): 45 | """Fixture to register document reading tools.""" 46 | from mcp_outline.features.documents.document_reading import register_tools 47 | register_tools(mcp) 48 | return mcp 49 | 50 | class TestDocumentReadingFormatters: 51 | """Tests for document reading formatting functions.""" 52 | 53 | def test_format_document_content(self): 54 | """Test formatting document content.""" 55 | result = _format_document_content(SAMPLE_DOCUMENT) 56 | 57 | # Verify the result contains the expected information 58 | assert "# Test Document" in result 59 | assert "This is a test document with some content." in result 60 | 61 | def test_format_document_content_missing_fields(self): 62 | """Test formatting document content with missing fields.""" 63 | # Test with missing title 64 | doc_no_title = {"text": "Content only"} 65 | result_no_title = _format_document_content(doc_no_title) 66 | assert "# Untitled Document" in result_no_title 67 | assert "Content only" in result_no_title 68 | 69 | # Test with missing text 70 | doc_no_text = {"title": "Title only"} 71 | result_no_text = _format_document_content(doc_no_text) 72 | assert "# Title only" in result_no_text 73 | assert result_no_text.strip().endswith("# Title only") 74 | 75 | # Test with empty document 76 | empty_doc = {} 77 | result_empty = _format_document_content(empty_doc) 78 | assert "# Untitled Document" in result_empty 79 | 80 | class TestDocumentReadingTools: 81 | """Tests for document reading tools.""" 82 | 83 | @patch("mcp_outline.features.documents.document_reading.get_outline_client") 84 | def test_read_document_success( 85 | self, mock_get_client, register_reading_tools 86 | ): 87 | """Test read_document tool success case.""" 88 | # Set up mock client 89 | mock_client = MagicMock() 90 | mock_client.get_document.return_value = SAMPLE_DOCUMENT 91 | mock_get_client.return_value = mock_client 92 | 93 | # Call the tool 94 | result = register_reading_tools.tools["read_document"]("doc123") 95 | 96 | # Verify client was called correctly 97 | mock_client.get_document.assert_called_once_with("doc123") 98 | 99 | # Verify result contains expected information 100 | assert "# Test Document" in result 101 | assert "This is a test document with some content." in result 102 | 103 | @patch("mcp_outline.features.documents.document_reading.get_outline_client") 104 | def test_read_document_client_error( 105 | self, mock_get_client, register_reading_tools 106 | ): 107 | """Test read_document tool with client error.""" 108 | # Set up mock client to raise an error 109 | mock_client = MagicMock() 110 | mock_client.get_document.side_effect = OutlineClientError("API error") 111 | mock_get_client.return_value = mock_client 112 | 113 | # Call the tool 114 | result = register_reading_tools.tools["read_document"]("doc123") 115 | 116 | # Verify error is handled and returned 117 | assert "Error reading document" in result 118 | assert "API error" in result 119 | 120 | @patch("mcp_outline.features.documents.document_reading.get_outline_client") 121 | def test_export_document_success( 122 | self, mock_get_client, register_reading_tools 123 | ): 124 | """Test export_document tool success case.""" 125 | # Set up mock client 126 | mock_client = MagicMock() 127 | mock_client.post.return_value = SAMPLE_EXPORT_RESPONSE 128 | mock_get_client.return_value = mock_client 129 | 130 | # Call the tool 131 | result = register_reading_tools.tools["export_document"]("doc123") 132 | 133 | # Verify client was called correctly 134 | mock_client.post.assert_called_once_with( 135 | "documents.export", 136 | {"id": "doc123"} 137 | ) 138 | 139 | # Verify result contains expected information 140 | assert "# Test Document" in result 141 | assert "This is a test document with some content." in result 142 | 143 | @patch("mcp_outline.features.documents.document_reading.get_outline_client") 144 | def test_export_document_empty_response( 145 | self, mock_get_client, register_reading_tools 146 | ): 147 | """Test export_document tool with empty response.""" 148 | # Set up mock client with empty response 149 | mock_client = MagicMock() 150 | mock_client.post.return_value = {} 151 | mock_get_client.return_value = mock_client 152 | 153 | # Call the tool 154 | result = register_reading_tools.tools["export_document"]("doc123") 155 | 156 | # Verify result contains default message 157 | assert "No content available" in result 158 | 159 | @patch("mcp_outline.features.documents.document_reading.get_outline_client") 160 | def test_export_document_client_error( 161 | self, mock_get_client, register_reading_tools 162 | ): 163 | """Test export_document tool with client error.""" 164 | # Set up mock client to raise an error 165 | mock_client = MagicMock() 166 | mock_client.post.side_effect = OutlineClientError("API error") 167 | mock_get_client.return_value = mock_client 168 | 169 | # Call the tool 170 | result = register_reading_tools.tools["export_document"]("doc123") 171 | 172 | # Verify error is handled and returned 173 | assert "Error exporting document" in result 174 | assert "API error" in result 175 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/document_collaboration.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Document collaboration tools for the MCP Outline server. 3 | 4 | This module provides MCP tools for document comments, sharing, and 5 | collaboration. 6 | """ 7 | from typing import Any, Dict, List 8 | 9 | from mcp_outline.features.documents.common import ( 10 | OutlineClientError, 11 | get_outline_client, 12 | ) 13 | 14 | 15 | def _format_comments( 16 | comments: List[Dict[str, Any]], 17 | total_count: int = 0, 18 | limit: int = 25, 19 | offset: int = 0 20 | ) -> str: 21 | """Format document comments into readable text.""" 22 | if not comments: 23 | return "No comments found for this document." 24 | 25 | output = "# Document Comments\n\n" 26 | 27 | # Add pagination info if provided 28 | if total_count: 29 | shown_range = f"{offset+1}-{min(offset+len(comments), total_count)}" 30 | output += f"Showing comments {shown_range} of {total_count} total\n\n" 31 | 32 | # Add warning if there might be more comments than shown 33 | if len(comments) == limit: 34 | output += "Note: Only showing the first batch of comments. " 35 | output += f"Use offset={offset+limit} to see more comments.\n\n" 36 | 37 | for i, comment in enumerate(comments, offset+1): 38 | user = comment.get("createdBy", {}).get("name", "Unknown User") 39 | created_at = comment.get("createdAt", "") 40 | comment_id = comment.get("id", "") 41 | anchor_text = comment.get("anchorText", "") 42 | 43 | # Extract data object containing the comment content 44 | data = comment.get("data", {}) 45 | 46 | # Convert data to JSON string for display 47 | try: 48 | import json 49 | text = json.dumps(data, indent=2) 50 | except Exception: 51 | text = str(data) 52 | 53 | output += f"## {i}. Comment by {user}\n" 54 | output += f"ID: {comment_id}\n" 55 | if created_at: 56 | output += f"Date: {created_at}\n" 57 | if anchor_text: 58 | output += f"\nReferencing text: \"{anchor_text}\"\n" 59 | if data: 60 | output += f"\nComment content:\n```json\n{text}\n```\n\n" 61 | else: 62 | output += "\n(No comment content found)\n\n" 63 | 64 | return output 65 | 66 | def register_tools(mcp) -> None: 67 | """ 68 | Register document collaboration tools with the MCP server. 69 | 70 | Args: 71 | mcp: The FastMCP server instance 72 | """ 73 | @mcp.tool() 74 | def list_document_comments( 75 | document_id: str, 76 | include_anchor_text: bool = False, 77 | limit: int = 25, 78 | offset: int = 0 79 | ) -> str: 80 | """ 81 | Retrieves comments on a specific document with pagination support. 82 | 83 | IMPORTANT: By default, this returns up to 25 comments at a time. If 84 | there are more than 25 comments on the document, you'll need to make 85 | multiple calls with different offset values to get all comments. The 86 | response will indicate if there 87 | are more comments available. 88 | 89 | Use this tool when you need to: 90 | - Review feedback and discussions on a document 91 | - See all comments from different users 92 | - Find specific comments or questions 93 | - Track collaboration and input on documents 94 | 95 | Args: 96 | document_id: The document ID to get comments from 97 | include_anchor_text: Whether to include the document text that 98 | comments refer to 99 | limit: Maximum number of comments to return (default: 25) 100 | offset: Number of comments to skip for pagination (default: 0) 101 | 102 | Returns: 103 | Formatted string containing comments with author, date, and 104 | optional anchor text 105 | """ 106 | try: 107 | client = get_outline_client() 108 | data = { 109 | "documentId": document_id, 110 | "includeAnchorText": include_anchor_text, 111 | "limit": limit, 112 | "offset": offset 113 | } 114 | 115 | response = client.post("comments.list", data) 116 | comments = response.get("data", []) 117 | pagination = response.get("pagination", {}) 118 | 119 | total_count = pagination.get("total", len(comments)) 120 | return _format_comments(comments, total_count, limit, offset) 121 | except OutlineClientError as e: 122 | return f"Error listing comments: {str(e)}" 123 | except Exception as e: 124 | return f"Unexpected error: {str(e)}" 125 | 126 | @mcp.tool() 127 | def get_comment(comment_id: str, include_anchor_text: bool = False) -> str: 128 | """ 129 | Retrieves a specific comment by its ID. 130 | 131 | Use this tool when you need to: 132 | - View details of a specific comment 133 | - Reference or quote a particular comment 134 | - Check comment content and metadata 135 | - Find a comment mentioned elsewhere 136 | 137 | Args: 138 | comment_id: The comment ID to retrieve 139 | include_anchor_text: Whether to include the document text that 140 | the comment refers to 141 | 142 | Returns: 143 | Formatted string with the comment content and metadata 144 | """ 145 | try: 146 | client = get_outline_client() 147 | response = client.post("comments.info", { 148 | "id": comment_id, 149 | "includeAnchorText": include_anchor_text 150 | }) 151 | comment = response.get("data", {}) 152 | 153 | if not comment: 154 | return "Comment not found." 155 | 156 | user = comment.get("createdBy", {}).get("name", "Unknown User") 157 | created_at = comment.get("createdAt", "") 158 | anchor_text = comment.get("anchorText", "") 159 | 160 | # Extract data object containing the comment content 161 | data = comment.get("data", {}) 162 | 163 | # Convert data to JSON string for display 164 | try: 165 | import json 166 | text = json.dumps(data, indent=2) 167 | except Exception: 168 | text = str(data) 169 | 170 | output = f"# Comment by {user}\n" 171 | if created_at: 172 | output += f"Date: {created_at}\n" 173 | if anchor_text: 174 | output += f"\nReferencing text: \"{anchor_text}\"\n" 175 | if data: 176 | output += f"\nComment content:\n```json\n{text}\n```\n" 177 | else: 178 | output += "\n(No comment content found)\n" 179 | 180 | return output 181 | except OutlineClientError as e: 182 | return f"Error getting comment: {str(e)}" 183 | except Exception as e: 184 | return f"Unexpected error: {str(e)}" 185 | 186 | @mcp.tool() 187 | def get_document_backlinks(document_id: str) -> str: 188 | """ 189 | Finds all documents that link to a specific document. 190 | 191 | Use this tool when you need to: 192 | - Discover references to a document across the workspace 193 | - Identify dependencies between documents 194 | - Find documents related to a specific document 195 | - Understand document relationships and connections 196 | 197 | Args: 198 | document_id: The document ID to find backlinks for 199 | 200 | Returns: 201 | Formatted string listing all documents that link to the specified 202 | document 203 | """ 204 | try: 205 | client = get_outline_client() 206 | response = client.post("documents.list", { 207 | "backlinkDocumentId": document_id 208 | }) 209 | documents = response.get("data", []) 210 | 211 | if not documents: 212 | return "No documents link to this document." 213 | 214 | output = "# Documents Linking to This Document\n\n" 215 | 216 | for i, document in enumerate(documents, 1): 217 | title = document.get("title", "Untitled Document") 218 | doc_id = document.get("id", "") 219 | updated_at = document.get("updatedAt", "") 220 | 221 | output += f"## {i}. {title}\n" 222 | output += f"ID: {doc_id}\n" 223 | if updated_at: 224 | output += f"Last Updated: {updated_at}\n" 225 | output += "\n" 226 | 227 | return output 228 | except OutlineClientError as e: 229 | return f"Error retrieving backlinks: {str(e)}" 230 | except Exception as e: 231 | return f"Unexpected error: {str(e)}" 232 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/document_lifecycle.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Document lifecycle management for the MCP Outline server. 3 | 4 | This module provides MCP tools for archiving, trashing, and restoring 5 | documents. 6 | """ 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def register_tools(mcp) -> None: 15 | """ 16 | Register document lifecycle tools with the MCP server. 17 | 18 | Args: 19 | mcp: The FastMCP server instance 20 | """ 21 | @mcp.tool() 22 | def archive_document(document_id: str) -> str: 23 | """ 24 | Archives a document to remove it from active use while preserving it. 25 | 26 | IMPORTANT: Archived documents are removed from collections but remain 27 | searchable in the system. They won't appear in normal collection views 28 | but can still be found through search or the archive list. 29 | 30 | Use this tool when you need to: 31 | - Remove outdated or inactive documents from view 32 | - Clean up collections while preserving document history 33 | - Preserve documents that are no longer relevant 34 | - Temporarily hide documents without deleting them 35 | 36 | Args: 37 | document_id: The document ID to archive 38 | 39 | Returns: 40 | Result message confirming archival 41 | """ 42 | try: 43 | client = get_outline_client() 44 | document = client.archive_document(document_id) 45 | 46 | if not document: 47 | return "Failed to archive document." 48 | 49 | doc_title = document.get("title", "Untitled") 50 | 51 | return f"Document archived successfully: {doc_title}" 52 | except OutlineClientError as e: 53 | return f"Error archiving document: {str(e)}" 54 | except Exception as e: 55 | return f"Unexpected error: {str(e)}" 56 | 57 | @mcp.tool() 58 | def unarchive_document(document_id: str) -> str: 59 | """ 60 | Restores a previously archived document to active status. 61 | 62 | Use this tool when you need to: 63 | - Restore archived documents to active use 64 | - Access or reference previously archived content 65 | - Make archived content visible in collections again 66 | - Update and reuse archived documents 67 | 68 | Args: 69 | document_id: The document ID to unarchive 70 | 71 | Returns: 72 | Result message confirming restoration 73 | """ 74 | try: 75 | client = get_outline_client() 76 | document = client.unarchive_document(document_id) 77 | 78 | if not document: 79 | return "Failed to unarchive document." 80 | 81 | doc_title = document.get("title", "Untitled") 82 | 83 | return f"Document unarchived successfully: {doc_title}" 84 | except OutlineClientError as e: 85 | return f"Error unarchiving document: {str(e)}" 86 | except Exception as e: 87 | return f"Unexpected error: {str(e)}" 88 | 89 | @mcp.tool() 90 | def delete_document(document_id: str, permanent: bool = False) -> str: 91 | """ 92 | Moves a document to trash or permanently deletes it. 93 | 94 | IMPORTANT: When permanent=False (the default), documents are moved to 95 | trash and retained for 30 days before being permanently deleted. 96 | During 97 | this period, they can be restored using the restore_document tool. 98 | Setting permanent=True bypasses the trash and immediately deletes the 99 | document without any recovery option. 100 | 101 | Use this tool when you need to: 102 | - Remove unwanted or unnecessary documents 103 | - Delete obsolete content 104 | - Clean up workspace by removing documents 105 | - Permanently remove sensitive information (with permanent=True) 106 | 107 | Args: 108 | document_id: The document ID to delete 109 | permanent: If True, permanently deletes the document without 110 | recovery option 111 | 112 | Returns: 113 | Result message confirming deletion 114 | """ 115 | try: 116 | client = get_outline_client() 117 | 118 | if permanent: 119 | success = client.permanently_delete_document(document_id) 120 | if success: 121 | return "Document permanently deleted." 122 | else: 123 | return "Failed to permanently delete document." 124 | else: 125 | # First get the document details for the success message 126 | document = client.get_document(document_id) 127 | doc_title = document.get("title", "Untitled") 128 | 129 | # Move to trash (using the regular delete endpoint) 130 | response = client.post("documents.delete", {"id": document_id}) 131 | 132 | # Check for successful response 133 | if response.get("success", False): 134 | return f"Document moved to trash: {doc_title}" 135 | else: 136 | return "Failed to move document to trash." 137 | 138 | except OutlineClientError as e: 139 | return f"Error deleting document: {str(e)}" 140 | except Exception as e: 141 | return f"Unexpected error: {str(e)}" 142 | 143 | @mcp.tool() 144 | def restore_document(document_id: str) -> str: 145 | """ 146 | Recovers a document from the trash back to active status. 147 | 148 | Use this tool when you need to: 149 | - Retrieve accidentally deleted documents 150 | - Restore documents from trash to active use 151 | - Recover documents deleted within the last 30 days 152 | - Access content that was previously trashed 153 | 154 | Args: 155 | document_id: The document ID to restore 156 | 157 | Returns: 158 | Result message confirming restoration 159 | """ 160 | try: 161 | client = get_outline_client() 162 | document = client.restore_document(document_id) 163 | 164 | if not document: 165 | return "Failed to restore document from trash." 166 | 167 | doc_title = document.get("title", "Untitled") 168 | 169 | return f"Document restored successfully: {doc_title}" 170 | except OutlineClientError as e: 171 | return f"Error restoring document: {str(e)}" 172 | except Exception as e: 173 | return f"Unexpected error: {str(e)}" 174 | 175 | @mcp.tool() 176 | def list_archived_documents() -> str: 177 | """ 178 | Displays all documents that have been archived. 179 | 180 | Use this tool when you need to: 181 | - Find specific archived documents 182 | - Review what documents have been archived 183 | - Identify documents for possible unarchiving 184 | - Check archive status of workspace content 185 | 186 | Returns: 187 | Formatted string containing list of archived documents 188 | """ 189 | try: 190 | client = get_outline_client() 191 | response = client.post("documents.archived") 192 | from mcp_outline.features.documents.document_search import ( 193 | _format_documents_list, 194 | ) 195 | documents = response.get("data", []) 196 | return _format_documents_list(documents, "Archived Documents") 197 | except OutlineClientError as e: 198 | return f"Error listing archived documents: {str(e)}" 199 | except Exception as e: 200 | return f"Unexpected error: {str(e)}" 201 | 202 | @mcp.tool() 203 | def list_trash() -> str: 204 | """ 205 | Displays all documents currently in the trash. 206 | 207 | Use this tool when you need to: 208 | - Find deleted documents that can be restored 209 | - Review what documents are pending permanent deletion 210 | - Identify documents to restore from trash 211 | - Verify if specific documents were deleted 212 | 213 | Returns: 214 | Formatted string containing list of documents in trash 215 | """ 216 | try: 217 | client = get_outline_client() 218 | documents = client.list_trash() 219 | from mcp_outline.features.documents.document_search import ( 220 | _format_documents_list, 221 | ) 222 | return _format_documents_list(documents, "Documents in Trash") 223 | except OutlineClientError as e: 224 | return f"Error listing trash: {str(e)}" 225 | except Exception as e: 226 | return f"Unexpected error: {str(e)}" 227 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/document_search.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Document search tools for the MCP Outline server. 3 | 4 | This module provides MCP tools for searching and listing documents. 5 | """ 6 | from typing import Any, Dict, List, Optional 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def _format_search_results(results: List[Dict[str, Any]]) -> str: 15 | """Format search results into readable text.""" 16 | if not results: 17 | return "No documents found matching your search." 18 | 19 | output = "# Search Results\n\n" 20 | 21 | for i, result in enumerate(results, 1): 22 | document = result.get("document", {}) 23 | title = document.get("title", "Untitled") 24 | doc_id = document.get("id", "") 25 | context = result.get("context", "") 26 | 27 | output += f"## {i}. {title}\n" 28 | output += f"ID: {doc_id}\n" 29 | if context: 30 | output += f"Context: {context}\n" 31 | output += "\n" 32 | 33 | return output 34 | 35 | def _format_documents_list(documents: List[Dict[str, Any]], title: str) -> str: 36 | """Format a list of documents into readable text.""" 37 | if not documents: 38 | return f"No {title.lower()} found." 39 | 40 | output = f"# {title}\n\n" 41 | 42 | for i, document in enumerate(documents, 1): 43 | doc_title = document.get("title", "Untitled") 44 | doc_id = document.get("id", "") 45 | updated_at = document.get("updatedAt", "") 46 | 47 | output += f"## {i}. {doc_title}\n" 48 | output += f"ID: {doc_id}\n" 49 | if updated_at: 50 | output += f"Last Updated: {updated_at}\n" 51 | output += "\n" 52 | 53 | return output 54 | 55 | def _format_collections(collections: List[Dict[str, Any]]) -> str: 56 | """Format collections into readable text.""" 57 | if not collections: 58 | return "No collections found." 59 | 60 | output = "# Collections\n\n" 61 | 62 | for i, collection in enumerate(collections, 1): 63 | name = collection.get("name", "Untitled Collection") 64 | coll_id = collection.get("id", "") 65 | description = collection.get("description", "") 66 | 67 | output += f"## {i}. {name}\n" 68 | output += f"ID: {coll_id}\n" 69 | if description: 70 | output += f"Description: {description}\n" 71 | output += "\n" 72 | 73 | return output 74 | 75 | def _format_collection_documents(doc_nodes: List[Dict[str, Any]]) -> str: 76 | """Format collection document structure into readable text.""" 77 | if not doc_nodes: 78 | return "No documents found in this collection." 79 | 80 | def format_node(node, depth=0): 81 | # Extract node details 82 | title = node.get("title", "Untitled") 83 | node_id = node.get("id", "") 84 | children = node.get("children", []) 85 | 86 | # Format this node 87 | indent = " " * depth 88 | text = f"{indent}- {title} (ID: {node_id})\n" 89 | 90 | # Recursively format children 91 | for child in children: 92 | text += format_node(child, depth + 1) 93 | 94 | return text 95 | 96 | output = "# Collection Structure\n\n" 97 | for node in doc_nodes: 98 | output += format_node(node) 99 | 100 | return output 101 | 102 | def register_tools(mcp) -> None: 103 | """ 104 | Register document search tools with the MCP server. 105 | 106 | Args: 107 | mcp: The FastMCP server instance 108 | """ 109 | @mcp.tool() 110 | def search_documents( 111 | query: str, 112 | collection_id: Optional[str] = None 113 | ) -> str: 114 | """ 115 | Searches for documents using keywords or phrases across your knowledge 116 | base. 117 | 118 | IMPORTANT: The search performs full-text search across all document 119 | content and titles. Results are ranked by relevance, with exact 120 | matches 121 | and title matches typically ranked higher. The search will return 122 | snippets of content (context) where the search terms appear in the 123 | document. You can limit the search to a specific collection by 124 | providing 125 | the collection_id. 126 | 127 | Use this tool when you need to: 128 | - Find documents containing specific terms or topics 129 | - Locate information across multiple documents 130 | - Search within a specific collection using collection_id 131 | - Discover content based on keywords 132 | 133 | Args: 134 | query: Search terms (e.g., "vacation policy" or "project plan") 135 | collection_id: Optional collection to limit the search to 136 | 137 | Returns: 138 | Formatted string containing search results with document titles 139 | and 140 | contexts 141 | """ 142 | try: 143 | client = get_outline_client() 144 | results = client.search_documents(query, collection_id) 145 | return _format_search_results(results) 146 | except OutlineClientError as e: 147 | return f"Error searching documents: {str(e)}" 148 | except Exception as e: 149 | return f"Unexpected error during search: {str(e)}" 150 | 151 | @mcp.tool() 152 | def list_collections() -> str: 153 | """ 154 | Retrieves and displays all available collections in the workspace. 155 | 156 | Use this tool when you need to: 157 | - See what collections exist in the workspace 158 | - Get collection IDs for other operations 159 | - Explore the organization of the knowledge base 160 | - Find a specific collection by name 161 | 162 | Returns: 163 | Formatted string containing collection names, IDs, and descriptions 164 | """ 165 | try: 166 | client = get_outline_client() 167 | collections = client.list_collections() 168 | return _format_collections(collections) 169 | except OutlineClientError as e: 170 | return f"Error listing collections: {str(e)}" 171 | except Exception as e: 172 | return f"Unexpected error listing collections: {str(e)}" 173 | 174 | @mcp.tool() 175 | def get_collection_structure(collection_id: str) -> str: 176 | """ 177 | Retrieves the hierarchical document structure of a collection. 178 | 179 | Use this tool when you need to: 180 | - Understand how documents are organized in a collection 181 | - Find document IDs within a specific collection 182 | - See the parent-child relationships between documents 183 | - Get an overview of a collection's content structure 184 | 185 | Args: 186 | collection_id: The collection ID to examine 187 | 188 | Returns: 189 | Formatted string showing the hierarchical structure of documents 190 | """ 191 | try: 192 | client = get_outline_client() 193 | docs = client.get_collection_documents(collection_id) 194 | return _format_collection_documents(docs) 195 | except OutlineClientError as e: 196 | return f"Error getting collection structure: {str(e)}" 197 | except Exception as e: 198 | return f"Unexpected error: {str(e)}" 199 | 200 | @mcp.tool() 201 | def get_document_id_from_title( 202 | query: str, collection_id: Optional[str] = None 203 | ) -> str: 204 | """ 205 | Locates a document ID by searching for its title. 206 | 207 | IMPORTANT: This tool first checks for exact title matches 208 | (case-insensitive). If none are found, it returns the best partial 209 | match instead. This is useful when you're not sure of the exact title 210 | but need 211 | to reference a document in other operations. Results are more accurate 212 | when you provide more of the actual title in your query. 213 | 214 | Use this tool when you need to: 215 | - Find a document's ID when you only know its title 216 | - Get the document ID for use in other operations 217 | - Verify if a document with a specific title exists 218 | - Find the best matching document if exact title is unknown 219 | 220 | Args: 221 | query: Title to search for (can be exact or partial) 222 | collection_id: Optional collection to limit the search to 223 | 224 | Returns: 225 | Document ID if found, or best match information 226 | """ 227 | try: 228 | client = get_outline_client() 229 | results = client.search_documents(query, collection_id) 230 | 231 | if not results: 232 | return f"No documents found matching '{query}'" 233 | 234 | # Check if we have an exact title match 235 | exact_matches = [ 236 | r for r in results 237 | if (r.get("document", {}).get("title", "").lower() 238 | == query.lower()) 239 | ] 240 | 241 | if exact_matches: 242 | doc = exact_matches[0].get("document", {}) 243 | doc_id = doc.get("id", "unknown") 244 | title = doc.get("title", "Untitled") 245 | return f"Document ID: {doc_id} (Title: {title})" 246 | 247 | # Otherwise return the top match 248 | doc = results[0].get("document", {}) 249 | doc_id = doc.get("id", "unknown") 250 | title = doc.get("title", "Untitled") 251 | return f"Best match - Document ID: {doc_id} (Title: {title})" 252 | except OutlineClientError as e: 253 | return f"Error searching for document: {str(e)}" 254 | except Exception as e: 255 | return f"Unexpected error: {str(e)}" 256 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/features/documents/collection_tools.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Collection management tools for the MCP Outline server. 3 | 4 | This module provides MCP tools for managing collections. 5 | """ 6 | from typing import Any, Dict, Optional 7 | 8 | from mcp_outline.features.documents.common import ( 9 | OutlineClientError, 10 | get_outline_client, 11 | ) 12 | 13 | 14 | def _format_file_operation(file_operation: Dict[str, Any]) -> str: 15 | """Format file operation data into readable text.""" 16 | if not file_operation: 17 | return "No file operation data available." 18 | 19 | # Get the file operation details 20 | state = file_operation.get("state", "unknown") 21 | type_info = file_operation.get("type", "unknown") 22 | name = file_operation.get("name", "unknown") 23 | file_operation_id = file_operation.get("id", "") 24 | 25 | # Format output 26 | output = f"# Export Operation: {name}\n\n" 27 | output += f"State: {state}\n" 28 | output += f"Type: {type_info}\n" 29 | output += f"ID: {file_operation_id}\n\n" 30 | 31 | # Provide instructions based on the state 32 | if state == "complete": 33 | output += "The export is complete and ready to download. " 34 | output += ( 35 | "Use the ID with the appropriate download tool to retrieve " 36 | "the file.\n" 37 | ) 38 | else: 39 | output += "The export is still in progress. " 40 | output += ( 41 | f"Check the operation state again later using the ID: " 42 | f"{file_operation_id}\n" 43 | ) 44 | 45 | return output 46 | 47 | def register_tools(mcp) -> None: 48 | """ 49 | Register collection management tools with the MCP server. 50 | 51 | Args: 52 | mcp: The FastMCP server instance 53 | """ 54 | @mcp.tool() 55 | def create_collection( 56 | name: str, 57 | description: str = "", 58 | color: Optional[str] = None 59 | ) -> str: 60 | """ 61 | Creates a new collection for organizing documents. 62 | 63 | Use this tool when you need to: 64 | - Create a new section or category for documents 65 | - Set up a workspace for a new project or team 66 | - Organize content by department or topic 67 | - Establish a separate space for related documents 68 | 69 | Args: 70 | name: Name for the collection 71 | description: Optional description of the collection's purpose 72 | color: Optional hex color code for visual identification 73 | (e.g. #FF0000) 74 | 75 | Returns: 76 | Result message with the new collection ID 77 | """ 78 | try: 79 | client = get_outline_client() 80 | collection = client.create_collection(name, description, color) 81 | 82 | if not collection: 83 | return "Failed to create collection." 84 | 85 | collection_id = collection.get("id", "unknown") 86 | collection_name = collection.get("name", "Untitled") 87 | 88 | return ( 89 | f"Collection created successfully: {collection_name} " 90 | f"(ID: {collection_id})" 91 | ) 92 | except OutlineClientError as e: 93 | return f"Error creating collection: {str(e)}" 94 | except Exception as e: 95 | return f"Unexpected error: {str(e)}" 96 | 97 | @mcp.tool() 98 | def update_collection( 99 | collection_id: str, 100 | name: Optional[str] = None, 101 | description: Optional[str] = None, 102 | color: Optional[str] = None 103 | ) -> str: 104 | """ 105 | Modifies an existing collection's properties. 106 | 107 | Use this tool when you need to: 108 | - Rename a collection 109 | - Update a collection's description 110 | - Change a collection's color coding 111 | - Refresh collection metadata 112 | 113 | Args: 114 | collection_id: The collection ID to update 115 | name: Optional new name for the collection 116 | description: Optional new description 117 | color: Optional new hex color code (e.g. #FF0000) 118 | 119 | Returns: 120 | Result message confirming update 121 | """ 122 | try: 123 | client = get_outline_client() 124 | 125 | # Make sure at least one field is being updated 126 | if name is None and description is None and color is None: 127 | return "Error: You must specify at least one field to update." 128 | 129 | collection = client.update_collection( 130 | collection_id, name, description, color 131 | ) 132 | 133 | if not collection: 134 | return "Failed to update collection." 135 | 136 | collection_name = collection.get("name", "Untitled") 137 | 138 | return f"Collection updated successfully: {collection_name}" 139 | except OutlineClientError as e: 140 | return f"Error updating collection: {str(e)}" 141 | except Exception as e: 142 | return f"Unexpected error: {str(e)}" 143 | 144 | @mcp.tool() 145 | def delete_collection(collection_id: str) -> str: 146 | """ 147 | Permanently removes a collection and all its documents. 148 | 149 | Use this tool when you need to: 150 | - Remove an entire section of content 151 | - Delete obsolete project collections 152 | - Remove collections that are no longer needed 153 | - Clean up workspace organization 154 | 155 | WARNING: This action cannot be undone and will delete all documents 156 | within the collection. 157 | 158 | Args: 159 | collection_id: The collection ID to delete 160 | 161 | Returns: 162 | Result message confirming deletion 163 | """ 164 | try: 165 | client = get_outline_client() 166 | success = client.delete_collection(collection_id) 167 | 168 | if success: 169 | return "Collection and all its documents deleted successfully." 170 | else: 171 | return "Failed to delete collection." 172 | except OutlineClientError as e: 173 | return f"Error deleting collection: {str(e)}" 174 | except Exception as e: 175 | return f"Unexpected error: {str(e)}" 176 | 177 | @mcp.tool() 178 | def export_collection( 179 | collection_id: str, 180 | format: str = "outline-markdown" 181 | ) -> str: 182 | """ 183 | Exports all documents in a collection to a downloadable file. 184 | 185 | IMPORTANT: This tool starts an asynchronous export operation which may 186 | take time to complete. The function returns information about the 187 | operation, including its status. When the operation is complete, the 188 | file can be downloaded or accessed via Outline's UI. The export 189 | preserves the document hierarchy and includes all document content and 190 | structure in the 191 | specified format. 192 | 193 | Use this tool when you need to: 194 | - Create a backup of collection content 195 | - Share collection content outside of Outline 196 | - Convert collection content to other formats 197 | - Archive collection content for offline use 198 | 199 | Args: 200 | collection_id: The collection ID to export 201 | format: Export format ("outline-markdown", "json", or "html") 202 | 203 | Returns: 204 | Information about the export operation and how to access the file 205 | """ 206 | try: 207 | client = get_outline_client() 208 | file_operation = client.export_collection(collection_id, format) 209 | 210 | if not file_operation: 211 | return "Failed to start export operation." 212 | 213 | return _format_file_operation(file_operation) 214 | except OutlineClientError as e: 215 | return f"Error exporting collection: {str(e)}" 216 | except Exception as e: 217 | return f"Unexpected error: {str(e)}" 218 | 219 | @mcp.tool() 220 | def export_all_collections(format: str = "outline-markdown") -> str: 221 | """ 222 | Exports the entire workspace content to a downloadable file. 223 | 224 | IMPORTANT: This tool starts an asynchronous export operation which may 225 | take time to complete, especially for large workspaces. The function 226 | returns information about the operation, including its status. When 227 | the operation is complete, the file can be downloaded or accessed via 228 | Outline's UI. The export includes all collections, documents, and 229 | their 230 | hierarchies in the specified format. 231 | 232 | Use this tool when you need to: 233 | - Create a complete backup of all workspace content 234 | - Migrate content to another system 235 | - Archive all workspace documents 236 | - Get a comprehensive export of knowledge base 237 | 238 | Args: 239 | format: Export format ("outline-markdown", "json", or "html") 240 | 241 | Returns: 242 | Information about the export operation and how to access the file 243 | """ 244 | try: 245 | client = get_outline_client() 246 | file_operation = client.export_all_collections(format) 247 | 248 | if not file_operation: 249 | return "Failed to start export operation." 250 | 251 | return _format_file_operation(file_operation) 252 | except OutlineClientError as e: 253 | return f"Error exporting collections: {str(e)}" 254 | except Exception as e: 255 | return f"Unexpected error: {str(e)}" 256 | ``` -------------------------------------------------------------------------------- /tests/features/documents/test_document_collaboration.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for document collaboration tools. 3 | """ 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from mcp_outline.features.documents.common import OutlineClientError 9 | from mcp_outline.features.documents.document_collaboration import ( 10 | _format_comments, 11 | ) 12 | 13 | 14 | # Mock FastMCP for registering tools 15 | class MockMCP: 16 | def __init__(self): 17 | self.tools = {} 18 | 19 | def tool(self): 20 | def decorator(func): 21 | self.tools[func.__name__] = func 22 | return func 23 | return decorator 24 | 25 | # Sample comment data 26 | SAMPLE_COMMENTS = [ 27 | { 28 | "id": "comment1", 29 | "data": {"content": "This is a test comment"}, 30 | "createdAt": "2023-01-01T12:00:00Z", 31 | "createdBy": { 32 | "id": "user1", 33 | "name": "Test User" 34 | } 35 | }, 36 | { 37 | "id": "comment2", 38 | "data": {"content": "Another comment"}, 39 | "createdAt": "2023-01-02T12:00:00Z", 40 | "createdBy": { 41 | "id": "user2", 42 | "name": "Another User" 43 | } 44 | } 45 | ] 46 | 47 | # Sample documents for backlinks 48 | SAMPLE_BACKLINK_DOCUMENTS = [ 49 | { 50 | "id": "doc1", 51 | "title": "Referencing Document 1", 52 | "updatedAt": "2023-01-01T12:00:00Z" 53 | }, 54 | { 55 | "id": "doc2", 56 | "title": "Referencing Document 2", 57 | "updatedAt": "2023-01-02T12:00:00Z" 58 | } 59 | ] 60 | 61 | @pytest.fixture 62 | def mcp(): 63 | """Fixture to provide mock MCP instance.""" 64 | return MockMCP() 65 | 66 | @pytest.fixture 67 | def register_collaboration_tools(mcp): 68 | """Fixture to register document collaboration tools.""" 69 | from mcp_outline.features.documents.document_collaboration import ( 70 | register_tools, 71 | ) 72 | register_tools(mcp) 73 | return mcp 74 | 75 | class TestDocumentCollaborationFormatters: 76 | """Tests for document collaboration formatting functions.""" 77 | 78 | def test_format_comments_with_data(self): 79 | """Test formatting comments with valid data.""" 80 | result = _format_comments(SAMPLE_COMMENTS) 81 | 82 | # Verify the result contains the expected information 83 | assert "# Document Comments" in result 84 | assert "Comment by Test User" in result 85 | assert "This is a test comment" in result 86 | assert "2023-01-01" in result 87 | assert "Comment by Another User" in result 88 | assert "Another comment" in result 89 | 90 | def test_format_comments_empty(self): 91 | """Test formatting empty comments list.""" 92 | result = _format_comments([]) 93 | 94 | assert "No comments found for this document" in result 95 | 96 | def test_format_comments_missing_fields(self): 97 | """Test formatting comments with missing fields.""" 98 | # Comments with missing fields 99 | incomplete_comments = [ 100 | # Missing user name 101 | { 102 | "id": "comment1", 103 | "data": {"content": "Comment with missing user"}, 104 | "createdAt": "2023-01-01T12:00:00Z", 105 | "createdBy": {} 106 | }, 107 | # Missing created date 108 | { 109 | "id": "comment2", 110 | "data": {"content": "Comment with missing date"}, 111 | "createdBy": { 112 | "name": "Test User" 113 | } 114 | }, 115 | # Missing text 116 | { 117 | "id": "comment3", 118 | "createdAt": "2023-01-03T12:00:00Z", 119 | "createdBy": { 120 | "name": "Another User" 121 | } 122 | } 123 | ] 124 | 125 | result = _format_comments(incomplete_comments) 126 | 127 | # Verify the result handles missing fields gracefully 128 | assert "Unknown User" in result 129 | assert "Comment with missing user" in result 130 | assert "Test User" in result 131 | assert "Comment with missing date" in result 132 | assert "Another User" in result 133 | 134 | class TestDocumentCollaborationTools: 135 | """Tests for document collaboration tools.""" 136 | 137 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 138 | def test_list_document_comments_success( 139 | self, mock_get_client, register_collaboration_tools 140 | ): 141 | """Test list_document_comments tool success case.""" 142 | # Set up mock client 143 | mock_client = MagicMock() 144 | mock_client.post.return_value = {"data": SAMPLE_COMMENTS} 145 | mock_get_client.return_value = mock_client 146 | 147 | # Call the tool 148 | result = register_collaboration_tools.tools[ 149 | "list_document_comments"]("doc123") 150 | 151 | # Verify client was called correctly 152 | mock_client.post.assert_called_once_with( 153 | "comments.list", { 154 | "documentId": "doc123", 155 | "includeAnchorText": False, 156 | "limit": 25, 157 | "offset": 0 158 | } 159 | ) 160 | 161 | # Verify result contains expected information 162 | assert "# Document Comments" in result 163 | assert "Comment by Test User" in result 164 | assert "This is a test comment" in result 165 | 166 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 167 | def test_list_document_comments_empty( 168 | self, mock_get_client, register_collaboration_tools 169 | ): 170 | """Test list_document_comments tool with no comments.""" 171 | # Set up mock client with empty response 172 | mock_client = MagicMock() 173 | mock_client.post.return_value = {"data": []} 174 | mock_get_client.return_value = mock_client 175 | 176 | # Call the tool 177 | result = register_collaboration_tools.tools[ 178 | "list_document_comments"]("doc123") 179 | 180 | # Verify result contains expected message 181 | assert "No comments found" in result 182 | 183 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 184 | def test_get_comment_success( 185 | self, mock_get_client, register_collaboration_tools 186 | ): 187 | """Test get_comment tool success case.""" 188 | # Set up mock client 189 | mock_client = MagicMock() 190 | mock_client.post.return_value = {"data": SAMPLE_COMMENTS[0]} 191 | mock_get_client.return_value = mock_client 192 | 193 | # Call the tool 194 | result = register_collaboration_tools.tools[ 195 | "get_comment"]("comment1") 196 | 197 | # Verify client was called correctly 198 | mock_client.post.assert_called_once_with( 199 | "comments.info", { 200 | "id": "comment1", 201 | "includeAnchorText": False 202 | } 203 | ) 204 | 205 | # Verify result contains expected information 206 | assert "# Comment by Test User" in result 207 | assert "This is a test comment" in result 208 | assert "2023-01-01" in result 209 | 210 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 211 | def test_get_comment_not_found( 212 | self, mock_get_client, register_collaboration_tools 213 | ): 214 | """Test get_comment tool with comment not found.""" 215 | # Set up mock client with empty response 216 | mock_client = MagicMock() 217 | mock_client.post.return_value = {"data": {}} 218 | mock_get_client.return_value = mock_client 219 | 220 | # Call the tool 221 | result = register_collaboration_tools.tools[ 222 | "get_comment"]("comment999") 223 | 224 | # Verify result contains expected message 225 | assert "Comment not found" in result 226 | 227 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 228 | def test_get_document_backlinks_success( 229 | self, mock_get_client, register_collaboration_tools 230 | ): 231 | """Test get_document_backlinks tool success case.""" 232 | # Set up mock client 233 | mock_client = MagicMock() 234 | mock_client.post.return_value = {"data": SAMPLE_BACKLINK_DOCUMENTS} 235 | mock_get_client.return_value = mock_client 236 | 237 | # Call the tool 238 | result = register_collaboration_tools.tools[ 239 | "get_document_backlinks"]("doc123") 240 | 241 | # Verify client was called correctly 242 | mock_client.post.assert_called_once_with( 243 | "documents.list", {"backlinkDocumentId": "doc123"} 244 | ) 245 | 246 | # Verify result contains expected information 247 | assert "# Documents Linking to This Document" in result 248 | assert "Referencing Document 1" in result 249 | assert "doc1" in result 250 | assert "Referencing Document 2" in result 251 | assert "doc2" in result 252 | 253 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 254 | def test_get_document_backlinks_none( 255 | self, mock_get_client, register_collaboration_tools 256 | ): 257 | """Test get_document_backlinks tool with no backlinks.""" 258 | # Set up mock client with empty response 259 | mock_client = MagicMock() 260 | mock_client.post.return_value = {"data": []} 261 | mock_get_client.return_value = mock_client 262 | 263 | # Call the tool 264 | result = register_collaboration_tools.tools[ 265 | "get_document_backlinks"]("doc123") 266 | 267 | # Verify result contains expected message 268 | assert "No documents link to this document" in result 269 | 270 | @patch("mcp_outline.features.documents.document_collaboration.get_outline_client") 271 | def test_get_document_backlinks_client_error( 272 | self, mock_get_client, register_collaboration_tools 273 | ): 274 | """Test get_document_backlinks tool with client error.""" 275 | # Set up mock client to raise an error 276 | mock_client = MagicMock() 277 | mock_client.post.side_effect = OutlineClientError("API error") 278 | mock_get_client.return_value = mock_client 279 | 280 | # Call the tool 281 | result = register_collaboration_tools.tools[ 282 | "get_document_backlinks"]("doc123") 283 | 284 | # Verify error is handled and returned 285 | assert "Error retrieving backlinks" in result 286 | assert "API error" in result 287 | ``` -------------------------------------------------------------------------------- /tests/features/documents/test_document_content.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for document content tools. 3 | """ 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from mcp_outline.features.documents.common import OutlineClientError 9 | 10 | 11 | # Mock FastMCP for registering tools 12 | class MockMCP: 13 | def __init__(self): 14 | self.tools = {} 15 | 16 | def tool(self): 17 | def decorator(func): 18 | self.tools[func.__name__] = func 19 | return func 20 | return decorator 21 | 22 | # Sample response data 23 | SAMPLE_CREATE_DOCUMENT_RESPONSE = { 24 | "data": { 25 | "id": "doc123", 26 | "title": "Test Document", 27 | "text": "This is a test document.", 28 | "updatedAt": "2023-01-01T12:00:00Z", 29 | "createdAt": "2023-01-01T12:00:00Z" 30 | } 31 | } 32 | 33 | SAMPLE_UPDATE_DOCUMENT_RESPONSE = { 34 | "data": { 35 | "id": "doc123", 36 | "title": "Updated Document", 37 | "text": "This document has been updated.", 38 | "updatedAt": "2023-01-02T12:00:00Z" 39 | } 40 | } 41 | 42 | SAMPLE_COMMENT_RESPONSE = { 43 | "data": { 44 | "id": "comment123", 45 | "documentId": "doc123", 46 | "createdById": "user123", 47 | "createdAt": "2023-01-01T12:00:00Z", 48 | "body": "This is a comment" 49 | } 50 | } 51 | 52 | @pytest.fixture 53 | def mcp(): 54 | """Fixture to provide mock MCP instance.""" 55 | return MockMCP() 56 | 57 | @pytest.fixture 58 | def register_content_tools(mcp): 59 | """Fixture to register document content tools.""" 60 | from mcp_outline.features.documents.document_content import register_tools 61 | register_tools(mcp) 62 | return mcp 63 | 64 | class TestDocumentContentTools: 65 | """Tests for document content tools.""" 66 | 67 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 68 | def test_create_document_success( 69 | self, mock_get_client, register_content_tools 70 | ): 71 | """Test create_document tool success case.""" 72 | # Set up mock client 73 | mock_client = MagicMock() 74 | mock_client.post.return_value = SAMPLE_CREATE_DOCUMENT_RESPONSE 75 | mock_get_client.return_value = mock_client 76 | 77 | # Call the tool 78 | result = register_content_tools.tools["create_document"]( 79 | title="Test Document", 80 | collection_id="col123", 81 | text="This is a test document." 82 | ) 83 | 84 | # Verify client was called correctly 85 | mock_client.post.assert_called_once_with( 86 | "documents.create", 87 | { 88 | "title": "Test Document", 89 | "text": "This is a test document.", 90 | "collectionId": "col123", 91 | "publish": True 92 | } 93 | ) 94 | 95 | # Verify result contains expected information 96 | assert "Document created successfully" in result 97 | assert "Test Document" in result 98 | assert "doc123" in result 99 | 100 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 101 | def test_create_document_with_parent( 102 | self, mock_get_client, register_content_tools 103 | ): 104 | """Test create_document tool with parent document ID.""" 105 | # Set up mock client 106 | mock_client = MagicMock() 107 | mock_client.post.return_value = SAMPLE_CREATE_DOCUMENT_RESPONSE 108 | mock_get_client.return_value = mock_client 109 | 110 | # Call the tool with parent document ID 111 | _ = register_content_tools.tools["create_document"]( 112 | title="Test Document", 113 | collection_id="col123", 114 | text="This is a test document.", 115 | parent_document_id="parent123" 116 | ) 117 | 118 | # Verify parent document ID was included in the call 119 | mock_client.post.assert_called_once() 120 | call_args = mock_client.post.call_args[0] 121 | 122 | assert call_args[0] == "documents.create" 123 | assert "parentDocumentId" in call_args[1] 124 | assert call_args[1]["parentDocumentId"] == "parent123" 125 | 126 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 127 | def test_create_document_failure( 128 | self, mock_get_client, register_content_tools 129 | ): 130 | """Test create_document tool with empty response.""" 131 | # Set up mock client with empty response 132 | mock_client = MagicMock() 133 | mock_client.post.return_value = {"data": None} 134 | mock_get_client.return_value = mock_client 135 | 136 | # Call the tool 137 | result = register_content_tools.tools["create_document"]( 138 | title="Test Document", 139 | collection_id="col123" 140 | ) 141 | 142 | # Verify result contains error message 143 | assert "Failed to create document" in result 144 | 145 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 146 | def test_create_document_client_error( 147 | self, mock_get_client, register_content_tools 148 | ): 149 | """Test create_document tool with client error.""" 150 | # Set up mock client to raise an error 151 | mock_client = MagicMock() 152 | mock_client.post.side_effect = OutlineClientError("API error") 153 | mock_get_client.return_value = mock_client 154 | 155 | # Call the tool 156 | result = register_content_tools.tools["create_document"]( 157 | title="Test Document", 158 | collection_id="col123" 159 | ) 160 | 161 | # Verify error is handled and returned 162 | assert "Error creating document" in result 163 | assert "API error" in result 164 | 165 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 166 | def test_update_document_success( 167 | self, mock_get_client, register_content_tools 168 | ): 169 | """Test update_document tool success case.""" 170 | # Set up mock client 171 | mock_client = MagicMock() 172 | mock_client.post.return_value = SAMPLE_UPDATE_DOCUMENT_RESPONSE 173 | mock_get_client.return_value = mock_client 174 | 175 | # Call the tool 176 | result = register_content_tools.tools["update_document"]( 177 | document_id="doc123", 178 | title="Updated Document", 179 | text="This document has been updated." 180 | ) 181 | 182 | # Verify client was called correctly 183 | mock_client.post.assert_called_once_with( 184 | "documents.update", 185 | { 186 | "id": "doc123", 187 | "title": "Updated Document", 188 | "text": "This document has been updated.", 189 | "append": False 190 | } 191 | ) 192 | 193 | # Verify result contains expected information 194 | assert "Document updated successfully" in result 195 | assert "Updated Document" in result 196 | 197 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 198 | def test_update_document_append( 199 | self, mock_get_client, register_content_tools 200 | ): 201 | """Test update_document tool with append flag.""" 202 | # Set up mock client 203 | mock_client = MagicMock() 204 | mock_client.post.return_value = SAMPLE_UPDATE_DOCUMENT_RESPONSE 205 | mock_get_client.return_value = mock_client 206 | 207 | # Call the tool with append flag 208 | _ = register_content_tools.tools["update_document"]( 209 | document_id="doc123", 210 | text="Additional text.", 211 | append=True 212 | ) 213 | 214 | # Verify append flag was included in the call 215 | mock_client.post.assert_called_once() 216 | call_args = mock_client.post.call_args[0] 217 | 218 | assert call_args[0] == "documents.update" 219 | assert "append" in call_args[1] 220 | assert call_args[1]["append"] is True 221 | 222 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 223 | def test_add_comment_success( 224 | self, mock_get_client, register_content_tools 225 | ): 226 | """Test add_comment tool success case.""" 227 | # Set up mock client 228 | mock_client = MagicMock() 229 | mock_client.post.return_value = SAMPLE_COMMENT_RESPONSE 230 | mock_get_client.return_value = mock_client 231 | 232 | # Call the tool 233 | result = register_content_tools.tools["add_comment"]( 234 | document_id="doc123", 235 | text="This is a comment" 236 | ) 237 | 238 | # Verify client was called correctly 239 | mock_client.post.assert_called_once_with( 240 | "comments.create", 241 | { 242 | "documentId": "doc123", 243 | "text": "This is a comment" 244 | } 245 | ) 246 | 247 | # Verify result contains expected information 248 | assert "Comment added successfully" in result 249 | assert "comment123" in result 250 | 251 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 252 | def test_add_comment_failure( 253 | self, mock_get_client, register_content_tools 254 | ): 255 | """Test add_comment tool with empty response.""" 256 | # Set up mock client with empty response 257 | mock_client = MagicMock() 258 | mock_client.post.return_value = {"data": None} 259 | mock_get_client.return_value = mock_client 260 | 261 | # Call the tool 262 | result = register_content_tools.tools["add_comment"]( 263 | document_id="doc123", 264 | text="This is a comment" 265 | ) 266 | 267 | # Verify result contains error message 268 | assert "Failed to create comment" in result 269 | 270 | @patch("mcp_outline.features.documents.document_content.get_outline_client") 271 | def test_add_comment_client_error( 272 | self, mock_get_client, register_content_tools 273 | ): 274 | """Test add_comment tool with client error.""" 275 | # Set up mock client to raise an error 276 | mock_client = MagicMock() 277 | mock_client.post.side_effect = OutlineClientError("API error") 278 | mock_get_client.return_value = mock_client 279 | 280 | # Call the tool 281 | result = register_content_tools.tools["add_comment"]( 282 | document_id="doc123", 283 | text="This is a comment" 284 | ) 285 | 286 | # Verify error is handled and returned 287 | assert "Error adding comment" in result 288 | assert "API error" in result 289 | ``` -------------------------------------------------------------------------------- /src/mcp_outline/utils/outline_client.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Client for interacting with Outline API. 3 | 4 | A simple client for making requests to the Outline API. 5 | """ 6 | import os 7 | from typing import Any, Dict, List, Optional 8 | 9 | import requests 10 | 11 | 12 | class OutlineError(Exception): 13 | """Exception for all Outline API errors.""" 14 | pass 15 | 16 | class OutlineClient: 17 | """Simple client for Outline API services.""" 18 | 19 | def __init__( 20 | self, 21 | api_key: Optional[str] = None, 22 | api_url: Optional[str] = None 23 | ): 24 | """ 25 | Initialize the Outline client. 26 | 27 | Args: 28 | api_key: Outline API key or from OUTLINE_API_KEY env var. 29 | api_url: Outline API URL or from OUTLINE_API_URL env var. 30 | 31 | Raises: 32 | OutlineError: If API key is missing. 33 | """ 34 | # Load configuration from environment variables if not provided 35 | self.api_key = api_key or os.getenv("OUTLINE_API_KEY") 36 | self.api_url = api_url or os.getenv("OUTLINE_API_URL", "https://app.getoutline.com/api") 37 | 38 | # Ensure API key is provided 39 | if not self.api_key: 40 | raise OutlineError("Missing API key. Set OUTLINE_API_KEY env var.") 41 | 42 | def post( 43 | self, 44 | endpoint: str, 45 | data: Optional[Dict[str, Any]] = None 46 | ) -> Dict[str, Any]: 47 | """ 48 | Make a POST request to the Outline API. 49 | 50 | Args: 51 | endpoint: The API endpoint to call. 52 | data: The request payload. 53 | 54 | Returns: 55 | The parsed JSON response. 56 | 57 | Raises: 58 | OutlineError: If the request fails. 59 | """ 60 | url = f"{self.api_url}/{endpoint}" 61 | headers = { 62 | "Authorization": f"Bearer {self.api_key}", 63 | "Content-Type": "application/json", 64 | "Accept": "application/json" 65 | } 66 | 67 | try: 68 | response = requests.post(url, headers=headers, json=data or {}) 69 | # Raise exception for 4XX/5XX responses 70 | response.raise_for_status() 71 | return response.json() 72 | except requests.exceptions.RequestException as e: 73 | raise OutlineError(f"API request failed: {str(e)}") 74 | 75 | def auth_info(self) -> Dict[str, Any]: 76 | """ 77 | Verify authentication and get user information. 78 | 79 | Returns: 80 | Dict containing user and team information. 81 | """ 82 | response = self.post("auth.info") 83 | return response.get("data", {}) 84 | 85 | def get_document(self, document_id: str) -> Dict[str, Any]: 86 | """ 87 | Get a document by ID. 88 | 89 | Args: 90 | document_id: The document ID. 91 | 92 | Returns: 93 | Document information. 94 | """ 95 | response = self.post("documents.info", {"id": document_id}) 96 | return response.get("data", {}) 97 | 98 | def search_documents( 99 | self, 100 | query: str, 101 | collection_id: Optional[str] = None, 102 | limit: int = 10 103 | ) -> List[Dict[str, Any]]: 104 | """ 105 | Search for documents using keywords. 106 | 107 | Args: 108 | query: Search terms 109 | collection_id: Optional collection to search within 110 | limit: Maximum number of results to return 111 | 112 | Returns: 113 | List of matching documents with context 114 | """ 115 | data: Dict[str, Any] = {"query": query, "limit": limit} 116 | if collection_id: 117 | data["collectionId"] = collection_id 118 | 119 | response = self.post("documents.search", data) 120 | return response.get("data", []) 121 | 122 | def list_collections(self, limit: int = 20) -> List[Dict[str, Any]]: 123 | """ 124 | List all available collections. 125 | 126 | Args: 127 | limit: Maximum number of results to return 128 | 129 | Returns: 130 | List of collections 131 | """ 132 | response = self.post("collections.list", {"limit": limit}) 133 | return response.get("data", []) 134 | 135 | def get_collection_documents( 136 | self, collection_id: str 137 | ) -> List[Dict[str, Any]]: 138 | """ 139 | Get document structure for a collection. 140 | 141 | Args: 142 | collection_id: The collection ID. 143 | 144 | Returns: 145 | List of document nodes in the collection. 146 | """ 147 | response = self.post("collections.documents", {"id": collection_id}) 148 | return response.get("data", []) 149 | 150 | def list_documents( 151 | self, 152 | collection_id: Optional[str] = None, 153 | limit: int = 20 154 | ) -> List[Dict[str, Any]]: 155 | """ 156 | List documents with optional filtering. 157 | 158 | Args: 159 | collection_id: Optional collection to filter by 160 | limit: Maximum number of results to return 161 | 162 | Returns: 163 | List of documents 164 | """ 165 | data: Dict[str, Any] = {"limit": limit} 166 | if collection_id: 167 | data["collectionId"] = collection_id 168 | 169 | response = self.post("documents.list", data) 170 | return response.get("data", []) 171 | 172 | def archive_document(self, document_id: str) -> Dict[str, Any]: 173 | """ 174 | Archive a document by ID. 175 | 176 | Args: 177 | document_id: The document ID to archive. 178 | 179 | Returns: 180 | The archived document data. 181 | """ 182 | response = self.post("documents.archive", {"id": document_id}) 183 | return response.get("data", {}) 184 | 185 | def unarchive_document(self, document_id: str) -> Dict[str, Any]: 186 | """ 187 | Unarchive a document by ID. 188 | 189 | Args: 190 | document_id: The document ID to unarchive. 191 | 192 | Returns: 193 | The unarchived document data. 194 | """ 195 | response = self.post("documents.unarchive", {"id": document_id}) 196 | return response.get("data", {}) 197 | 198 | def list_trash(self, limit: int = 25) -> List[Dict[str, Any]]: 199 | """ 200 | List documents in the trash. 201 | 202 | Args: 203 | limit: Maximum number of results to return 204 | 205 | Returns: 206 | List of documents in trash 207 | """ 208 | response = self.post( 209 | "documents.list", {"limit": limit, "deleted": True} 210 | ) 211 | return response.get("data", []) 212 | 213 | def restore_document(self, document_id: str) -> Dict[str, Any]: 214 | """ 215 | Restore a document from trash. 216 | 217 | Args: 218 | document_id: The document ID to restore. 219 | 220 | Returns: 221 | The restored document data. 222 | """ 223 | response = self.post("documents.restore", {"id": document_id}) 224 | return response.get("data", {}) 225 | 226 | def permanently_delete_document(self, document_id: str) -> bool: 227 | """ 228 | Permanently delete a document by ID. 229 | 230 | Args: 231 | document_id: The document ID to permanently delete. 232 | 233 | Returns: 234 | Success status. 235 | """ 236 | response = self.post("documents.delete", { 237 | "id": document_id, 238 | "permanent": True 239 | }) 240 | return response.get("success", False) 241 | 242 | # Collection management methods 243 | def create_collection( 244 | self, 245 | name: str, 246 | description: str = "", 247 | color: Optional[str] = None 248 | ) -> Dict[str, Any]: 249 | """ 250 | Create a new collection. 251 | 252 | Args: 253 | name: The name of the collection 254 | description: Optional description for the collection 255 | color: Optional hex color code for the collection 256 | 257 | Returns: 258 | The created collection data 259 | """ 260 | data: Dict[str, Any] = { 261 | "name": name, 262 | "description": description 263 | } 264 | 265 | if color: 266 | data["color"] = color 267 | 268 | response = self.post("collections.create", data) 269 | return response.get("data", {}) 270 | 271 | def update_collection( 272 | self, 273 | collection_id: str, 274 | name: Optional[str] = None, 275 | description: Optional[str] = None, 276 | color: Optional[str] = None 277 | ) -> Dict[str, Any]: 278 | """ 279 | Update an existing collection. 280 | 281 | Args: 282 | collection_id: The ID of the collection to update 283 | name: Optional new name for the collection 284 | description: Optional new description 285 | color: Optional new hex color code 286 | 287 | Returns: 288 | The updated collection data 289 | """ 290 | data: Dict[str, Any] = {"id": collection_id} 291 | 292 | if name is not None: 293 | data["name"] = name 294 | 295 | if description is not None: 296 | data["description"] = description 297 | 298 | if color is not None: 299 | data["color"] = color 300 | 301 | response = self.post("collections.update", data) 302 | return response.get("data", {}) 303 | 304 | def delete_collection(self, collection_id: str) -> bool: 305 | """ 306 | Delete a collection and all its documents. 307 | 308 | Args: 309 | collection_id: The ID of the collection to delete 310 | 311 | Returns: 312 | Success status 313 | """ 314 | response = self.post("collections.delete", {"id": collection_id}) 315 | return response.get("success", False) 316 | 317 | def export_collection( 318 | self, 319 | collection_id: str, 320 | format: str = "outline-markdown" 321 | ) -> Dict[str, Any]: 322 | """ 323 | Export a collection to a file. 324 | 325 | Args: 326 | collection_id: The ID of the collection to export 327 | format: The export format (outline-markdown, json, or html) 328 | 329 | Returns: 330 | FileOperation data that can be queried for progress 331 | """ 332 | response = self.post("collections.export", { 333 | "id": collection_id, 334 | "format": format 335 | }) 336 | return response.get("data", {}) 337 | 338 | def export_all_collections( 339 | self, 340 | format: str = "outline-markdown" 341 | ) -> Dict[str, Any]: 342 | """ 343 | Export all collections to a file. 344 | 345 | Args: 346 | format: The export format (outline-markdown, json, or html) 347 | 348 | Returns: 349 | FileOperation data that can be queried for progress 350 | """ 351 | response = self.post("collections.export_all", {"format": format}) 352 | return response.get("data", {}) 353 | 354 | def answer_question(self, 355 | query: str, 356 | collection_id: Optional[str] = None, 357 | document_id: Optional[str] = None) -> Dict[str, Any]: 358 | """ 359 | Ask a natural language question about document content. 360 | 361 | Args: 362 | query: The natural language question to answer 363 | collection_id: Optional collection to search within 364 | document_id: Optional document to search within 365 | 366 | Returns: 367 | Dictionary containing AI answer and search results 368 | """ 369 | data: Dict[str, Any] = {"query": query} 370 | 371 | if collection_id: 372 | data["collectionId"] = collection_id 373 | 374 | if document_id: 375 | data["documentId"] = document_id 376 | 377 | response = self.post("documents.answerQuestion", data) 378 | return response 379 | ``` -------------------------------------------------------------------------------- /tests/features/documents/test_document_search.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for document search tools. 3 | """ 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from mcp_outline.features.documents.common import OutlineClientError 9 | from mcp_outline.features.documents.document_search import ( 10 | _format_collection_documents, 11 | _format_collections, 12 | _format_documents_list, 13 | _format_search_results, 14 | ) 15 | 16 | 17 | # Mock FastMCP for registering tools 18 | class MockMCP: 19 | def __init__(self): 20 | self.tools = {} 21 | 22 | def tool(self): 23 | def decorator(func): 24 | self.tools[func.__name__] = func 25 | return func 26 | return decorator 27 | 28 | # Sample test data 29 | SAMPLE_SEARCH_RESULTS = [ 30 | { 31 | "document": { 32 | "id": "doc1", 33 | "title": "Test Document 1" 34 | }, 35 | "context": "This is a test document." 36 | }, 37 | { 38 | "document": { 39 | "id": "doc2", 40 | "title": "Test Document 2" 41 | }, 42 | "context": "Another test document." 43 | } 44 | ] 45 | 46 | SAMPLE_DOCUMENTS = [ 47 | { 48 | "id": "doc1", 49 | "title": "Test Document 1", 50 | "updatedAt": "2023-01-01T12:00:00Z" 51 | }, 52 | { 53 | "id": "doc2", 54 | "title": "Test Document 2", 55 | "updatedAt": "2023-01-02T12:00:00Z" 56 | } 57 | ] 58 | 59 | SAMPLE_COLLECTIONS = [ 60 | { 61 | "id": "coll1", 62 | "name": "Test Collection 1", 63 | "description": "Collection description" 64 | }, 65 | { 66 | "id": "coll2", 67 | "name": "Test Collection 2", 68 | "description": "" 69 | } 70 | ] 71 | 72 | SAMPLE_COLLECTION_DOCUMENTS = [ 73 | { 74 | "id": "doc1", 75 | "title": "Root Document", 76 | "children": [ 77 | { 78 | "id": "doc2", 79 | "title": "Child Document", 80 | "children": [] 81 | } 82 | ] 83 | } 84 | ] 85 | 86 | class TestDocumentSearchFormatters: 87 | """Tests for document search formatting functions.""" 88 | 89 | def test_format_search_results_with_data(self): 90 | """Test formatting search results with valid data.""" 91 | result = _format_search_results(SAMPLE_SEARCH_RESULTS) 92 | 93 | # Verify the result contains the expected information 94 | assert "# Search Results" in result 95 | assert "Test Document 1" in result 96 | assert "doc1" in result 97 | assert "This is a test document." in result 98 | assert "Test Document 2" in result 99 | 100 | def test_format_search_results_empty(self): 101 | """Test formatting empty search results.""" 102 | result = _format_search_results([]) 103 | 104 | assert "No documents found" in result 105 | 106 | def test_format_documents_list_with_data(self): 107 | """Test formatting document list with valid data.""" 108 | result = _format_documents_list(SAMPLE_DOCUMENTS, "Document List") 109 | 110 | # Verify the result contains the expected information 111 | assert "# Document List" in result 112 | assert "Test Document 1" in result 113 | assert "doc1" in result 114 | assert "2023-01-01" in result 115 | assert "Test Document 2" in result 116 | 117 | def test_format_collections_with_data(self): 118 | """Test formatting collections with valid data.""" 119 | result = _format_collections(SAMPLE_COLLECTIONS) 120 | 121 | # Verify the result contains the expected information 122 | assert "# Collections" in result 123 | assert "Test Collection 1" in result 124 | assert "coll1" in result 125 | assert "Collection description" in result 126 | assert "Test Collection 2" in result 127 | 128 | def test_format_collections_empty(self): 129 | """Test formatting empty collections list.""" 130 | result = _format_collections([]) 131 | 132 | assert "No collections found" in result 133 | 134 | def test_format_collection_documents_with_data(self): 135 | """Test formatting collection document structure with valid data.""" 136 | result = _format_collection_documents(SAMPLE_COLLECTION_DOCUMENTS) 137 | 138 | # Verify the result contains the expected information 139 | assert "# Collection Structure" in result 140 | assert "Root Document" in result 141 | assert "doc1" in result 142 | assert "Child Document" in result 143 | assert "doc2" in result 144 | 145 | def test_format_collection_documents_empty(self): 146 | """Test formatting empty collection document structure.""" 147 | result = _format_collection_documents([]) 148 | 149 | assert "No documents found in this collection" in result 150 | 151 | @pytest.fixture 152 | def mcp(): 153 | """Fixture to provide mock MCP instance.""" 154 | return MockMCP() 155 | 156 | @pytest.fixture 157 | def register_search_tools(mcp): 158 | """Fixture to register document search tools.""" 159 | from mcp_outline.features.documents.document_search import register_tools 160 | register_tools(mcp) 161 | return mcp 162 | 163 | class TestDocumentSearchTools: 164 | """Tests for document search tools.""" 165 | 166 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 167 | def test_search_documents_success( 168 | self, mock_get_client, register_search_tools 169 | ): 170 | """Test search_documents tool success case.""" 171 | # Set up mock client 172 | mock_client = MagicMock() 173 | mock_client.search_documents.return_value = SAMPLE_SEARCH_RESULTS 174 | mock_get_client.return_value = mock_client 175 | 176 | # Call the tool 177 | result = register_search_tools.tools["search_documents"]("test query") 178 | 179 | # Verify client was called correctly 180 | mock_client.search_documents.assert_called_once_with( 181 | "test query", 182 | None 183 | ) 184 | 185 | # Verify result contains expected information 186 | assert "Test Document 1" in result 187 | assert "doc1" in result 188 | 189 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 190 | def test_search_documents_with_collection( 191 | self, mock_get_client, register_search_tools 192 | ): 193 | """Test search_documents tool with collection filter.""" 194 | # Set up mock client 195 | mock_client = MagicMock() 196 | mock_client.search_documents.return_value = SAMPLE_SEARCH_RESULTS 197 | mock_get_client.return_value = mock_client 198 | 199 | # Call the tool 200 | _ = register_search_tools.tools["search_documents"]( 201 | "test query", 202 | "coll1" 203 | ) 204 | 205 | # Verify client was called correctly 206 | mock_client.search_documents.assert_called_once_with( 207 | "test query", "coll1") 208 | 209 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 210 | def test_search_documents_client_error( 211 | self, mock_get_client, register_search_tools 212 | ): 213 | """Test search_documents tool with client error.""" 214 | # Set up mock client to raise an error 215 | mock_client = MagicMock() 216 | mock_client.search_documents.side_effect = OutlineClientError( 217 | "API error") 218 | mock_get_client.return_value = mock_client 219 | 220 | # Call the tool 221 | result = register_search_tools.tools["search_documents"]("test query") 222 | 223 | # Verify error is handled and returned 224 | assert "Error searching documents" in result 225 | assert "API error" in result 226 | 227 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 228 | def test_list_collections_success( 229 | self, mock_get_client, register_search_tools 230 | ): 231 | """Test list_collections tool success case.""" 232 | # Set up mock client 233 | mock_client = MagicMock() 234 | mock_client.list_collections.return_value = SAMPLE_COLLECTIONS 235 | mock_get_client.return_value = mock_client 236 | 237 | # Call the tool 238 | result = register_search_tools.tools["list_collections"]() 239 | 240 | # Verify client was called correctly 241 | mock_client.list_collections.assert_called_once() 242 | 243 | # Verify result contains expected information 244 | assert "Test Collection 1" in result 245 | assert "coll1" in result 246 | 247 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 248 | def test_get_collection_structure_success( 249 | self, mock_get_client, register_search_tools 250 | ): 251 | """Test get_collection_structure tool success case.""" 252 | # Set up mock client 253 | mock_client = MagicMock() 254 | mock_client.get_collection_documents.return_value = ( 255 | SAMPLE_COLLECTION_DOCUMENTS) 256 | mock_get_client.return_value = mock_client 257 | 258 | # Call the tool 259 | result = register_search_tools.tools[ 260 | "get_collection_structure"]("coll1") 261 | 262 | # Verify client was called correctly 263 | mock_client.get_collection_documents.assert_called_once_with("coll1") 264 | 265 | # Verify result contains expected information 266 | assert "Root Document" in result 267 | assert "Child Document" in result 268 | 269 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 270 | def test_get_document_id_from_title_exact_match( 271 | self, mock_get_client, register_search_tools 272 | ): 273 | """Test get_document_id_from_title tool with exact match.""" 274 | # Search results with exact title match 275 | exact_match_results = [ 276 | { 277 | "document": { 278 | "id": "doc1", 279 | "title": "Exact Match" 280 | } 281 | } 282 | ] 283 | 284 | # Set up mock client 285 | mock_client = MagicMock() 286 | mock_client.search_documents.return_value = exact_match_results 287 | mock_get_client.return_value = mock_client 288 | 289 | # Call the tool 290 | result = register_search_tools.tools["get_document_id_from_title"]( 291 | "Exact Match" 292 | ) 293 | 294 | # Verify client was called correctly 295 | mock_client.search_documents.assert_called_once_with( 296 | "Exact Match", None) 297 | 298 | # Verify result contains expected information 299 | assert "Document ID: doc1" in result 300 | assert "Exact Match" in result 301 | 302 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 303 | def test_get_document_id_from_title_best_match( 304 | self, mock_get_client, register_search_tools 305 | ): 306 | """Test get_document_id_from_title tool with best match (non-exact).""" 307 | # Set up mock client 308 | mock_client = MagicMock() 309 | mock_client.search_documents.return_value = SAMPLE_SEARCH_RESULTS 310 | mock_get_client.return_value = mock_client 311 | 312 | # Call the tool with title that doesn't exactly match 313 | result = register_search_tools.tools[ 314 | "get_document_id_from_title"]("Test Doc") 315 | 316 | # Verify result contains expected information 317 | assert "Best match" in result 318 | assert "doc1" in result 319 | 320 | @patch("mcp_outline.features.documents.document_search.get_outline_client") 321 | def test_get_document_id_from_title_no_results( 322 | self, mock_get_client, register_search_tools 323 | ): 324 | """Test get_document_id_from_title tool with no results.""" 325 | # Set up mock client 326 | mock_client = MagicMock() 327 | mock_client.search_documents.return_value = [] 328 | mock_get_client.return_value = mock_client 329 | 330 | # Call the tool 331 | result = register_search_tools.tools[ 332 | "get_document_id_from_title"]("Nonexistent") 333 | 334 | # Verify result contains expected information 335 | assert "No documents found" in result 336 | assert "Nonexistent" in result 337 | ```