#
tokens: 25161/50000 34/34 files
lines: off (toggle) GitHub
raw markdown copy
# 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:
--------------------------------------------------------------------------------

```
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# UV
#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#uv.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
#   in version control.
#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Ruff stuff:
.ruff_cache/

# PyPI configuration file
.pypirc
.aider*
repomix-output.xml
docs/external

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# MCP Outline Server

A Model Context Protocol (MCP) server enabling AI assistants to interact with Outline (https://www.getoutline.com)

## Overview

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.

## Features

Currently implemented:

- **Document Search**: Search for documents by keywords
- **Collection Management**: List collections and view document structure
- **Document Reading**: Read document content, export as markdown
- **Comment Management**: View and add comments on documents
- **Document Creation**: Create new documents in collections
- **Document Editing**: Update document content and move documents
- **Backlink Management**: View documents that link to a specific document

## Add to Cursor with Docker

We recommend running this python MCP server using Docker to avoid having to install dependencies on your machine.

1. Install and run Docker (or Docker Desktop)
2. Build the Docker image `docker buildx build -t mcp-outline .`
3. In Cursor, go to the "MCP Servers" tab and click "Add Server"
   ```json
   {
     "mcpServers": {
       "mcp-outline": {
         "command": "docker",
         "args": [
           "run",
           "-i",
           "--rm",
           "--init",
           "-e",
           "DOCKER_CONTAINER=true",
           "-e",
           "OUTLINE_API_KEY",
           "-e",
           "OUTLINE_API_URL",
           "mcp-outline"
         ],
         "env": {
           "OUTLINE_API_KEY": "<YOUR_OUTLINE_API_KEY>",
           "OUTLINE_API_URL": "<YOUR_OUTLINE_API_URL>"
         }
       }
     }
   }
   ```
   > OUTLINE_API_URL is optional, defaulting to https://app.getoutline.com/api
4. Debug the docker image by using MCP inspector and passing the docker image to it:
   ```bash
   npx @modelcontextprotocol/inspector docker run -i --rm --init -e DOCKER_CONTAINER=true --env-file .env mcp-outline
   ```

## Development

### Prerequisites

- Python 3.10+
- Outline account with API access
- Outline API key (get this from your Outline account settings)

### Installation

```bash
# Clone the repository
git clone https://github.com/Vortiago/mcp-outline.git
cd mcp-outline

# Install in development mode
uv pip install -e ".[dev]"
```

### Configuration

Create a `.env` file in the project root with the following variables:

```
# Outline API Configuration
OUTLINE_API_KEY=your_outline_api_key_here

# For cloud-hosted Outline (default)
# OUTLINE_API_URL=https://app.getoutline.com/api

# For self-hosted Outline
# OUTLINE_API_URL=https://your-outline-instance.example.com/api
```

### Running the Server

```bash
# Development mode with the MCP Inspector
mcp dev src/mcp_outline/server.py

# Or use the provided script
./start_server.sh

# Install in Claude Desktop (if available)
mcp install src/mcp_outline/server.py --name "Document Outline Assistant"
```

When running the MCP Inspector, go to Tools > Click on a tool > it appears on the right side so that you can query it.
![MCP Inspector](./docs/mcp_inspector_guide.png)

## Usage Examples

### Search for Documents

```
Search for documents containing "project planning"
```

### List Collections

```
Show me all available collections
```

### Read a Document

```
Get the content of document with ID "docId123"
```

### Create a New Document

```
Create a new document titled "Research Report" in collection "colId456" with content "# Introduction\n\nThis is a research report..."
```

### Add a Comment

```
Add a comment to document "docId123" saying "This looks great, but we should add more details to the methodology section."
```

### Move a Document

```
Move document "docId123" to collection "colId789"
```

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

## Development

```bash
# Run tests
uv run pytest tests/

# Format code
uv run ruff format .
```

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## Acknowledgments

- Built with [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- Uses [Outline API](https://getoutline.com) for document management

```

--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------

```markdown
# MCP Outline Server Guide

This guide helps Claude implement and modify the MCP Outline server codebase effectively.

## 1. Purpose & Overview

This MCP server enables AI assistants to interact with Outline by:
- Connecting to Outline services via REST API
- Exposing Outline data (documents, collections, comments)
- Providing tools to create and modify Outline objects
- Using API key authentication for secure interactions

## 2. Core Concepts

### Outline & MCP Integration

This project bridges two systems:

1. **Outline Objects**:
   - Documents (content, metadata)
   - Collections (grouping of documents)
   - Comments on documents
   - Document structure and hierarchy

2. **MCP Components**:
   - **Tools**: Functions that interact with Outline API

## 3. Implementation Guidelines

### Tools Implementation
- Create tool functions for each Outline API endpoint
- Follow existing patterns in the `features/documents/` directory
- Keep functions simple with clear purposes
- Handle authentication and errors properly
- Example implementations: `search_documents`, `create_document`

### Development Workflow
1. Review the Outline API documentation
2. Use simple HTTP requests to the API
3. Follow existing patterns and code style
4. Keep the KISS principle in mind

## 4. Technical Requirements

### Code Style
- PEP 8 conventions
- Type hints for all functions
- Line length: 79 characters
- Small, focused functions

### Development Tools
- Install: `uv pip install -e ".[dev]"`
- Run server: `mcp dev src/mcp_outline/server.py`
- Run tests: `uv run pytest tests/`
- Format: `uv run ruff format .`

### Critical Requirements
- No logging to stdout/stderr
- Import sorting: standard library → third-party → local
- Proper error handling with specific exceptions
- Follow the KISS principle

```

--------------------------------------------------------------------------------
/src/mcp_outline/utils/__init__.py:
--------------------------------------------------------------------------------

```python

```

--------------------------------------------------------------------------------
/tests/features/__init__.py:
--------------------------------------------------------------------------------

```python
# Features test package

```

--------------------------------------------------------------------------------
/tests/features/documents/__init__.py:
--------------------------------------------------------------------------------

```python
# Document features test package

```

--------------------------------------------------------------------------------
/tests/utils/__init__.py:
--------------------------------------------------------------------------------

```python
"""
Test utilities for mcp-outline.
"""

```

--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------

```python
# Tests for MCP Outline
"""
Test package for MCP Outline
"""

```

--------------------------------------------------------------------------------
/src/mcp_outline/__init__.py:
--------------------------------------------------------------------------------

```python
# MCP Outline package
"""
MCP server for document outlines
"""

```

--------------------------------------------------------------------------------
/src/mcp_outline/__main__.py:
--------------------------------------------------------------------------------

```python
"""
Main module for MCP Outline server
"""
from mcp_outline.server import main

if __name__ == "__main__":
    main()

```

--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------

```json
{
    "python.testing.pytestArgs": [
        "tests"
    ],
    "python.testing.unittestEnabled": false,
    "python.testing.pytestEnabled": true
}
```

--------------------------------------------------------------------------------
/src/mcp_outline/features/__init__.py:
--------------------------------------------------------------------------------

```python
# Document Outline MCP features package
from mcp_outline.features import documents


def register_all(mcp):
    """
    Register all features with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    documents.register(mcp)

```

--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------

```python
"""
Tests for the MCP Outline server.
"""
import pytest

from mcp_outline.server import mcp


@pytest.mark.anyio
async def test_server_initialization():
    """Test that the server initializes correctly."""
    assert mcp.name == "Document Outline"
    assert len(await mcp.list_tools()) > 0  # Ensure functions are registered

```

--------------------------------------------------------------------------------
/start_server.sh:
--------------------------------------------------------------------------------

```bash
#!/bin/bash
# Start the MCP Outline server.
# Primarily used during development to make it easier for Claude to access the server living inside WSL2.

# Activate virtual environment if it exists
if [ -d ".venv" ]; then
    source .venv/bin/activate
fi

export $(grep -v '^#' .env | xargs)

# Run the server in development mode
python src/mcp_outline/server.py

```

--------------------------------------------------------------------------------
/src/mcp_outline/server.py:
--------------------------------------------------------------------------------

```python
"""
Outline MCP Server

A simple MCP server that provides document outline capabilities.
"""
from mcp.server.fastmcp import FastMCP

from mcp_outline.features import register_all

# Create a FastMCP server instance with a name
mcp = FastMCP("Document Outline")

# Register all features
register_all(mcp)


def main():
    # Start the server
    mcp.run()


if __name__ == "__main__":
    main()

```

--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------

```json
{
  // See https://go.microsoft.com/fwlink/?LinkId=733558
  // for the documentation about the tasks.json format
  "version": "2.0.0",
  "tasks": [
    {
      "label": "docker build",
      "type": "shell",
      "command": "sudo docker buildx build -t mcp-outline .",
      "problemMatcher": []
    },
    {
      "label": "MCP Inspector",
      "type": "shell",
      "command": "npx @modelcontextprotocol/inspector docker run -i --rm --init -e DOCKER_CONTAINER=true --env-file .env mcp-outline",
      "problemMatcher": []
    }
  ]
}

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/__init__.py:
--------------------------------------------------------------------------------

```python
# Document management features for MCP Outline
from typing import Optional

from mcp_outline.features.documents import (
    ai_tools,
    collection_tools,
    document_collaboration,
    document_content,
    document_lifecycle,
    document_organization,
    document_reading,
    document_search,
)


def register(
    mcp, api_key: Optional[str] = None, api_url: Optional[str] = None
):
    """
    Register document management features with the MCP server.

    Args:
        mcp: The FastMCP server instance
        api_key: Optional API key for Outline
        api_url: Optional API URL for Outline
    """
    # Register all the tools from each module
    document_search.register_tools(mcp)
    document_reading.register_tools(mcp)
    document_content.register_tools(mcp)
    document_organization.register_tools(mcp)
    document_lifecycle.register_tools(mcp)
    document_collaboration.register_tools(mcp)
    collection_tools.register_tools(mcp)
    ai_tools.register_tools(mcp)

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/common.py:
--------------------------------------------------------------------------------

