feat: add binary file attachment and import tools for Outline LTM system (#1)
Build and Push Outline MCP Docker Image / build (push) Successful in 1m18s

Implement 3 new MCP tools:
- attach_file_to_document(document_id, file_path)
- upload_image_to_document(image_path, document_id, alt_text)
- import_file_to_outline(file_path, collection_id, parent_document_id)

Security:
- Restrict file access to /tmp via _validate_file_path with realpath
- 50MB max file size enforced client-side
- Symlink traversal blocked

Technical:
- Extract shared _upload_attachment() helper
- Stream files to presigned URLs instead of loading into memory
- Add combined lifespan to close OutlineClient on shutdown
- Update CI workflow with modern action versions and PR triggers

Tests:
- Add 28 tests covering path validation, size limits, upload flow,
  error handling, symlink traversal, and multipart imports
This commit is contained in:
2026-05-25 00:36:54 +00:00
parent d152162dbf
commit dc926cc8da
5 changed files with 749 additions and 4 deletions
+22 -3
View File
@@ -5,6 +5,10 @@ on:
branches:
- main
- master
pull_request:
branches:
- main
- master
jobs:
build:
@@ -13,17 +17,32 @@ jobs:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install uv
uv sync --extra dev
- name: Run tests
run: uv run pytest tests/ -v
- name: Login to Gitea Container Registry
uses: docker/login-action@v2
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: gitea.ext.ben.io
username: ${{ gitea.actor }}
password: ${{ secrets.CR_PAT }}
- name: Build and Push
uses: docker/build-push-action@v4
if: github.event_name == 'push'
uses: docker/build-push-action@v5
with:
context: .
push: true
+5
View File
@@ -0,0 +1,5 @@
venv/
__pycache__/
*.egg-info/
.pytest_cache/
.ruff_cache/
+41
View File
@@ -46,6 +46,27 @@ Params: method (required), params (JSON string)
Returns: Raw API response as JSON string
```
### attach_file_to_document
Upload a local file as an attachment to an existing Outline document.
```
Params: document_id (required), file_path (required — must be under /tmp)
Returns: JSON with attachment_url, markdown embed string, name, size, content_type
```
### upload_image_to_document
Upload an image file as an attachment and return Markdown image embed syntax.
```
Params: image_path (required — must be under /tmp), document_id (required), alt_text (optional)
Returns: JSON with attachment_url, markdown image embed, name, size, content_type
```
### import_file_to_outline
Import a local file (Markdown, text, JSON, CSV) as a new Outline document.
```
Params: file_path (required — must be under /tmp), collection_id (required), parent_document_id (optional)
Returns: JSON with newly created document metadata
```
---
## Outline API Reference
@@ -384,3 +405,23 @@ params: {"invites": [{"email": "user@example.com", "name": "User Name", "role":
method: "collections.create"
params: {"name": "Engineering", "description": "Engineering documentation", "permission": "read_write"}
```
### Attach a file to a document
```
document_id: "document-uuid"
file_path: "/tmp/report.pdf"
```
### Upload an image to a document
```
image_path: "/tmp/screenshot.png"
document_id: "document-uuid"
alt_text: "System architecture diagram"
```
### Import a Markdown file as a new document
```
file_path: "/tmp/notes.md"
collection_id: "collection-uuid"
parent_document_id: "parent-document-uuid" # optional
```
+247 -1
View File
@@ -1,9 +1,11 @@
import os
import json
import logging
import mimetypes
from typing import Any
import httpx
from fastmcp import FastMCP
from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route, Mount
@@ -20,6 +22,29 @@ logger = logging.getLogger(__name__)
OUTLINE_API_URL = os.getenv("OUTLINE_API_URL", "").rstrip("/")
OUTLINE_API_TOKEN = os.getenv("OUTLINE_API_TOKEN", "")
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
ALLOWED_PATH_PREFIX = "/tmp"
def _validate_file_path(file_path: str) -> str:
"""Validate that file_path is within ALLOWED_PATH_PREFIX and exists."""
abs_path = os.path.abspath(file_path)
real_path = os.path.realpath(abs_path)
allowed_real = os.path.realpath(ALLOWED_PATH_PREFIX)
if not (real_path == allowed_real or real_path.startswith(allowed_real + os.sep)):
raise ValueError(f"File path must be within {ALLOWED_PATH_PREFIX}")
if not os.path.exists(real_path):
raise ValueError(f"File not found: {file_path}")
if not os.path.isfile(real_path):
raise ValueError(f"Path is not a file: {file_path}")
return real_path
def _get_content_type(file_path: str) -> str | None:
"""Guess content type from file extension."""
content_type, _ = mimetypes.guess_type(file_path)
return content_type
class OutlineClient:
"""HTTP client for Outline API with authentication."""
@@ -87,6 +112,61 @@ class OutlineClient:
except Exception as e:
return False, str(e)
async def upload_to_presigned_url(
self, upload_url: str, file_path: str, content_type: str
) -> dict:
"""Upload file bytes to a presigned URL (e.g., S3)."""
try:
with open(file_path, "rb") as f:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.put(
upload_url,
content=f,
headers={"Content-Type": content_type},
)
response.raise_for_status()
return {"ok": True}
except httpx.HTTPStatusError as e:
return {
"ok": False,
"error": f"Upload failed: {e.response.text}",
"status": e.response.status_code,
}
except Exception as e:
return {"ok": False, "error": f"Upload failed: {str(e)}"}
async def call_multipart(
self, method: str, file_path: str, fields: dict
) -> dict:
"""Execute a multipart Outline API call for file upload endpoints."""
try:
filename = os.path.basename(file_path)
content_type = _get_content_type(file_path) or "application/octet-stream"
with open(file_path, "rb") as f:
files = {"file": (filename, f, content_type)}
response = await self.client.post(
f"/api/{method}", files=files, data=fields
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
error_body = e.response.text
try:
error_json = e.response.json()
return {
"ok": False,
"error": error_json.get("message", str(e)),
"status": e.response.status_code,
}
except Exception:
return {
"ok": False,
"error": error_body or str(e),
"status": e.response.status_code,
}
except Exception as e:
return {"ok": False, "error": f"Request failed: {str(e)}"}
async def close(self):
if self._client and not self._client.is_closed:
await self._client.aclose()
@@ -224,6 +304,159 @@ async def outline_api_call(method: str, params: Any = None) -> str:
return json.dumps(result, indent=2)
async def _upload_attachment(document_id: str, file_path: str) -> dict:
"""Shared helper: validate file, create attachment, upload to presigned URL.
Returns a dict with keys: ok, attachment_url, name, size, content_type
or ok=False, error.
"""
try:
validated_path = _validate_file_path(file_path)
except ValueError as e:
return {"ok": False, "error": str(e)}
file_size = os.path.getsize(validated_path)
if file_size > MAX_FILE_SIZE:
return {
"ok": False,
"error": f"File exceeds 50MB limit ({file_size} bytes)",
}
content_type = _get_content_type(validated_path) or "application/octet-stream"
filename = os.path.basename(validated_path)
create_result = await outline_client.call(
"attachments.create",
{
"name": filename,
"documentId": document_id,
"size": file_size,
"contentType": content_type,
},
)
if not create_result.get("ok"):
return create_result
data = create_result.get("data", {})
upload_url = data.get("uploadUrl")
attachment_url = data.get("url") or data.get("attachmentUrl")
if not upload_url:
return {
"ok": False,
"error": "No uploadUrl returned from attachments.create",
}
upload_result = await outline_client.upload_to_presigned_url(
upload_url, validated_path, content_type
)
if not upload_result.get("ok"):
return upload_result
if not attachment_url:
return {
"ok": False,
"error": "Attachment created and uploaded, but no public URL was returned",
}
return {
"ok": True,
"attachment_url": attachment_url,
"name": filename,
"size": file_size,
"content_type": content_type,
}
@mcp.tool()
async def attach_file_to_document(document_id: str, file_path: str) -> str:
"""Upload a local file as an attachment to an existing Outline document.
The file must be located under /tmp. Files larger than 50MB are rejected.
Args:
document_id: The document UUID to attach the file to
file_path: Absolute path to the file (must be under /tmp)
Returns:
JSON string with attachment URL and Markdown ready for insertion
"""
result = await _upload_attachment(document_id, file_path)
if not result.get("ok"):
return json.dumps(result, indent=2)
result["markdown"] = f"[{result['name']}]({result['attachment_url']})"
return json.dumps(result, indent=2)
@mcp.tool()
async def upload_image_to_document(
image_path: str, document_id: str, alt_text: str = None
) -> str:
"""Upload an image file as an attachment and return Markdown embed syntax.
The file must be located under /tmp. Files larger than 50MB are rejected.
Args:
image_path: Absolute path to the image (must be under /tmp)
document_id: The document UUID to attach the image to
alt_text: Optional alt text for the image (defaults to filename)
Returns:
JSON string with image URL and Markdown embed ready for insertion
"""
result = await _upload_attachment(document_id, image_path)
if not result.get("ok"):
return json.dumps(result, indent=2)
alt = alt_text or result["name"]
result["markdown"] = f"![{alt}]({result['attachment_url']})"
return json.dumps(result, indent=2)
@mcp.tool()
async def import_file_to_outline(
file_path: str, collection_id: str, parent_document_id: str = None
) -> str:
"""Import a local file (Markdown, text, JSON, CSV) as a new Outline document.
The file must be located under /tmp. Files larger than 50MB are rejected.
Args:
file_path: Absolute path to the file (must be under /tmp)
collection_id: The collection UUID to create the document in
parent_document_id: Optional parent document UUID for nesting
Returns:
JSON string with the newly created document metadata
"""
try:
validated_path = _validate_file_path(file_path)
except ValueError as e:
return json.dumps({"ok": False, "error": str(e)}, indent=2)
file_size = os.path.getsize(validated_path)
if file_size > MAX_FILE_SIZE:
return json.dumps(
{
"ok": False,
"error": f"File exceeds 50MB limit ({file_size} bytes)",
},
indent=2,
)
fields = {"collectionId": collection_id}
if parent_document_id:
fields["parentDocumentId"] = parent_document_id
result = await outline_client.call_multipart(
"documents.import", validated_path, fields
)
return json.dumps(result, indent=2)
# --- API Reference Resource ---
API_REFERENCE = """# Outline API Reference
@@ -553,17 +786,30 @@ async def health(request):
return JSONResponse(response)
@asynccontextmanager
async def app_lifespan(app):
"""Manage application lifespan, closing the Outline client on shutdown."""
yield
await outline_client.close()
# --- ASGI Application ---
def create_app():
"""Create the ASGI application with health check and MCP routes."""
mcp_app = mcp.http_app()
@asynccontextmanager
async def combined_lifespan(app):
async with mcp_app.lifespan(app):
async with app_lifespan(app):
yield
routes = [
Route("/health", health, methods=["GET"]),
Mount("/", app=mcp_app),
]
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
return Starlette(routes=routes, lifespan=combined_lifespan)
# Create the app instance for uvicorn
+434
View File
@@ -0,0 +1,434 @@
import os
import sys
import json
import pytest
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
# Set env vars before importing server
os.environ["OUTLINE_API_URL"] = "https://test.example.com"
os.environ["OUTLINE_API_TOKEN"] = "test-token"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server import (
_validate_file_path,
_get_content_type,
OutlineClient,
attach_file_to_document,
upload_image_to_document,
import_file_to_outline,
outline_client,
MAX_FILE_SIZE,
)
class TestValidateFilePath:
def test_valid_file_under_tmp(self, tmp_path):
test_file = tmp_path / "test.txt"
test_file.write_text("hello")
result = _validate_file_path(str(test_file))
assert result == str(test_file)
def test_path_outside_tmp_rejected(self):
with pytest.raises(ValueError, match="must be within"):
_validate_file_path("/etc/passwd")
def test_path_traversal_rejected(self):
with pytest.raises(ValueError, match="must be within"):
_validate_file_path("/tmp/../../etc/passwd")
def test_nonexistent_file_rejected(self, tmp_path):
with pytest.raises(ValueError, match="File not found"):
_validate_file_path(str(tmp_path / "nonexistent.txt"))
def test_directory_rejected(self, tmp_path):
with pytest.raises(ValueError, match="not a file"):
_validate_file_path(str(tmp_path))
def test_symlink_traversal_rejected(self, tmp_path):
evil_link = tmp_path / "evil"
evil_link.symlink_to("/etc/passwd")
with pytest.raises(ValueError, match="must be within"):
_validate_file_path(str(evil_link))
class TestGetContentType:
def test_known_image_types(self):
assert _get_content_type("test.png") == "image/png"
assert _get_content_type("test.jpg") == "image/jpeg"
assert _get_content_type("test.jpeg") == "image/jpeg"
def test_known_document_types(self):
assert _get_content_type("test.pdf") == "application/pdf"
assert _get_content_type("test.md") == "text/markdown"
assert _get_content_type("test.txt") == "text/plain"
def test_unknown_type_returns_none(self):
assert _get_content_type("test.unknownxyz") is None
@pytest.mark.asyncio
class TestOutlineClientUpload:
async def test_upload_to_presigned_url_success(self):
client = OutlineClient("https://api.example.com", "token")
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.status_code = 200
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.put = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(
return_value=mock_client
)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as f:
f.write("test content")
temp_path = f.name
try:
result = await client.upload_to_presigned_url(
"https://upload.example.com", temp_path, "text/plain"
)
assert result["ok"] is True
finally:
os.unlink(temp_path)
async def test_upload_to_presigned_url_http_error(self):
client = OutlineClient("https://api.example.com", "token")
mock_response = MagicMock()
mock_response.status_code = 403
mock_response.text = "Forbidden"
from httpx import HTTPStatusError
mock_response.raise_for_status.side_effect = HTTPStatusError(
"Forbidden", request=MagicMock(), response=mock_response
)
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.put = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(
return_value=mock_client
)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=False)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as f:
f.write("test content")
temp_path = f.name
try:
result = await client.upload_to_presigned_url(
"https://upload.example.com", temp_path, "text/plain"
)
assert result["ok"] is False
assert result["status"] == 403
finally:
os.unlink(temp_path)
async def test_call_multipart_success(self):
client = OutlineClient("https://api.example.com", "token")
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = MagicMock(
return_value={"ok": True, "data": {"id": "doc-123"}}
)
mock_response.status_code = 200
client._client = MagicMock()
client._client.post = AsyncMock(return_value=mock_response)
client._client.is_closed = False
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as f:
f.write("test content")
temp_path = f.name
try:
result = await client.call_multipart(
"documents.import", temp_path, {"collectionId": "col-123"}
)
assert result["ok"] is True
assert result["data"]["id"] == "doc-123"
finally:
os.unlink(temp_path)
async def test_call_multipart_http_error(self):
client = OutlineClient("https://api.example.com", "token")
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = '{"message": "Bad request"}'
mock_response.json = MagicMock(return_value={"message": "Bad request"})
from httpx import HTTPStatusError
mock_response.raise_for_status.side_effect = HTTPStatusError(
"Bad request", request=MagicMock(), response=mock_response
)
client._client = MagicMock()
client._client.post = AsyncMock(return_value=mock_response)
client._client.is_closed = False
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as f:
f.write("test content")
temp_path = f.name
try:
result = await client.call_multipart(
"documents.import", temp_path, {"collectionId": "col-123"}
)
assert result["ok"] is False
assert result["error"] == "Bad request"
assert result["status"] == 400
finally:
os.unlink(temp_path)
@pytest.mark.asyncio
class TestAttachFileToDocument:
async def test_path_validation_failure(self):
result = await attach_file_to_document("doc-123", "/etc/passwd")
parsed = json.loads(result)
assert parsed["ok"] is False
assert "must be within" in parsed["error"]
async def test_size_limit_failure(self, tmp_path, monkeypatch):
test_file = tmp_path / "large.bin"
test_file.write_bytes(b"x" * 100)
monkeypatch.setattr("server.MAX_FILE_SIZE", 50)
result = await attach_file_to_document("doc-123", str(test_file))
parsed = json.loads(result)
assert parsed["ok"] is False
assert "exceeds 50MB" in parsed["error"]
async def test_successful_attachment(self, tmp_path, monkeypatch):
test_file = tmp_path / "test.txt"
test_file.write_text("hello world")
async def mock_call(method, params):
assert method == "attachments.create"
return {
"ok": True,
"data": {
"uploadUrl": "https://presigned.example.com/upload",
"url": "https://cdn.example.com/attachment.txt",
},
}
async def mock_upload(url, path, ct):
assert url == "https://presigned.example.com/upload"
return {"ok": True}
monkeypatch.setattr(outline_client, "call", mock_call)
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
result = await attach_file_to_document("doc-123", str(test_file))
parsed = json.loads(result)
assert parsed["ok"] is True
assert parsed["name"] == "test.txt"
assert parsed["markdown"] == "[test.txt](https://cdn.example.com/attachment.txt)"
async def test_attachment_create_failure(self, tmp_path, monkeypatch):
test_file = tmp_path / "test.txt"
test_file.write_text("hello")
async def mock_call(method, params):
return {"ok": False, "error": "Document not found"}
monkeypatch.setattr(outline_client, "call", mock_call)
result = await attach_file_to_document("doc-123", str(test_file))
parsed = json.loads(result)
assert parsed["ok"] is False
assert "Document not found" in parsed["error"]
async def test_upload_failure(self, tmp_path, monkeypatch):
test_file = tmp_path / "test.txt"
test_file.write_text("hello")
async def mock_call(method, params):
return {
"ok": True,
"data": {"uploadUrl": "https://presigned.example.com/upload"},
}
async def mock_upload(url, path, ct):
return {"ok": False, "error": "Upload rejected", "status": 413}
monkeypatch.setattr(outline_client, "call", mock_call)
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
result = await attach_file_to_document("doc-123", str(test_file))
parsed = json.loads(result)
assert parsed["ok"] is False
assert parsed["error"] == "Upload rejected"
async def test_missing_attachment_url(self, tmp_path, monkeypatch):
"""If Outline returns uploadUrl but no public url, we should error."""
test_file = tmp_path / "test.txt"
test_file.write_text("hello")
async def mock_call(method, params):
return {
"ok": True,
"data": {"uploadUrl": "https://presigned.example.com/upload"},
}
async def mock_upload(url, path, ct):
return {"ok": True}
monkeypatch.setattr(outline_client, "call", mock_call)
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
result = await attach_file_to_document("doc-123", str(test_file))
parsed = json.loads(result)
assert parsed["ok"] is False
assert "no public URL" in parsed["error"]
@pytest.mark.asyncio
class TestUploadImageToDocument:
async def test_path_validation_failure(self):
result = await upload_image_to_document("/etc/passwd", "doc-123")
parsed = json.loads(result)
assert parsed["ok"] is False
async def test_size_limit_failure(self, tmp_path, monkeypatch):
test_file = tmp_path / "large.png"
test_file.write_bytes(b"x" * 100)
monkeypatch.setattr("server.MAX_FILE_SIZE", 50)
result = await upload_image_to_document(str(test_file), "doc-123")
parsed = json.loads(result)
assert parsed["ok"] is False
assert "exceeds 50MB" in parsed["error"]
async def test_successful_image_upload(self, tmp_path, monkeypatch):
test_file = tmp_path / "test.png"
test_file.write_text("fake png content")
async def mock_call(method, params):
return {
"ok": True,
"data": {
"uploadUrl": "https://presigned.example.com/upload",
"url": "https://cdn.example.com/image.png",
},
}
async def mock_upload(url, path, ct):
return {"ok": True}
monkeypatch.setattr(outline_client, "call", mock_call)
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
result = await upload_image_to_document(str(test_file), "doc-123", "My Alt")
parsed = json.loads(result)
assert parsed["ok"] is True
assert parsed["markdown"] == "![My Alt](https://cdn.example.com/image.png)"
async def test_default_alt_text(self, tmp_path, monkeypatch):
test_file = tmp_path / "screenshot.png"
test_file.write_text("fake png content")
async def mock_call(method, params):
return {
"ok": True,
"data": {
"uploadUrl": "https://presigned.example.com/upload",
"url": "https://cdn.example.com/image.png",
},
}
async def mock_upload(url, path, ct):
return {"ok": True}
monkeypatch.setattr(outline_client, "call", mock_call)
monkeypatch.setattr(outline_client, "upload_to_presigned_url", mock_upload)
result = await upload_image_to_document(str(test_file), "doc-123")
parsed = json.loads(result)
assert parsed["ok"] is True
assert parsed["markdown"] == "![screenshot.png](https://cdn.example.com/image.png)"
@pytest.mark.asyncio
class TestImportFileToOutline:
async def test_path_validation_failure(self):
result = await import_file_to_outline("/etc/passwd", "col-123")
parsed = json.loads(result)
assert parsed["ok"] is False
assert "must be within" in parsed["error"]
async def test_size_limit_failure(self, tmp_path, monkeypatch):
test_file = tmp_path / "large.md"
test_file.write_bytes(b"x" * 100)
monkeypatch.setattr("server.MAX_FILE_SIZE", 50)
result = await import_file_to_outline(str(test_file), "col-123")
parsed = json.loads(result)
assert parsed["ok"] is False
assert "exceeds 50MB" in parsed["error"]
async def test_successful_import(self, tmp_path, monkeypatch):
test_file = tmp_path / "notes.md"
test_file.write_text("# My Notes\n\nHello world")
async def mock_call_multipart(method, path, fields):
assert method == "documents.import"
assert fields["collectionId"] == "col-123"
return {"ok": True, "data": {"id": "doc-456", "url": "https://outline.example.com/doc/doc-456"}}
monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart)
result = await import_file_to_outline(str(test_file), "col-123")
parsed = json.loads(result)
assert parsed["ok"] is True
assert parsed["data"]["id"] == "doc-456"
async def test_import_with_parent_document(self, tmp_path, monkeypatch):
test_file = tmp_path / "notes.md"
test_file.write_text("# Child Doc")
captured_fields = {}
async def mock_call_multipart(method, path, fields):
captured_fields.update(fields)
return {"ok": True, "data": {"id": "doc-789"}}
monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart)
result = await import_file_to_outline(
str(test_file), "col-123", parent_document_id="parent-999"
)
parsed = json.loads(result)
assert parsed["ok"] is True
assert captured_fields["parentDocumentId"] == "parent-999"
async def test_import_failure(self, tmp_path, monkeypatch):
test_file = tmp_path / "notes.md"
test_file.write_text("# Notes")
async def mock_call_multipart(method, path, fields):
return {"ok": False, "error": "Collection not found"}
monkeypatch.setattr(outline_client, "call_multipart", mock_call_multipart)
result = await import_file_to_outline(str(test_file), "col-123")
parsed = json.loads(result)
assert parsed["ok"] is False
assert "Collection not found" in parsed["error"]