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
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:
@@ -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""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user