```python
"""
Common utilities for document outline features.

This module provides shared functionality used by both tools and resources.
"""
import os

from mcp_outline.utils.outline_client import OutlineClient, OutlineError


class OutlineClientError(Exception):
    """Exception raised for errors in document outline client operations."""
    pass

def get_outline_client() -> OutlineClient:
    """
    Get the document outline client.
    
    Returns:
        OutlineClient instance
        
    Raises:
        OutlineClientError: If client creation fails
    """
    try:
        # Get API credentials from environment variables
        api_key = os.getenv("OUTLINE_API_KEY")
        api_url = os.getenv("OUTLINE_API_URL")
        
        # Create an instance of the outline client
        client = OutlineClient(api_key=api_key, api_url=api_url)
        
        # Test the connection by attempting to get auth info
        _ = client.auth_info()
        
        return client
    except OutlineError as e:
        raise OutlineClientError(f"Outline client error: {str(e)}")
    except Exception as e:
        raise OutlineClientError(f"Unexpected error: {str(e)}")

```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "mcp-outline"
description = "A Model Context Protocol (MCP) server for Outline (https://www.getoutline.com)"
version = "0.2.2"
authors = [
    {name = "Atle H. Havsø", email = "[email protected]"},
]
requires-python = ">=3.10"
readme = "README.md"
license-files = ["LICENSE"]
dependencies = [
    "mcp[cli]>=0.1.0",
    "requests>=2.25.0",
]

[project.scripts]
mcp-outline = "mcp_outline.server:main"

[project.urls]
"Homepage" = "https://github.com/Vortiago/mcp-outline"
"Bug Tracker" = "https://github.com/Vortiago/mcp-outline/issues"

[project.optional-dependencies]
dev = [
    "mcp[cli]>=0.1.0",
    "pytest>=7.0.0",
    "pytest-asyncio>=0.21.0",
    "ruff>=0.0.267",
    "anyio>=3.6.2",
    "pyright>=1.1.398",
    "trio>=0.22.0",
]

[tool.setuptools]
package-dir = {"" = "src"}

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_functions = "test_*"

[tool.ruff]
line-length = 79
target-version = "py310"
extend-exclude = ["docs"]

[tool.ruff.lint]
select = ["E", "F", "I"]

[tool.pyright]
exclude = [
    "**/node_modules",
    "**/__pycache__",
    "**/.*",
    "docs/"
]

```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
# Stage 1: Dependency installation using uv
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv

# Create non-root user and group
ARG APP_UID=1000
ARG APP_GID=1000
RUN addgroup --gid $APP_GID appgroup && \
    adduser --disabled-password --gecos "" --uid $APP_UID --gid $APP_GID appuser
USER appuser:appgroup

WORKDIR /app

# Copy metadata and ensure src exists to satisfy setuptools
COPY --chown=appuser:appgroup pyproject.toml README.md LICENSE uv.lock /app/
RUN mkdir -p /app/src && chown appuser:appgroup /app/src

# Install dependencies, cache to temp directory
RUN mkdir -p /tmp/uv_cache && chown appuser:appgroup /tmp/uv_cache
RUN --mount=type=cache,target=/tmp/uv_cache \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    --mount=type=bind,source=README.md,target=README.md \
    --mount=type=bind,source=LICENSE,target=LICENSE \
    uv sync --frozen --no-dev --no-editable

COPY --chown=appuser:appgroup ./src/mcp_outline /app/mcp_outline

# Stage 2: Final runtime image
FROM python:3.12-slim-bookworm

# Create non-root user and group
ARG APP_UID=1000
ARG APP_GID=1000
RUN addgroup --gid $APP_GID appgroup && \
    adduser --disabled-password --gecos "" --uid $APP_UID --gid $APP_GID appuser

WORKDIR /app

# Copy the installed virtual environment and code from builder stage
COPY --chown=appuser:appgroup --from=uv /app/.venv /app/.venv
COPY --chown=appuser:appgroup --from=uv /app /app

# Set environment
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH=/app

# Switch to non-root user
USER appuser:appgroup

ENTRYPOINT ["mcp-outline"]

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/document_reading.py:
--------------------------------------------------------------------------------

```python
"""
Document reading tools for the MCP Outline server.

This module provides MCP tools for reading document content.
"""
from typing import Any, Dict

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def _format_document_content(document: Dict[str, Any]) -> str:
    """Format document content into readable text."""
    title = document.get("title", "Untitled Document")
    text = document.get("text", "")
    
    return f"""# {title}

{text}
"""

def register_tools(mcp) -> None:
    """
    Register document reading tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def read_document(document_id: str) -> str:
        """
        Retrieves and displays the full content of a document.
        
        Use this tool when you need to:
        - Access the complete content of a specific document
        - Review document information in detail
        - Quote or reference document content
        - Analyze document contents
        
        Args:
            document_id: The document ID to retrieve
            
        Returns:
            Formatted string containing the document title and content
        """
        try:
            client = get_outline_client()
            document = client.get_document(document_id)
            return _format_document_content(document)
        except OutlineClientError as e:
            return f"Error reading document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def export_document(document_id: str) -> str:
        """
        Exports a document as plain markdown text.
        
        Use this tool when you need to:
        - Get clean markdown content without formatting
        - Extract document content for external use
        - Process document content in another application
        - Share document content outside Outline
        
        Args:
            document_id: The document ID to export
            
        Returns:
            Document content in markdown format without additional formatting
        """
        try:
            client = get_outline_client()
            response = client.post("documents.export", {"id": document_id})
            return response.get("data", "No content available")
        except OutlineClientError as e:
            return f"Error exporting document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/document_organization.py:
--------------------------------------------------------------------------------

```python
"""
Document organization for the MCP Outline server.

This module provides MCP tools for organizing documents.
"""
from typing import Optional

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def register_tools(mcp) -> None:
    """
    Register document organization tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def move_document(
        document_id: str,
        collection_id: Optional[str] = None,
        parent_document_id: Optional[str] = None
    ) -> str:
        """
        Relocates a document to a different collection or parent document.
        
        IMPORTANT: When moving a document that has child documents (nested 
        documents), all child documents will move along with it, maintaining 
        their hierarchical structure. You must specify either collection_id or 
        parent_document_id (or both).
        
        Use this tool when you need to:
        - Reorganize your document hierarchy
        - Move a document to a more relevant collection
        - Change a document's parent document
        - Restructure content organization
        
        Args:
            document_id: The document ID to move
            collection_id: Target collection ID (if moving between collections)
            parent_document_id: Optional parent document ID (for nesting)
            
        Returns:
            Result message confirming the move operation
        """
        try:
            client = get_outline_client()
            
            # Require at least one destination parameter
            if collection_id is None and parent_document_id is None:
                return (
                    "Error: You must specify either a collection_id or "
                    "parent_document_id."
                )
            
            data = {"id": document_id}
            
            if collection_id:
                data["collectionId"] = collection_id
                
            if parent_document_id:
                data["parentDocumentId"] = parent_document_id
            
            response = client.post("documents.move", data)
            
            # Check for successful response
            if response.get("data"):
                return "Document moved successfully."
            else:
                return "Failed to move document."
        except OutlineClientError as e:
            return f"Error moving document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/ai_tools.py:
--------------------------------------------------------------------------------

```python
"""
AI-powered tools for interacting with documents.

This module provides MCP tools for AI-powered features in Outline.
"""
from typing import Any, Dict, Optional

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def _format_ai_answer(response: Dict[str, Any]) -> str:
    """Format AI answer into readable text."""
    # Check if the search field exists (indicates AI answer is available)
    if "search" not in response:
        return (
            "AI answering is not enabled for this workspace or "
            "no relevant information was found."
        )
    
    search = response.get("search", {})
    answer = search.get("answer", "")
    
    if not answer:
        return "No answer was found for your question."
    
    # Format the answer
    output = "# AI Answer\n\n"
    output += f"{answer}\n\n"
    
    # Add source documents
    documents = response.get("documents", [])
    if documents:
        output += "## Sources\n\n"
        for i, doc in enumerate(documents, 1):
            title = doc.get("title", "Untitled")
            doc_id = doc.get("id", "")
            output += f"{i}. {title} (ID: {doc_id})\n"
    
    return output

def register_tools(mcp) -> None:
    """
    Register AI tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def ask_ai_about_documents(
        question: str,
        collection_id: Optional[str] = None,
        document_id: Optional[str] = None
    ) -> str:
        """
        Queries document content using natural language questions.
        
        Use this tool when you need to:
        - Find specific information across multiple documents
        - Get direct answers to questions about document content
        - Extract insights from your knowledge base
        - Answer questions like "What is our vacation policy?" or "How do we 
  onboard new clients?"
        
        Args:
            question: The natural language question to ask
            collection_id: Optional collection to limit the search to
            document_id: Optional document to limit the search to
            
        Returns:
            AI-generated answer based on document content with sources
        """
        try:
            client = get_outline_client()
            response = client.answer_question(
                question, collection_id, document_id
            )
            return _format_ai_answer(response)
        except OutlineClientError as e:
            return f"Error getting answer: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/tests/utils/test_outline_client.py:
--------------------------------------------------------------------------------

```python
"""
Tests for the Outline API client.
"""
import os
from unittest.mock import MagicMock, patch

import pytest
import requests

from mcp_outline.utils.outline_client import OutlineClient, OutlineError

# Test data
MOCK_API_KEY = "test_api_key"
MOCK_API_URL = "https://test.outline.com/api"

