# 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.  ## 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 ```