diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..581f4c4 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "mcp-outline", + "owner": { + "name": "Vortiago", + "url": "https://github.com/Vortiago" + }, + "metadata": { + "description": "Outline knowledge base integration for Claude Code" + }, + "plugins": [ + { + "name": "mcp-outline", + "source": "./", + "description": "Connect Claude Code to Outline for document search, reading, creation, and management", + "version": "1.6.0", + "author": { + "name": "Vortiago" + }, + "license": "MIT" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..946159d --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-outline", + "description": "Connect Claude Code to Outline for document search, reading, creation, and management", + "version": "1.6.0", + "author": { + "name": "Vortiago" + }, + "homepage": "https://github.com/Vortiago/mcp-outline", + "repository": "https://github.com/Vortiago/mcp-outline", + "license": "MIT", + "keywords": [ + "outline", + "documents", + "wiki", + "knowledge-base", + "mcp" + ] +} diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml new file mode 100644 index 0000000..d36720c --- /dev/null +++ b/.github/workflows/publish-mcp-registry.yml @@ -0,0 +1,31 @@ +name: Publish to MCP Registry + +on: + push: + tags: + - 'v*' + +jobs: + publish: + name: Publish to MCP Registry + runs-on: ubuntu-latest + permissions: + id-token: write # Required for GitHub OIDC authentication + contents: read + + steps: + - uses: actions/checkout@v6 + + - name: Update server.json version from tag + run: | + VERSION="${GITHUB_REF_NAME#v}" + jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.json.tmp + mv server.json.tmp server.json + cat server.json + + - name: Install mcp-publisher + run: | + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Publish to MCP Registry + run: ./mcp-publisher publish diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e6aca82 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "outline": { + "command": "uvx", + "args": ["mcp-outline"], + "env": { + "OUTLINE_API_KEY": "${OUTLINE_API_KEY:-}", + "OUTLINE_API_URL": "${OUTLINE_API_URL:-}", + "OUTLINE_READ_ONLY": "${OUTLINE_READ_ONLY:-}", + "OUTLINE_DISABLE_DELETE": "${OUTLINE_DISABLE_DELETE:-}", + "OUTLINE_DISABLE_AI_TOOLS": "${OUTLINE_DISABLE_AI_TOOLS:-}", + "OUTLINE_VERIFY_SSL": "${OUTLINE_VERIFY_SSL:-}" + } + } + } +} diff --git a/README.md b/README.md index 4d3a618..bfb0a3c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ Before using this MCP server, you need: ## Quick Start +### One-Click Install + +Click a button to install with interactive API key prompt: + +[![Install in VS Code](https://img.shields.io/badge/Install_in-VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=mcp-outline&inputs=%5B%7B%22id%22%3A%22outline_api_key%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Enter%20OUTLINE_API_KEY%22%2C%22password%22%3Atrue%7D%2C%7B%22id%22%3A%22outline_api_url%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Outline%20API%20URL%20(optional%2C%20for%20self-hosted)%22%2C%22password%22%3Afalse%7D%5D&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-outline%22%5D%2C%22env%22%3A%7B%22OUTLINE_API_KEY%22%3A%22%24%7Binput%3Aoutline_api_key%7D%22%2C%22OUTLINE_API_URL%22%3A%22%24%7Binput%3Aoutline_api_url%7D%22%7D%7D) +[![Install in VS Code Insiders](https://img.shields.io/badge/Install_in-VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=mcp-outline&inputs=%5B%7B%22id%22%3A%22outline_api_key%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Enter%20OUTLINE_API_KEY%22%2C%22password%22%3Atrue%7D%2C%7B%22id%22%3A%22outline_api_url%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Outline%20API%20URL%20(optional%2C%20for%20self-hosted)%22%2C%22password%22%3Afalse%7D%5D&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-outline%22%5D%2C%22env%22%3A%7B%22OUTLINE_API_KEY%22%3A%22%24%7Binput%3Aoutline_api_key%7D%22%2C%22OUTLINE_API_URL%22%3A%22%24%7Binput%3Aoutline_api_url%7D%22%7D%7D&quality=insiders) +[![Install in Cursor](https://img.shields.io/badge/Install_in-Cursor-000000?style=flat-square&logoColor=white)](https://cursor.com/en/install-mcp?name=mcp-outline&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtY3Atb3V0bGluZSJdLCJlbnYiOnsiT1VUTElORV9BUElfS0VZIjoiJHtpbnB1dDpvdXRsaW5lX2FwaV9rZXl9IiwiT1VUTElORV9BUElfVVJMIjoiJHtpbnB1dDpvdXRsaW5lX2FwaV91cmx9In0sImlucHV0cyI6W3siaWQiOiJvdXRsaW5lX2FwaV9rZXkiLCJ0eXBlIjoicHJvbXB0U3RyaW5nIiwiZGVzY3JpcHRpb24iOiJFbnRlciBPVVRMSU5FX0FQSV9LRVkiLCJwYXNzd29yZCI6dHJ1ZX0seyJpZCI6Im91dGxpbmVfYXBpX3VybCIsInR5cGUiOiJwcm9tcHRTdHJpbmciLCJkZXNjcmlwdGlvbiI6Ik91dGxpbmUgQVBJIFVSTCAob3B0aW9uYWwsIGZvciBzZWxmLWhvc3RlZCkiLCJwYXNzd29yZCI6ZmFsc2V9XX0=) + +### Manual Install + Install with uv (recommended), pip, or Docker: ```bash @@ -41,7 +51,49 @@ pip install mcp-outline # using pip docker run -e OUTLINE_API_KEY= ghcr.io/vortiago/mcp-outline:latest ``` -Then add to your MCP client. Example for **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`): +Then add to your MCP client config (works with VS Code, Claude Desktop, Cursor, and others): + +```json +{ + "inputs": [ + { + "id": "outline_api_key", + "type": "promptString", + "description": "Enter OUTLINE_API_KEY", + "password": true + }, + { + "id": "outline_api_url", + "type": "promptString", + "description": "Outline API URL (optional, for self-hosted)", + "password": false + } + ], + "servers": { + "mcp-outline": { + "command": "uvx", + "args": ["mcp-outline"], + "env": { + "OUTLINE_API_KEY": "${input:outline_api_key}", + "OUTLINE_API_URL": "${input:outline_api_url}" + } + } + } +} +``` + +
+Claude Code + +```bash +claude mcp add mcp-outline uvx mcp-outline +``` +
+ +
+Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { @@ -51,20 +103,21 @@ Then add to your MCP client. Example for **Claude Desktop** (`~/Library/Applicat "args": ["mcp-outline"], "env": { "OUTLINE_API_KEY": "", - "OUTLINE_API_URL": "" // Optional + "OUTLINE_API_URL": "" } } } } ``` +
-Setup guides for other clients: [Cursor, VS Code, Cline, Docker (HTTP), pip](docs/client-setup.md) +Setup guides for more clients: [Docker (HTTP), Cline, Codex, Windsurf, and others](docs/client-setup.md) ## Configuration | Variable | Required | Default | Notes | |----------|----------|---------|-------| -| `OUTLINE_API_KEY` | Yes* | - | Required for stdio transport. For SSE/HTTP, can be provided per-user via `x-outline-api-key` header instead ([details](docs/configuration.md#per-user-outline-api-keys)) | +| `OUTLINE_API_KEY` | Yes* | - | Required for tool calls to succeed. For SSE/HTTP, can alternatively be provided per-request via `x-outline-api-key` header ([details](docs/configuration.md#per-user-outline-api-keys)) | | `OUTLINE_API_URL` | No | `https://app.getoutline.com/api` | For self-hosted: `https://your-domain/api` | | `OUTLINE_VERIFY_SSL` | No | `true` | Set `false` for self-signed certificates | | `OUTLINE_READ_ONLY` | No | `false` | `true` = disable ALL write operations ([details](docs/configuration.md#read-only-mode)) | diff --git a/docs/client-setup.md b/docs/client-setup.md index 219de04..25868ce 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -25,7 +25,9 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (or `%APP ## Cursor -Go to **Settings → MCP** and click **Add Server**: +**One-click install**: [![Install in Cursor](https://img.shields.io/badge/Install_in-Cursor-000000?style=flat-square&logoColor=white)](https://cursor.com/en/install-mcp?name=mcp-outline&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtY3Atb3V0bGluZSJdLCJlbnYiOnsiT1VUTElORV9BUElfS0VZIjoiJHtpbnB1dDpvdXRsaW5lX2FwaV9rZXl9IiwiT1VUTElORV9BUElfVVJMIjoiJHtpbnB1dDpvdXRsaW5lX2FwaV91cmx9In0sImlucHV0cyI6W3siaWQiOiJvdXRsaW5lX2FwaV9rZXkiLCJ0eXBlIjoicHJvbXB0U3RyaW5nIiwiZGVzY3JpcHRpb24iOiJFbnRlciBPVVRMSU5FX0FQSV9LRVkiLCJwYXNzd29yZCI6dHJ1ZX0seyJpZCI6Im91dGxpbmVfYXBpX3VybCIsInR5cGUiOiJwcm9tcHRTdHJpbmciLCJkZXNjcmlwdGlvbiI6Ik91dGxpbmUgQVBJIFVSTCAob3B0aW9uYWwsIGZvciBzZWxmLWhvc3RlZCkiLCJwYXNzd29yZCI6ZmFsc2V9XX0=) + +Or go to **Settings → MCP** and click **Add Server**: ```json { @@ -33,43 +35,47 @@ Go to **Settings → MCP** and click **Add Server**: "command": "uvx", "args": ["mcp-outline"], "env": { - "OUTLINE_API_KEY": "", - "OUTLINE_API_URL": "" // Optional - } + "OUTLINE_API_KEY": "${input:outline_api_key}", + "OUTLINE_API_URL": "${input:outline_api_url}" + }, + "inputs": [ + { + "id": "outline_api_key", + "type": "promptString", + "description": "Enter OUTLINE_API_KEY", + "password": true + }, + { + "id": "outline_api_url", + "type": "promptString", + "description": "Outline API URL (optional, for self-hosted)", + "password": false + } + ] } } ``` ## VS Code -Create a `.vscode/mcp.json` file in your workspace: - -```json -{ - "servers": { - "mcp-outline": { - "type": "stdio", - "command": "uvx", - "args": ["mcp-outline"], - "env": { - "OUTLINE_API_KEY": "", - "OUTLINE_API_URL": "" // Optional - } - } - } -} -``` +**One-click install**: [![Install in VS Code](https://img.shields.io/badge/Install_in-VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=mcp-outline&inputs=%5B%7B%22id%22%3A%22outline_api_key%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Enter%20OUTLINE_API_KEY%22%2C%22password%22%3Atrue%7D%2C%7B%22id%22%3A%22outline_api_url%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Outline%20API%20URL%20(optional%2C%20for%20self-hosted)%22%2C%22password%22%3Afalse%7D%5D&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-outline%22%5D%2C%22env%22%3A%7B%22OUTLINE_API_KEY%22%3A%22%24%7Binput%3Aoutline_api_key%7D%22%2C%22OUTLINE_API_URL%22%3A%22%24%7Binput%3Aoutline_api_url%7D%22%7D%7D) -**Optional**: Use input variables for sensitive credentials: +Or create a `.vscode/mcp.json` file in your workspace (recommended — uses secure password prompts): ```json { "inputs": [ { "type": "promptString", - "id": "outline-api-key", - "description": "Outline API Key", + "id": "outline_api_key", + "description": "Enter OUTLINE_API_KEY", "password": true + }, + { + "type": "promptString", + "id": "outline_api_url", + "description": "Outline API URL (optional, for self-hosted)", + "password": false } ], "servers": { @@ -78,13 +84,20 @@ Create a `.vscode/mcp.json` file in your workspace: "command": "uvx", "args": ["mcp-outline"], "env": { - "OUTLINE_API_KEY": "${input:outline-api-key}" + "OUTLINE_API_KEY": "${input:outline_api_key}", + "OUTLINE_API_URL": "${input:outline_api_url}" } } } } ``` +You can also install via the CLI: + +```bash +code --add-mcp '{"name":"mcp-outline","command":"uvx","args":["mcp-outline"],"env":{"OUTLINE_API_KEY":"${input:outline_api_key}","OUTLINE_API_URL":"${input:outline_api_url}"},"inputs":[{"id":"outline_api_key","type":"promptString","description":"Enter OUTLINE_API_KEY","password":true},{"id":"outline_api_url","type":"promptString","description":"Outline API URL (optional, for self-hosted)","password":false}]}' +``` + See the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more details. ## Cline (VS Code) @@ -104,6 +117,31 @@ In Cline extension settings, add to MCP servers: } ``` +## Claude Code (Plugin) + +Install directly as a Claude Code plugin from GitHub: + +```bash +/plugin marketplace add Vortiago/mcp-outline +/plugin install mcp-outline@mcp-outline +``` + +Or test locally during development: + +```bash +claude --plugin-dir ./path-to-mcp-outline +``` + +Set your Outline API key in your shell profile (`~/.bashrc` or `~/.zshrc`): + +```bash +export OUTLINE_API_KEY="your-api-key-here" +# For self-hosted Outline: +export OUTLINE_API_URL="https://your-instance.example.com/api" +``` + +Restart Claude Code after setting environment variables. If `OUTLINE_API_KEY` is not configured, each tool call will return an error with setup instructions. + ## Using pip Instead of uvx If you prefer to use `pip` instead: diff --git a/docs/configuration.md b/docs/configuration.md index e47261e..6db01cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,7 +87,7 @@ Then connect from your client with a user-specific key: } ``` -> **Note:** The `x-outline-api-key` header is only available for HTTP transports. In `stdio` mode, the `OUTLINE_API_KEY` environment variable is the only option. +> **Note:** The `x-outline-api-key` header is only available for HTTP transports (SSE, streamable-http). In `stdio` mode, you must set `OUTLINE_API_KEY` via environment variable or a [dotenv config file](client-setup.md#claude-code-plugin). If it is missing, the server still starts but every tool call will return an error with setup instructions. ### Dynamic Tool List diff --git a/docs/registries.md b/docs/registries.md new file mode 100644 index 0000000..a41f51a --- /dev/null +++ b/docs/registries.md @@ -0,0 +1,19 @@ +# Registries & Marketplaces + +Where mcp-outline is listed and which files control each listing. + +| Registry | Config Files | Published How | Notes | +|----------|-------------|---------------|-------| +| **PyPI** | `pyproject.toml` | CI on tag (`publish.yml`) | Version from git tag via setuptools-scm | +| **Docker (GHCR)** | `Dockerfile` | CI on tag | Image: `ghcr.io/vortiago/mcp-outline` | +| **MCP Official Registry** | `server.json` | CI on tag (`publish-mcp-registry.yml`) | Version patched from tag at publish time | +| **Glama** | `glama.json` | Manual claim at [glama.ai](https://glama.ai) | Only sets maintainer for ownership | +| **Claude Code Plugin** | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `.mcp.json` | Repo discovery | Install: `/plugin marketplace add Vortiago/mcp-outline` | + +## File reference + +- **`server.json`** — MCP Registry server manifest. Schema: `2025-12-11`. Versions updated by CI at publish time. +- **`glama.json`** — Glama ownership claim. Minimal: just `$schema` + `maintainers`. +- **`.claude-plugin/plugin.json`** — Claude Code plugin metadata (name, description, keywords). +- **`.claude-plugin/marketplace.json`** — Claude Code marketplace entry. Lists plugins available from this repo. +- **`.mcp.json`** — MCP server config used by the Claude Code plugin. Defines how to run the server with env vars. diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..bb98467 --- /dev/null +++ b/glama.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": [ + "Vortiago" + ] +} diff --git a/pyproject.toml b/pyproject.toml index bdea108..75b8470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ license-files = ["LICENSE"] dependencies = [ "mcp[cli]>=1.20.0", "httpx>=0.27.0", + "python-dotenv>=1.0.0", ] [project.scripts] diff --git a/server.json b/server.json new file mode 100644 index 0000000..506f728 --- /dev/null +++ b/server.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.vortiago/mcp-outline", + "title": "Outline MCP Server", + "description": "Connect AI assistants to Outline for document search, reading, and management", + "version": "1.6.0", + "websiteUrl": "https://github.com/Vortiago/mcp-outline", + "repository": { + "url": "https://github.com/Vortiago/mcp-outline", + "source": "github", + "id": "Vortiago/mcp-outline" + }, + "packages": [ + { + "registryType": "pypi", + "identifier": "mcp-outline", + "version": "1.6.0", + "transport": { + "type": "stdio" + }, + "runtimeHint": "uvx", + "environmentVariables": [ + { + "name": "OUTLINE_API_KEY", + "description": "Outline API key (Settings > API). Can also be set in ~/.config/mcp-outline/.env", + "isRequired": false, + "isSecret": true + }, + { + "name": "OUTLINE_API_URL", + "description": "Outline API URL for self-hosted instances", + "isRequired": false + } + ] + } + ] +} diff --git a/src/mcp_outline/server.py b/src/mcp_outline/server.py index f16f5c1..3e479ce 100644 --- a/src/mcp_outline/server.py +++ b/src/mcp_outline/server.py @@ -7,8 +7,10 @@ import logging import os import sys +from pathlib import Path from typing import Literal +from dotenv import load_dotenv from mcp.server.fastmcp import FastMCP from mcp_outline.features import register_all @@ -21,11 +23,26 @@ # This must happen before creating the FastMCP instance patch_for_copilot_cli() +# Strip empty env vars so that dotenv can fill them from the +# config file. MCP clients (e.g. Claude Code) may pass +# empty strings for unset variables via their env block. +for _key in list(os.environ): + if _key.startswith("OUTLINE_") and os.environ[_key] == "": + del os.environ[_key] + +# Load configuration from dotenv files. +# Existing environment variables always take priority (override=False). +# Project-level file is loaded first so it wins over user-level. +_project_env = Path.cwd() / ".mcp-outline.env" +load_dotenv(_project_env) +_config_path = Path.home() / ".config" / "mcp-outline" / ".env" +load_dotenv(_config_path) + # Get host from environment variable, default to 127.0.0.1 # Use 0.0.0.0 for Docker containers to allow external connections host = os.getenv("MCP_HOST", "127.0.0.1") -# Get port from environment variable, default to 3000 (standard MCP HTTP port) +# Get port from environment variable, default to 3000 port = int(os.getenv("MCP_PORT", "3000")) # Create a FastMCP server instance with a name and port configuration diff --git a/src/mcp_outline/utils/outline_client.py b/src/mcp_outline/utils/outline_client.py index 8b701d2..a5421fb 100644 --- a/src/mcp_outline/utils/outline_client.py +++ b/src/mcp_outline/utils/outline_client.py @@ -91,8 +91,13 @@ def __init__( # sanitized_key will be None or empty string if invalid if not self.api_key: raise OutlineError( - "Missing API key. Set OUTLINE_API_KEY env var " - "or pass per-request via x-outline-api-key header." + "Missing API key. Set OUTLINE_API_KEY in " + ".mcp-outline.env (project) or " + "~/.config/mcp-outline/.env (user), " + "or pass per-request via " + "x-outline-api-key header. " + "Get your key from Outline " + "(Settings > API)." ) # Rate limit tracking diff --git a/tests/test_dotenv_config.py b/tests/test_dotenv_config.py new file mode 100644 index 0000000..77a2cea --- /dev/null +++ b/tests/test_dotenv_config.py @@ -0,0 +1,187 @@ +""" +Tests for dotenv configuration loading from +.mcp-outline.env (project) and ~/.config/mcp-outline/.env (user). +""" + +import os +import sys +from pathlib import Path +from unittest.mock import call, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _clean_server_module(): + """Remove cached server module so each test re-executes + module-level code.""" + mods = [k for k in sys.modules if k.startswith("mcp_outline.server")] + for m in mods: + del sys.modules[m] + yield + mods = [k for k in sys.modules if k.startswith("mcp_outline.server")] + for m in mods: + del sys.modules[m] + + +def test_load_dotenv_called_project_then_user(): + """load_dotenv is called for project env first, + then user config.""" + project = Path.cwd() / ".mcp-outline.env" + user = Path.home() / ".config" / "mcp-outline" / ".env" + + with patch("dotenv.load_dotenv") as mock_load: + import mcp_outline.server # noqa: F401 + + assert mock_load.call_count == 2 + mock_load.assert_has_calls([call(project), call(user)]) + + +def test_env_var_not_overridden(tmp_path): + """An existing OUTLINE_API_KEY env var must survive the + module-level load_dotenv call (override=False default).""" + config_dir = tmp_path / ".config" / "mcp-outline" + config_dir.mkdir(parents=True) + (config_dir / ".env").write_text("OUTLINE_API_KEY=from-file\n") + + with patch.dict(os.environ, {"OUTLINE_API_KEY": "from-env"}): + with patch("pathlib.Path.home", return_value=tmp_path): + import mcp_outline.server # noqa: F401 + + assert os.environ["OUTLINE_API_KEY"] == "from-env" + + +def test_env_var_loaded_from_user_file(tmp_path): + """When OUTLINE_API_KEY is not set, the module-level + load_dotenv populates it from the user config file.""" + config_dir = tmp_path / ".config" / "mcp-outline" + config_dir.mkdir(parents=True) + (config_dir / ".env").write_text("OUTLINE_API_KEY=from-file\n") + + env = os.environ.copy() + env.pop("OUTLINE_API_KEY", None) + + with patch.dict(os.environ, env, clear=True): + with patch("pathlib.Path.home", return_value=tmp_path): + import mcp_outline.server # noqa: F401 + + assert os.environ["OUTLINE_API_KEY"] == "from-file" + + +def test_project_env_takes_priority(tmp_path): + """Project-level .mcp-outline.env wins over user-level + config when both exist.""" + # User config + user_dir = tmp_path / ".config" / "mcp-outline" + user_dir.mkdir(parents=True) + (user_dir / ".env").write_text( + "OUTLINE_API_KEY=from-user\nOUTLINE_API_URL=http://user-instance/api\n" + ) + + # Project config + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".mcp-outline.env").write_text( + "OUTLINE_API_KEY=from-project\n" + "OUTLINE_API_URL=http://project-instance/api\n" + ) + + env = os.environ.copy() + env.pop("OUTLINE_API_KEY", None) + env.pop("OUTLINE_API_URL", None) + + with patch.dict(os.environ, env, clear=True): + with patch("pathlib.Path.home", return_value=tmp_path): + with patch( + "pathlib.Path.cwd", + return_value=project_dir, + ): + import mcp_outline.server # noqa: F401 + + assert os.environ["OUTLINE_API_KEY"] == "from-project" + assert os.environ["OUTLINE_API_URL"] == "http://project-instance/api" + + +def test_empty_env_vars_stripped_before_dotenv(tmp_path): + """Empty OUTLINE_* env vars (from MCP client env blocks) + should be stripped so dotenv can fill them from file.""" + config_dir = tmp_path / ".config" / "mcp-outline" + config_dir.mkdir(parents=True) + (config_dir / ".env").write_text( + "OUTLINE_API_KEY=from-file\nOUTLINE_API_URL=http://localhost:3030\n" + ) + + env = os.environ.copy() + env.pop("OUTLINE_API_KEY", None) + env.pop("OUTLINE_API_URL", None) + # Simulate MCP client passing empty strings + env["OUTLINE_API_KEY"] = "" + env["OUTLINE_API_URL"] = "" + + with patch.dict(os.environ, env, clear=True): + with patch("pathlib.Path.home", return_value=tmp_path): + import mcp_outline.server # noqa: F401 + + assert os.environ["OUTLINE_API_KEY"] == "from-file" + assert os.environ["OUTLINE_API_URL"] == "http://localhost:3030" + + +def test_missing_config_file_no_error(): + """Server imports without error when config files + do not exist.""" + with patch("dotenv.load_dotenv") as mock_load: + mock_load.return_value = False + import mcp_outline.server # noqa: F401 + + assert mock_load.call_count == 2 + + +def test_stdio_starts_without_api_key(): + """In stdio mode, main() starts normally even without + an API key. Unlike SSE/HTTP where per-request auth + via x-outline-api-key header is available, stdio has + no header fallback — so every tool call will return + an OutlineClientError with config instructions.""" + env = os.environ.copy() + env.pop("OUTLINE_API_KEY", None) + env["MCP_TRANSPORT"] = "stdio" + + with patch.dict(os.environ, env, clear=True): + import mcp_outline.server + + with patch.object(mcp_outline.server.mcp, "run") as mock_run: + mcp_outline.server.main() + mock_run.assert_called_once_with(transport="stdio") + + +def test_stdio_starts_with_api_key(): + """In stdio mode, main() proceeds when API key is set.""" + with patch.dict( + os.environ, + { + "OUTLINE_API_KEY": "ol_api_test", + "MCP_TRANSPORT": "stdio", + }, + ): + import mcp_outline.server + + with patch.object(mcp_outline.server.mcp, "run") as mock_run: + mcp_outline.server.main() + mock_run.assert_called_once_with(transport="stdio") + + +def test_sse_skips_api_key_check(): + """In SSE mode, main() starts without a global API key. + Unlike stdio, SSE/HTTP modes support per-request auth + via the x-outline-api-key header, so a global key is + not required.""" + env = os.environ.copy() + env.pop("OUTLINE_API_KEY", None) + env["MCP_TRANSPORT"] = "sse" + + with patch.dict(os.environ, env, clear=True): + import mcp_outline.server + + with patch.object(mcp_outline.server.mcp, "run") as mock_run: + mcp_outline.server.main() + mock_run.assert_called_once_with(transport="sse") diff --git a/uv.lock b/uv.lock index 06f1376..3c9e8cb 100644 --- a/uv.lock +++ b/uv.lock @@ -530,6 +530,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "mcp", extra = ["cli"] }, + { name = "python-dotenv" }, ] [package.dev-dependencies] @@ -550,6 +551,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.20.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, ] [package.metadata.requires-dev]