class TestOutlineClient:
    """Test suite for OutlineClient."""
    
    def setup_method(self):
        """Set up test environment."""
        # Save original environment variables
        self.original_api_key = os.environ.get("OUTLINE_API_KEY")
        self.original_api_url = os.environ.get("OUTLINE_API_URL")
        
        # Set test environment variables
        os.environ["OUTLINE_API_KEY"] = MOCK_API_KEY
        os.environ["OUTLINE_API_URL"] = MOCK_API_URL
    
    def teardown_method(self):
        """Restore original environment."""
        # Restore original environment variables
        if self.original_api_key is not None:
            os.environ["OUTLINE_API_KEY"] = self.original_api_key
        else:
            os.environ.pop("OUTLINE_API_KEY", None)
            
        if self.original_api_url is not None:
            os.environ["OUTLINE_API_URL"] = self.original_api_url
        else:
            os.environ.pop("OUTLINE_API_URL", None)
    
    def test_init_from_env_variables(self):
        """Test initialization from environment variables."""
        client = OutlineClient()
        assert client.api_key == MOCK_API_KEY
        assert client.api_url == MOCK_API_URL
    
    def test_init_from_arguments(self):
        """Test initialization from constructor arguments."""
        custom_key = "custom_key"
        custom_url = "https://custom.outline.com/api"
        
        client = OutlineClient(api_key=custom_key, api_url=custom_url)
        assert client.api_key == custom_key
        assert client.api_url == custom_url
    
    def test_init_missing_api_key(self):
        """Test error when API key is missing."""
        os.environ.pop("OUTLINE_API_KEY", None)
        
        with pytest.raises(OutlineError):
            OutlineClient(api_key=None)
    
    @patch("requests.post")
    def test_post_request(self, mock_post):
        """Test POST request method."""
        # Setup mock response
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"data": {"test": "value"}}
        mock_post.return_value = mock_response
        
        # Create client and make request
        client = OutlineClient()
        data = {"param": "value"}
        result = client.post("test_endpoint", data)
        
        # Verify request was made correctly
        mock_post.assert_called_once_with(
            f"{MOCK_API_URL}/test_endpoint",
            headers={
                "Authorization": f"Bearer {MOCK_API_KEY}",
                "Content-Type": "application/json",
                "Accept": "application/json"
            },
            json=data
        )
        
        assert result == {"data": {"test": "value"}}
    
    @patch("requests.post")
    def test_error_handling(self, mock_post):
        """Test error handling for request exceptions."""
        # Setup mock to raise an exception
        error_msg = "Connection error"
        mock_post.side_effect = requests.exceptions.RequestException(error_msg)
        
        # Create client and test exception handling
        client = OutlineClient()
        
        with pytest.raises(OutlineError) as exc_info:
            client.post("test_endpoint")
        
        assert "API request failed" in str(exc_info.value)

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/document_content.py:
--------------------------------------------------------------------------------

```python
"""
Document content management for the MCP Outline server.

This module provides MCP tools for creating and updating document content.
"""
from typing import Any, Dict, Optional

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def register_tools(mcp) -> None:
    """
    Register document content tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def create_document(
        title: str,
        collection_id: str,
        text: str = "",
        parent_document_id: Optional[str] = None,
        publish: bool = True
    ) -> str:
        """
        Creates a new document in a specified collection.
        
        Use this tool when you need to:
        - Add new content to a knowledge base
        - Create documentation, guides, or notes
        - Add a child document to an existing parent
        - Start a new document thread or topic
        
        Args:
            title: The document title
            collection_id: The collection ID to create the document in
            text: Optional markdown content for the document
            parent_document_id: Optional parent document ID for nesting
            publish: Whether to publish the document immediately (True) or 
                save as draft (False)
            
        Returns:
            Result message with the new document ID
        """
        try:
            client = get_outline_client()
            
            data = {
                "title": title,
                "text": text,
                "collectionId": collection_id,
                "publish": publish
            }
            
            if parent_document_id:
                data["parentDocumentId"] = parent_document_id
                
            response = client.post("documents.create", data)
            document = response.get("data", {})
            
            if not document:
                return "Failed to create document."
                
            doc_id = document.get("id", "unknown")
            doc_title = document.get("title", "Untitled")
            
            return f"Document created successfully: {doc_title} (ID: {doc_id})"
        except OutlineClientError as e:
            return f"Error creating document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def update_document(
        document_id: str,
        title: Optional[str] = None,
        text: Optional[str] = None,
        append: bool = False
    ) -> str:
        """
        Modifies an existing document's title or content.
        
        IMPORTANT: This tool replaces the document content rather than just 
adding to it.
        To update a document with changed data, you need to first read the 
document,
        add your changes to the content, and then send the complete document 
with your changes.
        
        Use this tool when you need to:
        - Edit or update document content
        - Change a document's title
        - Append new content to an existing document
        - Fix errors or add information to documents
        
        Args:
            document_id: The document ID to update
            title: New title (if None, keeps existing title)
            text: New content (if None, keeps existing content)
            append: If True, adds text to the end of document instead of 
    replacing
            
        Returns:
            Result message confirming update
        """
        try:
            client = get_outline_client()
            
            # Only include fields that are being updated
            data: Dict[str, Any] = {"id": document_id}
            
            if title is not None:
                data["title"] = title
                
            if text is not None:
                data["text"] = text
                data["append"] = append
            
            response = client.post("documents.update", data)
            document = response.get("data", {})
            
            if not document:
                return "Failed to update document."
                
            doc_title = document.get("title", "Untitled")
            
            return f"Document updated successfully: {doc_title}"
        except OutlineClientError as e:
            return f"Error updating document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
            
    @mcp.tool()
    def add_comment(
        document_id: str,
        text: str,
        parent_comment_id: Optional[str] = None
    ) -> str:
        """
        Adds a comment to a document or replies to an existing comment.
        
        Use this tool when you need to:
        - Provide feedback on document content
        - Ask questions about specific information
        - Reply to another user's comment
        - Collaborate with others on document development
        
        Args:
            document_id: The document to comment on
            text: The comment text (supports markdown)
            parent_comment_id: Optional ID of a parent comment (for replies)
            
        Returns:
            Result message with the new comment ID
        """
        try:
            client = get_outline_client()
            
            data = {
                "documentId": document_id,
                "text": text
            }
            
            if parent_comment_id:
                data["parentCommentId"] = parent_comment_id
            
            response = client.post("comments.create", data)
            comment = response.get("data", {})
            
            if not comment:
                return "Failed to create comment."
                
            comment_id = comment.get("id", "unknown")
            
            if parent_comment_id:
                return f"Reply added successfully (ID: {comment_id})"
            else:
                return f"Comment added successfully (ID: {comment_id})"
        except OutlineClientError as e:
            return f"Error adding comment: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/tests/features/documents/test_document_reading.py:
--------------------------------------------------------------------------------

```python
"""
Tests for document reading tools.
"""
from unittest.mock import MagicMock, patch

import pytest

from mcp_outline.features.documents.common import OutlineClientError
from mcp_outline.features.documents.document_reading import (
    _format_document_content,
)


# Mock FastMCP for registering tools
class MockMCP:
    def __init__(self):
        self.tools = {}
    
    def tool(self):
        def decorator(func):
            self.tools[func.__name__] = func
            return func
        return decorator

# Sample document data
SAMPLE_DOCUMENT = {
    "id": "doc123",
    "title": "Test Document",
    "text": "This is a test document with some content.",
    "updatedAt": "2023-01-01T12:00:00Z"
}

# Sample export response
SAMPLE_EXPORT_RESPONSE = {
    "data": "# Test Document\n\nThis is a test document with some content."
}

@pytest.fixture
def mcp():
    """Fixture to provide mock MCP instance."""
    return MockMCP()

@pytest.fixture
def register_reading_tools(mcp):
    """Fixture to register document reading tools."""
    from mcp_outline.features.documents.document_reading import register_tools
    register_tools(mcp)
    return mcp

class TestDocumentReadingFormatters:
    """Tests for document reading formatting functions."""
    
    def test_format_document_content(self):
        """Test formatting document content."""
        result = _format_document_content(SAMPLE_DOCUMENT)
        
        # Verify the result contains the expected information
        assert "# Test Document" in result
        assert "This is a test document with some content." in result
    
    def test_format_document_content_missing_fields(self):
        """Test formatting document content with missing fields."""
        # Test with missing title
        doc_no_title = {"text": "Content only"}
        result_no_title = _format_document_content(doc_no_title)
        assert "# Untitled Document" in result_no_title
        assert "Content only" in result_no_title
        
        # Test with missing text
        doc_no_text = {"title": "Title only"}
        result_no_text = _format_document_content(doc_no_text)
        assert "# Title only" in result_no_text
        assert result_no_text.strip().endswith("# Title only")
        
        # Test with empty document
        empty_doc = {}
        result_empty = _format_document_content(empty_doc)
        assert "# Untitled Document" in result_empty

class TestDocumentReadingTools:
    """Tests for document reading tools."""
    
    @patch("mcp_outline.features.documents.document_reading.get_outline_client")
    def test_read_document_success(
        self, mock_get_client, register_reading_tools
    ):
        """Test read_document tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.get_document.return_value = SAMPLE_DOCUMENT
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_reading_tools.tools["read_document"]("doc123")
        
        # Verify client was called correctly
        mock_client.get_document.assert_called_once_with("doc123")
        
        # Verify result contains expected information
        assert "# Test Document" in result
        assert "This is a test document with some content." in result
    
    @patch("mcp_outline.features.documents.document_reading.get_outline_client")
    def test_read_document_client_error(
        self, mock_get_client, register_reading_tools
    ):
        """Test read_document tool with client error."""
        # Set up mock client to raise an error
        mock_client = MagicMock()
        mock_client.get_document.side_effect = OutlineClientError("API error")
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_reading_tools.tools["read_document"]("doc123")
        
        # Verify error is handled and returned
        assert "Error reading document" in result
        assert "API error" in result
    
    @patch("mcp_outline.features.documents.document_reading.get_outline_client")
    def test_export_document_success(
        self, mock_get_client, register_reading_tools
    ):
        """Test export_document tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = SAMPLE_EXPORT_RESPONSE
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_reading_tools.tools["export_document"]("doc123")
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "documents.export", 
            {"id": "doc123"}
        )
        
        # Verify result contains expected information
        assert "# Test Document" in result
        assert "This is a test document with some content." in result
    
    @patch("mcp_outline.features.documents.document_reading.get_outline_client")
    def test_export_document_empty_response(
        self, mock_get_client, register_reading_tools
    ):
        """Test export_document tool with empty response."""
        # Set up mock client with empty response
        mock_client = MagicMock()
        mock_client.post.return_value = {}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_reading_tools.tools["export_document"]("doc123")
        
        # Verify result contains default message
        assert "No content available" in result
    
    @patch("mcp_outline.features.documents.document_reading.get_outline_client")
    def test_export_document_client_error(
        self, mock_get_client, register_reading_tools
    ):
        """Test export_document tool with client error."""
        # Set up mock client to raise an error
        mock_client = MagicMock()
        mock_client.post.side_effect = OutlineClientError("API error")
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_reading_tools.tools["export_document"]("doc123")
        
        # Verify error is handled and returned
        assert "Error exporting document" in result
        assert "API error" in result

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/document_collaboration.py:
--------------------------------------------------------------------------------

