Add HTTP URL-based attachment serving for Gmail attachments

This commit implements a new feature that allows Gmail attachments to be
served via HTTP URLs instead of returning base64-encoded data in the tool
response. This avoids consuming LLM context window space and token budgets
for large attachments.

Architecture:
-------------
The implementation works in both stdio and streamable-http transport modes:

1. Temp File Storage (core/attachment_storage.py):
   - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/
   - Uses UUID-based file IDs to prevent guessing/unauthorized access
   - Tracks metadata: filename, mime type, size, creation/expiration times
   - Files expire after 1 hour (configurable) with automatic cleanup support
   - Handles base64 decoding and file writing

2. HTTP Route Handlers:
   - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode)
   - Added same route to MinimalOAuthServer (stdio mode)
   - Both routes serve files with proper Content-Type headers via FileResponse
   - Returns 404 for expired or missing attachments

3. Modified get_gmail_attachment_content():
   - Now saves attachments to temp storage and returns HTTP URL
   - Attempts to fetch filename/mimeType from message metadata (best effort)
   - Handles stateless mode gracefully (skips file saving, shows preview)
   - Falls back to base64 preview if file saving fails
   - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups

Key Features:
-------------
- Works in both stdio and streamable-http modes (uses existing HTTP servers)
- Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true)
- Secure: UUID-based file IDs prevent unauthorized access
- Automatic expiration: Files cleaned up after 1 hour
- Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured
- Graceful degradation: Falls back to preview if storage fails

Benefits:
---------
- Avoids context window bloat: Large attachments don't consume LLM tokens
- Better performance: Clients can stream/download files directly
- More efficient: No need to decode base64 in client applications
- Works across network boundaries: URLs accessible from any client

The feature maintains backward compatibility - if file saving fails or stateless
mode is enabled, the function falls back to showing a base64 preview.
This commit is contained in:
Josh Dzielak
2025-11-29 15:06:57 +01:00
parent 0402b1a0b8
commit ee1db221af
4 changed files with 360 additions and 13 deletions

View File

@@ -2,7 +2,7 @@ import logging
from typing import List, Optional
from importlib import metadata
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.middleware import Middleware
@@ -156,6 +156,33 @@ async def health_check(request: Request):
"transport": get_transport_mode()
})
@server.custom_route("/attachments/{file_id}", methods=["GET"])
async def serve_attachment(file_id: str, request: Request):
"""Serve a stored attachment file."""
from core.attachment_storage import get_attachment_storage
storage = get_attachment_storage()
metadata = storage.get_attachment_metadata(file_id)
if not metadata:
return JSONResponse(
{"error": "Attachment not found or expired"},
status_code=404
)
file_path = storage.get_attachment_path(file_id)
if not file_path:
return JSONResponse(
{"error": "Attachment file not found"},
status_code=404
)
return FileResponse(
path=str(file_path),
filename=metadata["filename"],
media_type=metadata["mime_type"]
)
async def legacy_oauth2_callback(request: Request) -> HTMLResponse:
state = request.query_params.get("state")
code = request.query_params.get("code")