diff --git a/server.py b/server.py index f041e75..c7edc94 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,6 @@ import os import json +import base64 import logging import mimetypes import contextlib @@ -227,6 +228,120 @@ async def outline_api_call(method: str, params: Any = None) -> str: return json.dumps(result, indent=2) +@mcp.tool() +async def upload_file_attachment( + document_id: str, + filename: str, + content_base64: str, + content_type: str = "", +) -> str: + """Upload a file attachment to an Outline document. + + Accepts base64-encoded file content, uploads it to Outline's storage, + and returns metadata including ready-to-embed markdown. + + To embed the attachment in the document body, use the returned + embedMarkdown value with documents.update (append: true). + + Args: + document_id: The UUID of the destination document + filename: The filename including extension (e.g., 'report.pdf', 'screenshot.png') + content_base64: The file content encoded as a base64 string + content_type: Optional MIME type (e.g., 'application/pdf'). Auto-detected from filename if omitted. + + Returns: + JSON with attachment id, url, size, and embedMarkdown for inserting into a document + """ + try: + file_bytes = base64.b64decode(content_base64) + except Exception as e: + return json.dumps({"ok": False, "error": f"Invalid base64 content: {e}"}) + + size = len(file_bytes) + MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "104857600")) + if size > MAX_UPLOAD_SIZE: + return json.dumps({"ok": False, "error": f"File too large ({size} bytes, limit {MAX_UPLOAD_SIZE})"}) + if size == 0: + return json.dumps({"ok": False, "error": "File content is empty"}) + + if not content_type: + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + + create_response = await outline_client.call("attachments.create", { + "name": filename, + "documentId": document_id, + "size": size, + "contentType": content_type, + }) + + if not create_response.get("ok"): + return json.dumps({ + "ok": False, + "error": f"attachments.create failed: {create_response.get('error')}", + }) + + response_data = create_response.get("data", {}) + upload_url = response_data.get("uploadUrl", "") + form_fields = response_data.get("form", {}) + attachment_data = response_data.get("attachment", {}) + + if not upload_url: + return json.dumps({"ok": False, "error": "Outline did not return an uploadUrl"}) + + if upload_url.startswith("/"): + upload_url = f"{OUTLINE_API_URL}{upload_url}" + + try: + if form_fields: + files = {"file": (filename, file_bytes, content_type)} + async with httpx.AsyncClient() as client: + resp = await client.post( + upload_url, + data=form_fields, + files=files, + headers={"Authorization": f"Bearer {OUTLINE_API_TOKEN}"}, + timeout=600.0, + ) + resp.raise_for_status() + else: + async with httpx.AsyncClient() as client: + resp = await client.put( + upload_url, + content=file_bytes, + headers={ + "Content-Type": content_type, + "Content-Length": str(size), + }, + timeout=600.0, + ) + resp.raise_for_status() + except Exception as e: + att_id = attachment_data.get("id") + if att_id: + try: + await outline_client.call("attachments.delete", {"id": att_id}) + except Exception: + pass + return json.dumps({"ok": False, "error": f"Storage upload failed: {e}"}) + + att_name = attachment_data.get("name", filename) + att_size = attachment_data.get("size", size) + att_url = attachment_data.get("url", "") + embed_markdown = f"[{att_name} {att_size}]({att_url})" + + return json.dumps({ + "ok": True, + "data": { + "id": attachment_data.get("id"), + "name": att_name, + "size": att_size, + "contentType": attachment_data.get("contentType", content_type), + "url": att_url, + "embedMarkdown": embed_markdown, + }, + }, indent=2) + + # --- API Reference Resource --- API_REFERENCE = """# Outline API Reference @@ -487,6 +602,7 @@ Delete file operation. Params: id (required) ### attachments.create Create attachment upload URL. Params: name (required), documentId (required), size (required), contentType (required) +Note: This only registers metadata. Use the upload_file_attachment tool instead for a complete upload. ### attachments.delete Delete attachment. Params: id (required) @@ -494,17 +610,23 @@ 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. +### Uploading Files (Preferred Method) +Use the `upload_file_attachment` MCP tool to upload files. It handles the full workflow: +1. Registers the attachment with Outline +2. Uploads file content to storage (S3 or local) +3. Returns embedMarkdown ready to insert into a document + +Example workflow to attach a file to a document: +1. Call upload_file_attachment(document_id, filename, content_base64) +2. Get embedMarkdown from the response +3. Call documents.update with append=true and text=embedMarkdown + +### Embedding Attachments in Document Markdown +Outline renders attachment cards using this specific markdown syntax: + [filename.ext filesize](/api/attachments.redirect?id=UUID) +Where filesize is the size in bytes. The upload_file_attachment tool returns this +as the embedMarkdown field. Always use the relative /api/attachments.redirect path, +not absolute URLs, for Outline to render the attachment card UI. ---