```python
"""
Document collaboration tools for the MCP Outline server.

This module provides MCP tools for document comments, sharing, and 
collaboration.
"""
from typing import Any, Dict, List

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def _format_comments(
    comments: List[Dict[str, Any]],
    total_count: int = 0,
    limit: int = 25,
    offset: int = 0
) -> str:
    """Format document comments into readable text."""
    if not comments:
        return "No comments found for this document."
    
    output = "# Document Comments\n\n"
    
    # Add pagination info if provided
    if total_count:
        shown_range = f"{offset+1}-{min(offset+len(comments), total_count)}"
        output += f"Showing comments {shown_range} of {total_count} total\n\n"
        
        # Add warning if there might be more comments than shown
        if len(comments) == limit:
            output += "Note: Only showing the first batch of comments. "
            output += f"Use offset={offset+limit} to see more comments.\n\n"
    
    for i, comment in enumerate(comments, offset+1):
        user = comment.get("createdBy", {}).get("name", "Unknown User")
        created_at = comment.get("createdAt", "")
        comment_id = comment.get("id", "")
        anchor_text = comment.get("anchorText", "")
        
        # Extract data object containing the comment content
        data = comment.get("data", {})
        
        # Convert data to JSON string for display
        try:
            import json
            text = json.dumps(data, indent=2)
        except Exception:
            text = str(data)
        
        output += f"## {i}. Comment by {user}\n"
        output += f"ID: {comment_id}\n"
        if created_at:
            output += f"Date: {created_at}\n"
        if anchor_text:
            output += f"\nReferencing text: \"{anchor_text}\"\n"
        if data:
            output += f"\nComment content:\n```json\n{text}\n```\n\n"
        else:
            output += "\n(No comment content found)\n\n"
    
    return output

def register_tools(mcp) -> None:
    """
    Register document collaboration tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def list_document_comments(
        document_id: str,
        include_anchor_text: bool = False,
        limit: int = 25,
        offset: int = 0
    ) -> str:
        """
        Retrieves comments on a specific document with pagination support.
        
        IMPORTANT: By default, this returns up to 25 comments at a time. If 
        there are more than 25 comments on the document, you'll need to make 
        multiple calls with different offset values to get all comments. The 
        response will indicate if there 
        are more comments available.
        
        Use this tool when you need to:
        - Review feedback and discussions on a document
        - See all comments from different users
        - Find specific comments or questions
        - Track collaboration and input on documents
        
        Args:
            document_id: The document ID to get comments from
            include_anchor_text: Whether to include the document text that 
                comments refer to
            limit: Maximum number of comments to return (default: 25)
            offset: Number of comments to skip for pagination (default: 0)
            
        Returns:
            Formatted string containing comments with author, date, and 
            optional anchor text
        """
        try:
            client = get_outline_client()
            data = {
                "documentId": document_id,
                "includeAnchorText": include_anchor_text,
                "limit": limit,
                "offset": offset
            }
            
            response = client.post("comments.list", data)
            comments = response.get("data", [])
            pagination = response.get("pagination", {})
            
            total_count = pagination.get("total", len(comments))
            return _format_comments(comments, total_count, limit, offset)
        except OutlineClientError as e:
            return f"Error listing comments: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def get_comment(comment_id: str, include_anchor_text: bool = False) -> str:
        """
        Retrieves a specific comment by its ID.
        
        Use this tool when you need to:
        - View details of a specific comment
        - Reference or quote a particular comment
        - Check comment content and metadata
        - Find a comment mentioned elsewhere
        
        Args:
            comment_id: The comment ID to retrieve
            include_anchor_text: Whether to include the document text that 
                the comment refers to
            
        Returns:
            Formatted string with the comment content and metadata
        """
        try:
            client = get_outline_client()
            response = client.post("comments.info", {
                "id": comment_id,
                "includeAnchorText": include_anchor_text
            })
            comment = response.get("data", {})
            
            if not comment:
                return "Comment not found."
            
            user = comment.get("createdBy", {}).get("name", "Unknown User")
            created_at = comment.get("createdAt", "")
            anchor_text = comment.get("anchorText", "")
            
            # Extract data object containing the comment content
            data = comment.get("data", {})
            
            # Convert data to JSON string for display
            try:
                import json
                text = json.dumps(data, indent=2)
            except Exception:
                text = str(data)
            
            output = f"# Comment by {user}\n"
            if created_at:
                output += f"Date: {created_at}\n"
            if anchor_text:
                output += f"\nReferencing text: \"{anchor_text}\"\n"
            if data:
                output += f"\nComment content:\n```json\n{text}\n```\n"
            else:
                output += "\n(No comment content found)\n"
            
            return output
        except OutlineClientError as e:
            return f"Error getting comment: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
            
    @mcp.tool()
    def get_document_backlinks(document_id: str) -> str:
        """
        Finds all documents that link to a specific document.
        
        Use this tool when you need to:
        - Discover references to a document across the workspace
        - Identify dependencies between documents
        - Find documents related to a specific document
        - Understand document relationships and connections
        
        Args:
            document_id: The document ID to find backlinks for
            
        Returns:
            Formatted string listing all documents that link to the specified 
document
        """
        try:
            client = get_outline_client()
            response = client.post("documents.list", {
                "backlinkDocumentId": document_id
            })
            documents = response.get("data", [])
            
            if not documents:
                return "No documents link to this document."
            
            output = "# Documents Linking to This Document\n\n"
            
            for i, document in enumerate(documents, 1):
                title = document.get("title", "Untitled Document")
                doc_id = document.get("id", "")
                updated_at = document.get("updatedAt", "")
                
                output += f"## {i}. {title}\n"
                output += f"ID: {doc_id}\n"
                if updated_at:
                    output += f"Last Updated: {updated_at}\n"
                output += "\n"
            
            return output
        except OutlineClientError as e:
            return f"Error retrieving backlinks: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/document_lifecycle.py:
--------------------------------------------------------------------------------

```python
"""
Document lifecycle management for the MCP Outline server.

This module provides MCP tools for archiving, trashing, and restoring 
documents.
"""

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def register_tools(mcp) -> None:
    """
    Register document lifecycle tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def archive_document(document_id: str) -> str:
        """
        Archives a document to remove it from active use while preserving it.
        
        IMPORTANT: Archived documents are removed from collections but remain
        searchable in the system. They won't appear in normal collection views
        but can still be found through search or the archive list.
        
        Use this tool when you need to:
        - Remove outdated or inactive documents from view
        - Clean up collections while preserving document history
        - Preserve documents that are no longer relevant
        - Temporarily hide documents without deleting them
        
        Args:
            document_id: The document ID to archive
            
        Returns:
            Result message confirming archival
        """
        try:
            client = get_outline_client()
            document = client.archive_document(document_id)
            
            if not document:
                return "Failed to archive document."
                
            doc_title = document.get("title", "Untitled")
            
            return f"Document archived successfully: {doc_title}"
        except OutlineClientError as e:
            return f"Error archiving document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def unarchive_document(document_id: str) -> str:
        """
        Restores a previously archived document to active status.
        
        Use this tool when you need to:
        - Restore archived documents to active use
        - Access or reference previously archived content
        - Make archived content visible in collections again
        - Update and reuse archived documents
        
        Args:
            document_id: The document ID to unarchive
            
        Returns:
            Result message confirming restoration
        """
        try:
            client = get_outline_client()
            document = client.unarchive_document(document_id)
            
            if not document:
                return "Failed to unarchive document."
                
            doc_title = document.get("title", "Untitled")
            
            return f"Document unarchived successfully: {doc_title}"
        except OutlineClientError as e:
            return f"Error unarchiving document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
            
    @mcp.tool()
    def delete_document(document_id: str, permanent: bool = False) -> str:
        """
        Moves a document to trash or permanently deletes it.
        
        IMPORTANT: When permanent=False (the default), documents are moved to 
        trash and retained for 30 days before being permanently deleted. 
        During 
        this period, they can be restored using the restore_document tool. 
        Setting permanent=True bypasses the trash and immediately deletes the 
        document without any recovery option.
        
        Use this tool when you need to:
        - Remove unwanted or unnecessary documents
        - Delete obsolete content
        - Clean up workspace by removing documents
        - Permanently remove sensitive information (with permanent=True)
        
        Args:
            document_id: The document ID to delete
            permanent: If True, permanently deletes the document without 
                recovery option
            
        Returns:
            Result message confirming deletion
        """
        try:
            client = get_outline_client()
            
            if permanent:
                success = client.permanently_delete_document(document_id)
                if success:
                    return "Document permanently deleted."
                else:
                    return "Failed to permanently delete document."
            else:
                # First get the document details for the success message
                document = client.get_document(document_id)
                doc_title = document.get("title", "Untitled")
                
                # Move to trash (using the regular delete endpoint)
                response = client.post("documents.delete", {"id": document_id})
                
                # Check for successful response
                if response.get("success", False):
                    return f"Document moved to trash: {doc_title}"
                else:
                    return "Failed to move document to trash."
                    
        except OutlineClientError as e:
            return f"Error deleting document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def restore_document(document_id: str) -> str:
        """
        Recovers a document from the trash back to active status.
        
        Use this tool when you need to:
        - Retrieve accidentally deleted documents
        - Restore documents from trash to active use
        - Recover documents deleted within the last 30 days
        - Access content that was previously trashed
        
        Args:
            document_id: The document ID to restore
            
        Returns:
            Result message confirming restoration
        """
        try:
            client = get_outline_client()
            document = client.restore_document(document_id)
            
            if not document:
                return "Failed to restore document from trash."
                
            doc_title = document.get("title", "Untitled")
            
            return f"Document restored successfully: {doc_title}"
        except OutlineClientError as e:
            return f"Error restoring document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
            
    @mcp.tool()
    def list_archived_documents() -> str:
        """
        Displays all documents that have been archived.
        
        Use this tool when you need to:
        - Find specific archived documents
        - Review what documents have been archived
        - Identify documents for possible unarchiving
        - Check archive status of workspace content
        
        Returns:
            Formatted string containing list of archived documents
        """
        try:
            client = get_outline_client()
            response = client.post("documents.archived")
            from mcp_outline.features.documents.document_search import (
                _format_documents_list,
            )
            documents = response.get("data", [])
            return _format_documents_list(documents, "Archived Documents")
        except OutlineClientError as e:
            return f"Error listing archived documents: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
            
    @mcp.tool()
    def list_trash() -> str:
        """
        Displays all documents currently in the trash.
        
        Use this tool when you need to:
        - Find deleted documents that can be restored
        - Review what documents are pending permanent deletion
        - Identify documents to restore from trash
        - Verify if specific documents were deleted
        
        Returns:
            Formatted string containing list of documents in trash
        """
        try:
            client = get_outline_client()
            documents = client.list_trash()
            from mcp_outline.features.documents.document_search import (
                _format_documents_list,
            )
            return _format_documents_list(documents, "Documents in Trash")
        except OutlineClientError as e:
            return f"Error listing trash: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/document_search.py:
--------------------------------------------------------------------------------

```python
"""
Document search tools for the MCP Outline server.

