feat: add secure HTTP streaming upload gateway and address code review findings
Build and Push Outline MCP Docker Image / build (push) Successful in 11s

This commit is contained in:
2026-05-25 02:11:51 +00:00
parent d152162dbf
commit d637394c0e
3 changed files with 450 additions and 3 deletions
+189 -1
View File
@@ -1,10 +1,13 @@
import os
import json
import logging
import mimetypes
import contextlib
from typing import Any
import httpx
from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route, Mount
from dotenv import load_dotenv
@@ -491,6 +494,18 @@ Delete attachment. Params: id (required)
### attachments.redirect
Get attachment URL. Params: id (required)
### Custom HTTP Gateway Upload
Rather than manually performing the multi-step attachments.create + PUT process, you can stream files directly through the MCP server's built-in HTTP streaming gateway:
- **Endpoint**: `POST /upload`
- **Query Parameters**:
- `documentId` (required): The UUID of the destination Outline document.
- `name` (required): The filename of the attachment.
- **Headers**:
- `Content-Length` (required): Total size of the file in bytes.
- `Content-Type` (optional): The MIME type of the file (auto-detected if omitted).
- **Body**: Raw binary stream of the file content.
- **Response**: JSON containing the created attachment's details, including its public access `url` on success.
---
## Auth
@@ -532,6 +547,167 @@ def get_api_reference() -> str:
return API_REFERENCE
# --- Upload Endpoint ---
async def upload_endpoint(request: Request) -> JSONResponse:
"""HTTP streaming endpoint to upload file attachments to Outline.
This endpoint acts as a non-buffering gateway/proxy. It calls Outline's
attachments.create to register the metadata and obtain an upload URL,
then streams the incoming request body directly to S3.
"""
# 1. Authorization validation
auth_header = request.headers.get("Authorization")
if not auth_header or auth_header != f"Bearer {OUTLINE_API_TOKEN}":
return JSONResponse(
{"ok": False, "error": "Unauthorized"},
status_code=401,
)
document_id = request.query_params.get("documentId")
if not document_id:
return JSONResponse(
{"ok": False, "error": "Missing 'documentId' query parameter"},
status_code=400,
)
filename = request.query_params.get("name")
if not filename:
return JSONResponse(
{"ok": False, "error": "Missing 'name' query parameter"},
status_code=400,
)
# Size is required by attachments.create
content_length_str = request.headers.get("content-length")
if not content_length_str:
return JSONResponse(
{"ok": False, "error": "Missing 'Content-Length' header"},
status_code=400,
)
try:
size = int(content_length_str)
except ValueError:
return JSONResponse(
{"ok": False, "error": "Invalid 'Content-Length' header"},
status_code=400,
)
# Validate file size limits (abuses and negative boundary checks)
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "104857600")) # Default 100MB
if size < 0:
return JSONResponse(
{"ok": False, "error": "Invalid file size (must be non-negative)"},
status_code=400,
)
if size > MAX_UPLOAD_SIZE:
return JSONResponse(
{"ok": False, "error": f"File too large (exceeds limit of {MAX_UPLOAD_SIZE} bytes)"},
status_code=413,
)
# Detect content type
content_type = (
request.headers.get("content-type")
or mimetypes.guess_type(filename)[0]
or "application/octet-stream"
)
# 2. Initialize upload session in Outline
create_params = {
"name": filename,
"documentId": document_id,
"size": size,
"contentType": content_type,
}
logger.info(f"Initiating attachment creation in Outline: {create_params}")
create_response = await outline_client.call("attachments.create", create_params)
if not create_response.get("ok"):
return JSONResponse(
{
"ok": False,
"error": f"Failed to initialize attachment in Outline: {create_response.get('error')}",
},
status_code=502,
)
attachment_data = create_response.get("data", {})
upload_url = attachment_data.get("uploadUrl")
if not upload_url:
return JSONResponse(
{"ok": False, "error": "Outline API did not return an uploadUrl"},
status_code=502,
)
# 3. Stream the request body directly to S3 upload_url
try:
# Pre-signed S3 PUT URLs usually enforce exact headers
headers = {
"Content-Type": content_type,
"Content-Length": str(size),
}
logger.info(f"Streaming file content to storage (size={size} bytes)")
async with httpx.AsyncClient() as client:
put_response = await client.put(
upload_url,
content=request.stream(),
headers=headers,
timeout=600.0,
)
put_response.raise_for_status()
logger.info("Upload to storage completed successfully")
# 4. Return success and attachment metadata
return JSONResponse(
{
"ok": True,
"data": {
"id": attachment_data.get("id"),
"name": attachment_data.get("name"),
"size": attachment_data.get("size"),
"contentType": attachment_data.get("contentType"),
"url": attachment_data.get("url"),
},
}
)
except Exception as e:
# Clean up orphaned attachment record in Outline to maintain consistency
attachment_id = attachment_data.get("id")
if attachment_id:
logger.warning(
f"Cleaning up orphaned attachment {attachment_id} in Outline due to S3 upload failure"
)
try:
await outline_client.call("attachments.delete", {"id": attachment_id})
except Exception as cleanup_err:
logger.error(
f"Failed to delete orphaned attachment {attachment_id}: {str(cleanup_err)}"
)
if isinstance(e, httpx.HTTPStatusError):
logger.error(
f"Storage upload HTTP error: {e.response.status_code} - {e.response.text}"
)
return JSONResponse(
{
"ok": False,
"error": f"Failed to upload attachment to storage: {str(e)}",
},
status_code=502,
)
else:
logger.error(f"Exception during attachment upload: {str(e)}")
return JSONResponse(
{"ok": False, "error": f"Upload stream failed: {str(e)}"},
status_code=500,
)
# --- Health Check Endpoint ---
async def health(request):
"""Health check endpoint for Docker/load balancer.
@@ -558,12 +734,24 @@ def create_app():
"""Create the ASGI application with health check and MCP routes."""
mcp_app = mcp.http_app()
@contextlib.asynccontextmanager
async def lifespan(app: Starlette):
"""Custom ASGI lifespan manager that integrates with FastMCP's lifespan
and ensures the Outline HTTP client's connections are gracefully closed
on server shutdown.
"""
async with mcp_app.lifespan(app):
yield
logger.info("Closing Outline client connections on server shutdown")
await outline_client.close()
routes = [
Route("/health", health, methods=["GET"]),
Route("/upload", upload_endpoint, methods=["POST"]),
Mount("/", app=mcp_app),
]
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
return Starlette(routes=routes, lifespan=lifespan)
# Create the app instance for uvicorn