#
tokens: 34469/50000 34/34 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | ![MCP Inspector](./docs/mcp_inspector_guide.png)
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 | 
```