This module provides MCP tools for searching and listing documents.
"""
from typing import Any, Dict, List, Optional

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def _format_search_results(results: List[Dict[str, Any]]) -> str:
    """Format search results into readable text."""
    if not results:
        return "No documents found matching your search."
    
    output = "# Search Results\n\n"
    
    for i, result in enumerate(results, 1):
        document = result.get("document", {})
        title = document.get("title", "Untitled")
        doc_id = document.get("id", "")
        context = result.get("context", "")
        
        output += f"## {i}. {title}\n"
        output += f"ID: {doc_id}\n"
        if context:
            output += f"Context: {context}\n"
        output += "\n"
    
    return output

def _format_documents_list(documents: List[Dict[str, Any]], title: str) -> str:
    """Format a list of documents into readable text."""
    if not documents:
        return f"No {title.lower()} found."
    
    output = f"# {title}\n\n"
    
    for i, document in enumerate(documents, 1):
        doc_title = document.get("title", "Untitled")
        doc_id = document.get("id", "")
        updated_at = document.get("updatedAt", "")
        
        output += f"## {i}. {doc_title}\n"
        output += f"ID: {doc_id}\n"
        if updated_at:
            output += f"Last Updated: {updated_at}\n"
        output += "\n"
    
    return output

def _format_collections(collections: List[Dict[str, Any]]) -> str:
    """Format collections into readable text."""
    if not collections:
        return "No collections found."
    
    output = "# Collections\n\n"
    
    for i, collection in enumerate(collections, 1):
        name = collection.get("name", "Untitled Collection")
        coll_id = collection.get("id", "")
        description = collection.get("description", "")
        
        output += f"## {i}. {name}\n"
        output += f"ID: {coll_id}\n"
        if description:
            output += f"Description: {description}\n"
        output += "\n"
    
    return output

def _format_collection_documents(doc_nodes: List[Dict[str, Any]]) -> str:
    """Format collection document structure into readable text."""
    if not doc_nodes:
        return "No documents found in this collection."
    
    def format_node(node, depth=0):
        # Extract node details
        title = node.get("title", "Untitled")
        node_id = node.get("id", "")
        children = node.get("children", [])
        
        # Format this node
        indent = "  " * depth
        text = f"{indent}- {title} (ID: {node_id})\n"
        
        # Recursively format children
        for child in children:
            text += format_node(child, depth + 1)
        
        return text
    
    output = "# Collection Structure\n\n"
    for node in doc_nodes:
        output += format_node(node)
    
    return output

def register_tools(mcp) -> None:
    """
    Register document search tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def search_documents(
        query: str, 
        collection_id: Optional[str] = None
    ) -> str:
        """
        Searches for documents using keywords or phrases across your knowledge 
        base.
        
        IMPORTANT: The search performs full-text search across all document 
        content and titles. Results are ranked by relevance, with exact 
        matches 
        and title matches typically ranked higher. The search will return 
        snippets of content (context) where the search terms appear in the 
        document. You can limit the search to a specific collection by 
        providing 
        the collection_id.
        
        Use this tool when you need to:
        - Find documents containing specific terms or topics
        - Locate information across multiple documents
        - Search within a specific collection using collection_id
        - Discover content based on keywords
        
        Args:
            query: Search terms (e.g., "vacation policy" or "project plan")
            collection_id: Optional collection to limit the search to
            
        Returns:
            Formatted string containing search results with document titles 
            and 
            contexts
        """
        try:
            client = get_outline_client()
            results = client.search_documents(query, collection_id)
            return _format_search_results(results)
        except OutlineClientError as e:
            return f"Error searching documents: {str(e)}"
        except Exception as e:
            return f"Unexpected error during search: {str(e)}"
    
    @mcp.tool()
    def list_collections() -> str:
        """
        Retrieves and displays all available collections in the workspace.
        
        Use this tool when you need to:
        - See what collections exist in the workspace
        - Get collection IDs for other operations
        - Explore the organization of the knowledge base
        - Find a specific collection by name
        
        Returns:
            Formatted string containing collection names, IDs, and descriptions
        """
        try:
            client = get_outline_client()
            collections = client.list_collections()
            return _format_collections(collections)
        except OutlineClientError as e:
            return f"Error listing collections: {str(e)}"
        except Exception as e:
            return f"Unexpected error listing collections: {str(e)}"
    
    @mcp.tool()
    def get_collection_structure(collection_id: str) -> str:
        """
        Retrieves the hierarchical document structure of a collection.
        
        Use this tool when you need to:
        - Understand how documents are organized in a collection
        - Find document IDs within a specific collection
        - See the parent-child relationships between documents
        - Get an overview of a collection's content structure
        
        Args:
            collection_id: The collection ID to examine
            
        Returns:
            Formatted string showing the hierarchical structure of documents
        """
        try:
            client = get_outline_client()
            docs = client.get_collection_documents(collection_id)
            return _format_collection_documents(docs)
        except OutlineClientError as e:
            return f"Error getting collection structure: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
            
    @mcp.tool()
    def get_document_id_from_title(
        query: str, collection_id: Optional[str] = None
    ) -> str:
        """
        Locates a document ID by searching for its title.
        
        IMPORTANT: This tool first checks for exact title matches 
        (case-insensitive). If none are found, it returns the best partial 
        match instead. This is useful when you're not sure of the exact title 
        but need 
        to reference a document in other operations. Results are more accurate 
        when you provide more of the actual title in your query.
        
        Use this tool when you need to:
        - Find a document's ID when you only know its title
        - Get the document ID for use in other operations
        - Verify if a document with a specific title exists
        - Find the best matching document if exact title is unknown
        
        Args:
            query: Title to search for (can be exact or partial)
            collection_id: Optional collection to limit the search to
            
        Returns:
            Document ID if found, or best match information
        """
        try:
            client = get_outline_client()
            results = client.search_documents(query, collection_id)
            
            if not results:
                return f"No documents found matching '{query}'"
            
            # Check if we have an exact title match
            exact_matches = [
                r for r in results 
                if (r.get("document", {}).get("title", "").lower() 
                    == query.lower())
            ]
            
            if exact_matches:
                doc = exact_matches[0].get("document", {})
                doc_id = doc.get("id", "unknown")
                title = doc.get("title", "Untitled")
                return f"Document ID: {doc_id} (Title: {title})"
            
            # Otherwise return the top match
            doc = results[0].get("document", {})
            doc_id = doc.get("id", "unknown")
            title = doc.get("title", "Untitled")
            return f"Best match - Document ID: {doc_id} (Title: {title})"
        except OutlineClientError as e:
            return f"Error searching for document: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/src/mcp_outline/features/documents/collection_tools.py:
--------------------------------------------------------------------------------

```python
"""
Collection management tools for the MCP Outline server.

This module provides MCP tools for managing collections.
"""
from typing import Any, Dict, Optional

from mcp_outline.features.documents.common import (
    OutlineClientError,
    get_outline_client,
)


def _format_file_operation(file_operation: Dict[str, Any]) -> str:
    """Format file operation data into readable text."""
    if not file_operation:
        return "No file operation data available."
    
    # Get the file operation details
    state = file_operation.get("state", "unknown")
    type_info = file_operation.get("type", "unknown")
    name = file_operation.get("name", "unknown")
    file_operation_id = file_operation.get("id", "")
    
    # Format output
    output = f"# Export Operation: {name}\n\n"
    output += f"State: {state}\n"
    output += f"Type: {type_info}\n"
    output += f"ID: {file_operation_id}\n\n"
    
    # Provide instructions based on the state
    if state == "complete":
        output += "The export is complete and ready to download. "
        output += (
            "Use the ID with the appropriate download tool to retrieve "
            "the file.\n"
        )
    else:
        output += "The export is still in progress. "
        output += (
            f"Check the operation state again later using the ID: "
            f"{file_operation_id}\n"
        )
    
    return output

def register_tools(mcp) -> None:
    """
    Register collection management tools with the MCP server.
    
    Args:
        mcp: The FastMCP server instance
    """
    @mcp.tool()
    def create_collection(
        name: str,
        description: str = "",
        color: Optional[str] = None
    ) -> str:
        """
        Creates a new collection for organizing documents.
        
        Use this tool when you need to:
        - Create a new section or category for documents
        - Set up a workspace for a new project or team
        - Organize content by department or topic
        - Establish a separate space for related documents
        
        Args:
            name: Name for the collection
            description: Optional description of the collection's purpose
            color: Optional hex color code for visual identification 
    (e.g. #FF0000)
            
        Returns:
            Result message with the new collection ID
        """
        try:
            client = get_outline_client()
            collection = client.create_collection(name, description, color)
            
            if not collection:
                return "Failed to create collection."
                
            collection_id = collection.get("id", "unknown")
            collection_name = collection.get("name", "Untitled")
            
            return (
                f"Collection created successfully: {collection_name} "
                f"(ID: {collection_id})"
            )
        except OutlineClientError as e:
            return f"Error creating collection: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def update_collection(
        collection_id: str,
        name: Optional[str] = None,
        description: Optional[str] = None,
        color: Optional[str] = None
    ) -> str:
        """
        Modifies an existing collection's properties.
        
        Use this tool when you need to:
        - Rename a collection
        - Update a collection's description
        - Change a collection's color coding
        - Refresh collection metadata
        
        Args:
            collection_id: The collection ID to update
            name: Optional new name for the collection
            description: Optional new description
            color: Optional new hex color code (e.g. #FF0000)
            
        Returns:
            Result message confirming update
        """
        try:
            client = get_outline_client()
            
            # Make sure at least one field is being updated
            if name is None and description is None and color is None:
                return "Error: You must specify at least one field to update."
            
            collection = client.update_collection(
                collection_id, name, description, color
            )
            
            if not collection:
                return "Failed to update collection."
                
            collection_name = collection.get("name", "Untitled")
            
            return f"Collection updated successfully: {collection_name}"
        except OutlineClientError as e:
            return f"Error updating collection: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def delete_collection(collection_id: str) -> str:
        """
        Permanently removes a collection and all its documents.
        
        Use this tool when you need to:
        - Remove an entire section of content
        - Delete obsolete project collections
        - Remove collections that are no longer needed
        - Clean up workspace organization
        
        WARNING: This action cannot be undone and will delete all documents 
within the collection.
        
        Args:
            collection_id: The collection ID to delete
            
        Returns:
            Result message confirming deletion
        """
        try:
            client = get_outline_client()
            success = client.delete_collection(collection_id)
            
            if success:
                return "Collection and all its documents deleted successfully."
            else:
                return "Failed to delete collection."
        except OutlineClientError as e:
            return f"Error deleting collection: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def export_collection(
        collection_id: str,
        format: str = "outline-markdown"
    ) -> str:
        """
        Exports all documents in a collection to a downloadable file.
        
        IMPORTANT: This tool starts an asynchronous export operation which may 
        take time to complete. The function returns information about the 
        operation, including its status. When the operation is complete, the 
        file can be downloaded or accessed via Outline's UI. The export 
        preserves the document hierarchy and includes all document content and 
        structure in the 
        specified format.
        
        Use this tool when you need to:
        - Create a backup of collection content
        - Share collection content outside of Outline
        - Convert collection content to other formats
        - Archive collection content for offline use
        
        Args:
            collection_id: The collection ID to export
            format: Export format ("outline-markdown", "json", or "html")
            
        Returns:
            Information about the export operation and how to access the file
        """
        try:
            client = get_outline_client()
            file_operation = client.export_collection(collection_id, format)
            
            if not file_operation:
                return "Failed to start export operation."
                
            return _format_file_operation(file_operation)
        except OutlineClientError as e:
            return f"Error exporting collection: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"
    
    @mcp.tool()
    def export_all_collections(format: str = "outline-markdown") -> str:
        """
        Exports the entire workspace content to a downloadable file.
        
        IMPORTANT: This tool starts an asynchronous export operation which may 
        take time to complete, especially for large workspaces. The function 
        returns information about the operation, including its status. When 
        the operation is complete, the file can be downloaded or accessed via 
        Outline's UI. The export includes all collections, documents, and 
        their 
        hierarchies in the specified format.
        
        Use this tool when you need to:
        - Create a complete backup of all workspace content
        - Migrate content to another system
        - Archive all workspace documents
        - Get a comprehensive export of knowledge base
        
        Args:
            format: Export format ("outline-markdown", "json", or "html")
            
        Returns:
            Information about the export operation and how to access the file
        """
        try:
            client = get_outline_client()
            file_operation = client.export_all_collections(format)
            
            if not file_operation:
                return "Failed to start export operation."
                
            return _format_file_operation(file_operation)
        except OutlineClientError as e:
            return f"Error exporting collections: {str(e)}"
        except Exception as e:
            return f"Unexpected error: {str(e)}"

```

--------------------------------------------------------------------------------
/tests/features/documents/test_document_collaboration.py:
--------------------------------------------------------------------------------

```python
"""
Tests for document collaboration tools.
"""
from unittest.mock import MagicMock, patch

import pytest

from mcp_outline.features.documents.common import OutlineClientError
from mcp_outline.features.documents.document_collaboration import (
    _format_comments,
)


# Mock FastMCP for registering tools
class MockMCP:
    def __init__(self):
        self.tools = {}
    
    def tool(self):
        def decorator(func):
            self.tools[func.__name__] = func
            return func
        return decorator

# Sample comment data
SAMPLE_COMMENTS = [
    {
        "id": "comment1",
        "data": {"content": "This is a test comment"},
        "createdAt": "2023-01-01T12:00:00Z",
        "createdBy": {
            "id": "user1",
            "name": "Test User"
        }
    },
    {
        "id": "comment2",
        "data": {"content": "Another comment"},
        "createdAt": "2023-01-02T12:00:00Z",
        "createdBy": {
            "id": "user2",
            "name": "Another User"
        }
    }
]

# Sample documents for backlinks
SAMPLE_BACKLINK_DOCUMENTS = [
    {
        "id": "doc1",
        "title": "Referencing Document 1",
        "updatedAt": "2023-01-01T12:00:00Z"
    },
    {
        "id": "doc2",
        "title": "Referencing Document 2",
        "updatedAt": "2023-01-02T12:00:00Z"
    }
]

@pytest.fixture
def mcp():
    """Fixture to provide mock MCP instance."""
    return MockMCP()

@pytest.fixture
def register_collaboration_tools(mcp):
    """Fixture to register document collaboration tools."""
    from mcp_outline.features.documents.document_collaboration import (
        register_tools,
    )
    register_tools(mcp)
    return mcp

class TestDocumentCollaborationFormatters:
    """Tests for document collaboration formatting functions."""
    
    def test_format_comments_with_data(self):
        """Test formatting comments with valid data."""
        result = _format_comments(SAMPLE_COMMENTS)
        
        # Verify the result contains the expected information
        assert "# Document Comments" in result
        assert "Comment by Test User" in result
        assert "This is a test comment" in result
        assert "2023-01-01" in result
        assert "Comment by Another User" in result
        assert "Another comment" in result
    
    def test_format_comments_empty(self):
        """Test formatting empty comments list."""
        result = _format_comments([])
        
        assert "No comments found for this document" in result
    
    def test_format_comments_missing_fields(self):
        """Test formatting comments with missing fields."""
        # Comments with missing fields
        incomplete_comments = [
            # Missing user name
            {
                "id": "comment1",
                "data": {"content": "Comment with missing user"},
                "createdAt": "2023-01-01T12:00:00Z",
                "createdBy": {}
            },
            # Missing created date
            {
                "id": "comment2",
                "data": {"content": "Comment with missing date"},
                "createdBy": {
                    "name": "Test User"
                }
            },
            # Missing text
            {
                "id": "comment3",
                "createdAt": "2023-01-03T12:00:00Z",
                "createdBy": {
                    "name": "Another User"
                }
            }
        ]
        
        result = _format_comments(incomplete_comments)
        
        # Verify the result handles missing fields gracefully
        assert "Unknown User" in result
        assert "Comment with missing user" in result
        assert "Test User" in result
        assert "Comment with missing date" in result
        assert "Another User" in result

class TestDocumentCollaborationTools:
    """Tests for document collaboration tools."""
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_list_document_comments_success(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test list_document_comments tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": SAMPLE_COMMENTS}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "list_document_comments"]("doc123")
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "comments.list", {
                "documentId": "doc123",
                "includeAnchorText": False,
                "limit": 25,
                "offset": 0
            }
        )
        
        # Verify result contains expected information
        assert "# Document Comments" in result
        assert "Comment by Test User" in result
        assert "This is a test comment" in result
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_list_document_comments_empty(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test list_document_comments tool with no comments."""
        # Set up mock client with empty response
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": []}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "list_document_comments"]("doc123")
        
        # Verify result contains expected message
        assert "No comments found" in result
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_get_comment_success(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test get_comment tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": SAMPLE_COMMENTS[0]}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "get_comment"]("comment1")
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "comments.info", {
                "id": "comment1",
                "includeAnchorText": False
            }
        )
        
        # Verify result contains expected information
        assert "# Comment by Test User" in result
        assert "This is a test comment" in result
        assert "2023-01-01" in result
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_get_comment_not_found(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test get_comment tool with comment not found."""
        # Set up mock client with empty response
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": {}}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "get_comment"]("comment999")
        
        # Verify result contains expected message
        assert "Comment not found" in result
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_get_document_backlinks_success(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test get_document_backlinks tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": SAMPLE_BACKLINK_DOCUMENTS}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "get_document_backlinks"]("doc123")
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "documents.list", {"backlinkDocumentId": "doc123"}
        )
        
        # Verify result contains expected information
        assert "# Documents Linking to This Document" in result
        assert "Referencing Document 1" in result
        assert "doc1" in result
        assert "Referencing Document 2" in result
        assert "doc2" in result
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_get_document_backlinks_none(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test get_document_backlinks tool with no backlinks."""
        # Set up mock client with empty response
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": []}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "get_document_backlinks"]("doc123")
        
        # Verify result contains expected message
        assert "No documents link to this document" in result
    
    @patch("mcp_outline.features.documents.document_collaboration.get_outline_client")
    def test_get_document_backlinks_client_error(
        self, mock_get_client, register_collaboration_tools
    ):
        """Test get_document_backlinks tool with client error."""
        # Set up mock client to raise an error
        mock_client = MagicMock()
        mock_client.post.side_effect = OutlineClientError("API error")
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_collaboration_tools.tools[
            "get_document_backlinks"]("doc123")
        
        # Verify error is handled and returned
        assert "Error retrieving backlinks" in result
        assert "API error" in result

```

--------------------------------------------------------------------------------
/tests/features/documents/test_document_content.py:
--------------------------------------------------------------------------------

```python
"""
Tests for document content tools.
"""
from unittest.mock import MagicMock, patch

import pytest

from mcp_outline.features.documents.common import OutlineClientError


# Mock FastMCP for registering tools
class MockMCP:
    def __init__(self):
        self.tools = {}
    
    def tool(self):
        def decorator(func):
            self.tools[func.__name__] = func
            return func
        return decorator

# Sample response data
SAMPLE_CREATE_DOCUMENT_RESPONSE = {
    "data": {
        "id": "doc123",
        "title": "Test Document",
        "text": "This is a test document.",
        "updatedAt": "2023-01-01T12:00:00Z",
        "createdAt": "2023-01-01T12:00:00Z"
    }
}

SAMPLE_UPDATE_DOCUMENT_RESPONSE = {
    "data": {
        "id": "doc123",
        "title": "Updated Document",
        "text": "This document has been updated.",
        "updatedAt": "2023-01-02T12:00:00Z"
    }
}

SAMPLE_COMMENT_RESPONSE = {
    "data": {
        "id": "comment123",
        "documentId": "doc123",
        "createdById": "user123",
        "createdAt": "2023-01-01T12:00:00Z",
        "body": "This is a comment"
    }
}

@pytest.fixture
def mcp():
    """Fixture to provide mock MCP instance."""
    return MockMCP()

@pytest.fixture
def register_content_tools(mcp):
    """Fixture to register document content tools."""
    from mcp_outline.features.documents.document_content import register_tools
    register_tools(mcp)
    return mcp

class TestDocumentContentTools:
    """Tests for document content tools."""
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_create_document_success(
        self, mock_get_client, register_content_tools
    ):
        """Test create_document tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = SAMPLE_CREATE_DOCUMENT_RESPONSE
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["create_document"](
            title="Test Document",
            collection_id="col123",
            text="This is a test document."
        )
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "documents.create",
            {
                "title": "Test Document",
                "text": "This is a test document.",
                "collectionId": "col123",
                "publish": True
            }
        )
        
        # Verify result contains expected information
        assert "Document created successfully" in result
        assert "Test Document" in result
        assert "doc123" in result
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_create_document_with_parent(
        self, mock_get_client, register_content_tools
    ):
        """Test create_document tool with parent document ID."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = SAMPLE_CREATE_DOCUMENT_RESPONSE
        mock_get_client.return_value = mock_client
        
        # Call the tool with parent document ID
        _ = register_content_tools.tools["create_document"](
            title="Test Document",
            collection_id="col123",
            text="This is a test document.",
            parent_document_id="parent123"
        )
        
        # Verify parent document ID was included in the call
        mock_client.post.assert_called_once()
        call_args = mock_client.post.call_args[0]
        
        assert call_args[0] == "documents.create"
        assert "parentDocumentId" in call_args[1]
        assert call_args[1]["parentDocumentId"] == "parent123"
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_create_document_failure(
        self, mock_get_client, register_content_tools
    ):
        """Test create_document tool with empty response."""
        # Set up mock client with empty response
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": None}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["create_document"](
            title="Test Document",
            collection_id="col123"
        )
        
        # Verify result contains error message
        assert "Failed to create document" in result
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_create_document_client_error(
        self, mock_get_client, register_content_tools
    ):
        """Test create_document tool with client error."""
        # Set up mock client to raise an error
        mock_client = MagicMock()
        mock_client.post.side_effect = OutlineClientError("API error")
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["create_document"](
            title="Test Document",
            collection_id="col123"
        )
        
        # Verify error is handled and returned
        assert "Error creating document" in result
        assert "API error" in result
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_update_document_success(
        self, mock_get_client, register_content_tools
    ):
        """Test update_document tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = SAMPLE_UPDATE_DOCUMENT_RESPONSE
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["update_document"](
            document_id="doc123",
            title="Updated Document",
            text="This document has been updated."
        )
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "documents.update",
            {
                "id": "doc123",
                "title": "Updated Document",
                "text": "This document has been updated.",
                "append": False
            }
        )
        
        # Verify result contains expected information
        assert "Document updated successfully" in result
        assert "Updated Document" in result
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_update_document_append(
        self, mock_get_client, register_content_tools
    ):
        """Test update_document tool with append flag."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = SAMPLE_UPDATE_DOCUMENT_RESPONSE
        mock_get_client.return_value = mock_client
        
        # Call the tool with append flag
        _ = register_content_tools.tools["update_document"](
            document_id="doc123",
            text="Additional text.",
            append=True
        )
        
        # Verify append flag was included in the call
        mock_client.post.assert_called_once()
        call_args = mock_client.post.call_args[0]
        
        assert call_args[0] == "documents.update"
        assert "append" in call_args[1]
        assert call_args[1]["append"] is True
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_add_comment_success(
        self, mock_get_client, register_content_tools
    ):
        """Test add_comment tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.post.return_value = SAMPLE_COMMENT_RESPONSE
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["add_comment"](
            document_id="doc123",
            text="This is a comment"
        )
        
        # Verify client was called correctly
        mock_client.post.assert_called_once_with(
            "comments.create",
            {
                "documentId": "doc123",
                "text": "This is a comment"
            }
        )
        
        # Verify result contains expected information
        assert "Comment added successfully" in result
        assert "comment123" in result
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_add_comment_failure(
        self, mock_get_client, register_content_tools
    ):
        """Test add_comment tool with empty response."""
        # Set up mock client with empty response
        mock_client = MagicMock()
        mock_client.post.return_value = {"data": None}
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["add_comment"](
            document_id="doc123",
            text="This is a comment"
        )
        
        # Verify result contains error message
        assert "Failed to create comment" in result
    
    @patch("mcp_outline.features.documents.document_content.get_outline_client")
    def test_add_comment_client_error(
        self, mock_get_client, register_content_tools
    ):
        """Test add_comment tool with client error."""
        # Set up mock client to raise an error
        mock_client = MagicMock()
        mock_client.post.side_effect = OutlineClientError("API error")
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_content_tools.tools["add_comment"](
            document_id="doc123",
            text="This is a comment"
        )
        
        # Verify error is handled and returned
        assert "Error adding comment" in result
        assert "API error" in result

```

--------------------------------------------------------------------------------
/src/mcp_outline/utils/outline_client.py:
--------------------------------------------------------------------------------

```python
"""
Client for interacting with Outline API.

A simple client for making requests to the Outline API.
"""
import os
from typing import Any, Dict, List, Optional

import requests


class OutlineError(Exception):
    """Exception for all Outline API errors."""
    pass

class OutlineClient:
    """Simple client for Outline API services."""
    
    def __init__(
        self, 
        api_key: Optional[str] = None, 
        api_url: Optional[str] = None
    ):
        """
        Initialize the Outline client.
        
        Args:
            api_key: Outline API key or from OUTLINE_API_KEY env var.
            api_url: Outline API URL or from OUTLINE_API_URL env var.
        
        Raises:
            OutlineError: If API key is missing.
        """
        # Load configuration from environment variables if not provided
        self.api_key = api_key or os.getenv("OUTLINE_API_KEY")
        self.api_url = api_url or os.getenv("OUTLINE_API_URL", "https://app.getoutline.com/api")
        
        # Ensure API key is provided
        if not self.api_key:
            raise OutlineError("Missing API key. Set OUTLINE_API_KEY env var.")
    
    def post(
        self, 
        endpoint: str, 
        data: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        """
        Make a POST request to the Outline API.
        
        Args:
            endpoint: The API endpoint to call.
            data: The request payload.
            
        Returns:
            The parsed JSON response.
            
        Raises:
            OutlineError: If the request fails.
        """
        url = f"{self.api_url}/{endpoint}"
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
        
        try:
            response = requests.post(url, headers=headers, json=data or {})
            # Raise exception for 4XX/5XX responses
            response.raise_for_status()  
            return response.json()
        except requests.exceptions.RequestException as e:
            raise OutlineError(f"API request failed: {str(e)}")
    
    def auth_info(self) -> Dict[str, Any]:
        """
        Verify authentication and get user information.
        
        Returns:
            Dict containing user and team information.
        """
        response = self.post("auth.info")
        return response.get("data", {})
    
    def get_document(self, document_id: str) -> Dict[str, Any]:
        """
        Get a document by ID.
        
        Args:
            document_id: The document ID.
            
        Returns:
            Document information.
        """
        response = self.post("documents.info", {"id": document_id})
        return response.get("data", {})
    
    def search_documents(
        self, 
        query: str, 
        collection_id: Optional[str] = None, 
        limit: int = 10
    ) -> List[Dict[str, Any]]:
        """
        Search for documents using keywords.
        
        Args:
            query: Search terms
            collection_id: Optional collection to search within
            limit: Maximum number of results to return
            
        Returns:
            List of matching documents with context
        """
        data: Dict[str, Any] = {"query": query, "limit": limit}
        if collection_id:
            data["collectionId"] = collection_id
            
        response = self.post("documents.search", data)
        return response.get("data", [])
    
    def list_collections(self, limit: int = 20) -> List[Dict[str, Any]]:
        """
        List all available collections.
        
        Args:
            limit: Maximum number of results to return
            
        Returns:
            List of collections
        """
        response = self.post("collections.list", {"limit": limit})
        return response.get("data", [])
    
    def get_collection_documents(
        self, collection_id: str
    ) -> List[Dict[str, Any]]:
        """
        Get document structure for a collection.
        
        Args:
            collection_id: The collection ID.
            
        Returns:
            List of document nodes in the collection.
        """
        response = self.post("collections.documents", {"id": collection_id})
        return response.get("data", [])
    
    def list_documents(
        self, 
        collection_id: Optional[str] = None, 
        limit: int = 20
    ) -> List[Dict[str, Any]]:
        """
        List documents with optional filtering.
        
        Args:
            collection_id: Optional collection to filter by
            limit: Maximum number of results to return
            
        Returns:
            List of documents
        """
        data: Dict[str, Any] = {"limit": limit}
        if collection_id:
            data["collectionId"] = collection_id
            
        response = self.post("documents.list", data)
        return response.get("data", [])
    
    def archive_document(self, document_id: str) -> Dict[str, Any]:
        """
        Archive a document by ID.
        
        Args:
            document_id: The document ID to archive.
            
        Returns:
            The archived document data.
        """
        response = self.post("documents.archive", {"id": document_id})
        return response.get("data", {})
    
    def unarchive_document(self, document_id: str) -> Dict[str, Any]:
        """
        Unarchive a document by ID.
        
        Args:
            document_id: The document ID to unarchive.
            
        Returns:
            The unarchived document data.
        """
        response = self.post("documents.unarchive", {"id": document_id})
        return response.get("data", {})
        
    def list_trash(self, limit: int = 25) -> List[Dict[str, Any]]:
        """
        List documents in the trash.
        
        Args:
            limit: Maximum number of results to return
            
        Returns:
            List of documents in trash
        """
        response = self.post(
            "documents.list", {"limit": limit, "deleted": True}
        )
        return response.get("data", [])
    
    def restore_document(self, document_id: str) -> Dict[str, Any]:
        """
        Restore a document from trash.
        
        Args:
            document_id: The document ID to restore.
            
        Returns:
            The restored document data.
        """
        response = self.post("documents.restore", {"id": document_id})
        return response.get("data", {})
    
    def permanently_delete_document(self, document_id: str) -> bool:
        """
        Permanently delete a document by ID.
        
        Args:
            document_id: The document ID to permanently delete.
            
        Returns:
            Success status.
        """
        response = self.post("documents.delete", {
            "id": document_id,
            "permanent": True
        })
        return response.get("success", False)
    
    # Collection management methods
    def create_collection(
        self, 
        name: str, 
        description: str = "", 
        color: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Create a new collection.
        
        Args:
            name: The name of the collection
            description: Optional description for the collection
            color: Optional hex color code for the collection
            
        Returns:
            The created collection data
        """
        data: Dict[str, Any] = {
            "name": name,
            "description": description
        }
        
        if color:
            data["color"] = color
            
        response = self.post("collections.create", data)
        return response.get("data", {})
    
    def update_collection(
        self, 
        collection_id: str, 
        name: Optional[str] = None,
        description: Optional[str] = None, 
        color: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Update an existing collection.
        
        Args:
            collection_id: The ID of the collection to update
            name: Optional new name for the collection
            description: Optional new description
            color: Optional new hex color code
            
        Returns:
            The updated collection data
        """
        data: Dict[str, Any] = {"id": collection_id}
        
        if name is not None:
            data["name"] = name
            
        if description is not None:
            data["description"] = description
            
        if color is not None:
            data["color"] = color
            
        response = self.post("collections.update", data)
        return response.get("data", {})
    
    def delete_collection(self, collection_id: str) -> bool:
        """
        Delete a collection and all its documents.
        
        Args:
            collection_id: The ID of the collection to delete
            
        Returns:
            Success status
        """
        response = self.post("collections.delete", {"id": collection_id})
        return response.get("success", False)
    
    def export_collection(
        self, 
        collection_id: str, 
        format: str = "outline-markdown"
    ) -> Dict[str, Any]:
        """
        Export a collection to a file.
        
        Args:
            collection_id: The ID of the collection to export
            format: The export format (outline-markdown, json, or html)
            
        Returns:
            FileOperation data that can be queried for progress
        """
        response = self.post("collections.export", {
            "id": collection_id,
            "format": format
        })
        return response.get("data", {})
    
    def export_all_collections(
        self, 
        format: str = "outline-markdown"
    ) -> Dict[str, Any]:
        """
        Export all collections to a file.
        
        Args:
            format: The export format (outline-markdown, json, or html)
            
        Returns:
            FileOperation data that can be queried for progress
        """
        response = self.post("collections.export_all", {"format": format})
        return response.get("data", {})
    
    def answer_question(self, 
                       query: str,
                       collection_id: Optional[str] = None, 
                       document_id: Optional[str] = None) -> Dict[str, Any]:
        """
        Ask a natural language question about document content.
        
        Args:
            query: The natural language question to answer
            collection_id: Optional collection to search within
            document_id: Optional document to search within
            
        Returns:
            Dictionary containing AI answer and search results
        """
        data: Dict[str, Any] = {"query": query}
        
        if collection_id:
            data["collectionId"] = collection_id
            
        if document_id:
            data["documentId"] = document_id
            
        response = self.post("documents.answerQuestion", data)
        return response

```

--------------------------------------------------------------------------------
/tests/features/documents/test_document_search.py:
--------------------------------------------------------------------------------

```python
"""
Tests for document search tools.
"""
from unittest.mock import MagicMock, patch

import pytest

from mcp_outline.features.documents.common import OutlineClientError
from mcp_outline.features.documents.document_search import (
    _format_collection_documents,
    _format_collections,
    _format_documents_list,
    _format_search_results,
)


# Mock FastMCP for registering tools
class MockMCP:
    def __init__(self):
        self.tools = {}
    
    def tool(self):
        def decorator(func):
            self.tools[func.__name__] = func
            return func
        return decorator

# Sample test data
SAMPLE_SEARCH_RESULTS = [
    {
        "document": {
            "id": "doc1",
            "title": "Test Document 1"
        },
        "context": "This is a test document."
    },
    {
        "document": {
            "id": "doc2",
            "title": "Test Document 2"
        },
        "context": "Another test document."
    }
]

SAMPLE_DOCUMENTS = [
    {
        "id": "doc1",
        "title": "Test Document 1",
        "updatedAt": "2023-01-01T12:00:00Z"
    },
    {
        "id": "doc2",
        "title": "Test Document 2",
        "updatedAt": "2023-01-02T12:00:00Z"
    }
]

SAMPLE_COLLECTIONS = [
    {
        "id": "coll1",
        "name": "Test Collection 1",
        "description": "Collection description"
    },
    {
        "id": "coll2",
        "name": "Test Collection 2",
        "description": ""
    }
]

SAMPLE_COLLECTION_DOCUMENTS = [
    {
        "id": "doc1",
        "title": "Root Document",
        "children": [
            {
                "id": "doc2",
                "title": "Child Document",
                "children": []
            }
        ]
    }
]

class TestDocumentSearchFormatters:
    """Tests for document search formatting functions."""
    
    def test_format_search_results_with_data(self):
        """Test formatting search results with valid data."""
        result = _format_search_results(SAMPLE_SEARCH_RESULTS)
        
        # Verify the result contains the expected information
        assert "# Search Results" in result
        assert "Test Document 1" in result
        assert "doc1" in result
        assert "This is a test document." in result
        assert "Test Document 2" in result
    
    def test_format_search_results_empty(self):
        """Test formatting empty search results."""
        result = _format_search_results([])
        
        assert "No documents found" in result
    
    def test_format_documents_list_with_data(self):
        """Test formatting document list with valid data."""
        result = _format_documents_list(SAMPLE_DOCUMENTS, "Document List")
        
        # Verify the result contains the expected information
        assert "# Document List" in result
        assert "Test Document 1" in result
        assert "doc1" in result
        assert "2023-01-01" in result
        assert "Test Document 2" in result
    
    def test_format_collections_with_data(self):
        """Test formatting collections with valid data."""
        result = _format_collections(SAMPLE_COLLECTIONS)
        
        # Verify the result contains the expected information
        assert "# Collections" in result
        assert "Test Collection 1" in result
        assert "coll1" in result
        assert "Collection description" in result
        assert "Test Collection 2" in result
    
    def test_format_collections_empty(self):
        """Test formatting empty collections list."""
        result = _format_collections([])
        
        assert "No collections found" in result
    
    def test_format_collection_documents_with_data(self):
        """Test formatting collection document structure with valid data."""
        result = _format_collection_documents(SAMPLE_COLLECTION_DOCUMENTS)
        
        # Verify the result contains the expected information
        assert "# Collection Structure" in result
        assert "Root Document" in result
        assert "doc1" in result
        assert "Child Document" in result
        assert "doc2" in result
    
    def test_format_collection_documents_empty(self):
        """Test formatting empty collection document structure."""
        result = _format_collection_documents([])
        
        assert "No documents found in this collection" in result

@pytest.fixture
def mcp():
    """Fixture to provide mock MCP instance."""
    return MockMCP()

@pytest.fixture
def register_search_tools(mcp):
    """Fixture to register document search tools."""
    from mcp_outline.features.documents.document_search import register_tools
    register_tools(mcp)
    return mcp

class TestDocumentSearchTools:
    """Tests for document search tools."""
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_search_documents_success(
        self, mock_get_client, register_search_tools
    ):
        """Test search_documents tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.search_documents.return_value = SAMPLE_SEARCH_RESULTS
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_search_tools.tools["search_documents"]("test query")
        
        # Verify client was called correctly
        mock_client.search_documents.assert_called_once_with(
            "test query", 
            None
        )
        
        # Verify result contains expected information
        assert "Test Document 1" in result
        assert "doc1" in result
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_search_documents_with_collection(
        self, mock_get_client, register_search_tools
    ):
        """Test search_documents tool with collection filter."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.search_documents.return_value = SAMPLE_SEARCH_RESULTS
        mock_get_client.return_value = mock_client
        
        # Call the tool
        _ = register_search_tools.tools["search_documents"](
            "test query", 
            "coll1"
        )
        
        # Verify client was called correctly
        mock_client.search_documents.assert_called_once_with(
            "test query", "coll1")
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_search_documents_client_error(
        self, mock_get_client, register_search_tools
    ):
        """Test search_documents tool with client error."""
        # Set up mock client to raise an error
        mock_client = MagicMock()
        mock_client.search_documents.side_effect = OutlineClientError(
            "API error")
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_search_tools.tools["search_documents"]("test query")
        
        # Verify error is handled and returned
        assert "Error searching documents" in result
        assert "API error" in result
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_list_collections_success(
        self, mock_get_client, register_search_tools
    ):
        """Test list_collections tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.list_collections.return_value = SAMPLE_COLLECTIONS
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_search_tools.tools["list_collections"]()
        
        # Verify client was called correctly
        mock_client.list_collections.assert_called_once()
        
        # Verify result contains expected information
        assert "Test Collection 1" in result
        assert "coll1" in result
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_get_collection_structure_success(
        self, mock_get_client, register_search_tools
    ):
        """Test get_collection_structure tool success case."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.get_collection_documents.return_value = (
            SAMPLE_COLLECTION_DOCUMENTS)
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_search_tools.tools[
            "get_collection_structure"]("coll1")
        
        # Verify client was called correctly
        mock_client.get_collection_documents.assert_called_once_with("coll1")
        
        # Verify result contains expected information
        assert "Root Document" in result
        assert "Child Document" in result
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_get_document_id_from_title_exact_match(
        self, mock_get_client, register_search_tools
    ):
        """Test get_document_id_from_title tool with exact match."""
        # Search results with exact title match
        exact_match_results = [
            {
                "document": {
                    "id": "doc1",
                    "title": "Exact Match"
                }
            }
        ]
        
        # Set up mock client
        mock_client = MagicMock()
        mock_client.search_documents.return_value = exact_match_results
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_search_tools.tools["get_document_id_from_title"](
            "Exact Match"
        )
        
        # Verify client was called correctly
        mock_client.search_documents.assert_called_once_with(
            "Exact Match", None)
        
        # Verify result contains expected information
        assert "Document ID: doc1" in result
        assert "Exact Match" in result
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_get_document_id_from_title_best_match(
        self, mock_get_client, register_search_tools
    ):
        """Test get_document_id_from_title tool with best match (non-exact)."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.search_documents.return_value = SAMPLE_SEARCH_RESULTS
        mock_get_client.return_value = mock_client
        
        # Call the tool with title that doesn't exactly match
        result = register_search_tools.tools[
            "get_document_id_from_title"]("Test Doc")
        
        # Verify result contains expected information
        assert "Best match" in result
        assert "doc1" in result
    
    @patch("mcp_outline.features.documents.document_search.get_outline_client")
    def test_get_document_id_from_title_no_results(
        self, mock_get_client, register_search_tools
    ):
        """Test get_document_id_from_title tool with no results."""
        # Set up mock client
        mock_client = MagicMock()
        mock_client.search_documents.return_value = []
        mock_get_client.return_value = mock_client
        
        # Call the tool
        result = register_search_tools.tools[
            "get_document_id_from_title"]("Nonexistent")
        
        # Verify result contains expected information
        assert "No documents found" in result
        assert "Nonexistent" in result

```