diff --git a/src/grist_mcp/grist_client.py b/src/grist_mcp/grist_client.py index d4ad7d8..8fc4ad4 100644 --- a/src/grist_mcp/grist_client.py +++ b/src/grist_mcp/grist_client.py @@ -149,6 +149,38 @@ class GristClient: "size_bytes": len(content), } + async def download_attachment(self, attachment_id: int) -> dict: + """Download an attachment by ID. + + Args: + attachment_id: The ID of the attachment to download. + + Returns: + Dict with content (bytes), content_type, and filename. + """ + import re + + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.get( + f"{self._base_url}/attachments/{attachment_id}/download", + headers=self._headers, + ) + response.raise_for_status() + + # Extract filename from Content-Disposition header + content_disp = response.headers.get("content-disposition", "") + filename = None + if "filename=" in content_disp: + match = re.search(r'filename="?([^";]+)"?', content_disp) + if match: + filename = match.group(1) + + return { + "content": response.content, + "content_type": response.headers.get("content-type", "application/octet-stream"), + "filename": filename, + } + # Schema operations async def create_table(self, table_id: str, columns: list[dict]) -> str: diff --git a/src/grist_mcp/main.py b/src/grist_mcp/main.py index 7376cc5..f87d1aa 100644 --- a/src/grist_mcp/main.py +++ b/src/grist_mcp/main.py @@ -363,6 +363,67 @@ def create_app(config: Config): "code": "GRIST_ERROR", }) + async def handle_attachment_download( + scope: Scope, receive: Receive, send: Send, attachment_id: int + ) -> None: + """Handle attachment download by ID.""" + # Extract token + token = _get_bearer_token(scope) + if not token: + await send_json_response(send, 401, { + "success": False, + "error": "Missing Authorization header", + "code": "INVALID_TOKEN", + }) + return + + # Validate session token + session = token_manager.validate_token(token) + if session is None: + await send_json_response(send, 401, { + "success": False, + "error": "Invalid or expired token", + "code": "TOKEN_EXPIRED", + }) + return + + # Check read permission + if "read" not in session.permissions: + await send_json_response(send, 403, { + "success": False, + "error": "Read permission required for attachment download", + "code": "UNAUTHORIZED", + }) + return + + # Download from Grist + try: + doc = auth.get_document(session.document) + client = GristClient(doc) + result = await client.download_attachment(attachment_id) + + # Build response headers + headers = [[b"content-type", result["content_type"].encode()]] + if result["filename"]: + disposition = f'attachment; filename="{result["filename"]}"' + headers.append([b"content-disposition", disposition.encode()]) + + await send({ + "type": "http.response.start", + "status": 200, + "headers": headers, + }) + await send({ + "type": "http.response.body", + "body": result["content"], + }) + except Exception as e: + await send_json_response(send, 500, { + "success": False, + "error": str(e), + "code": "GRIST_ERROR", + }) + async def app(scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": return @@ -380,6 +441,17 @@ def create_app(config: Config): await handle_proxy(scope, receive, send) elif path == "/api/v1/attachments" and method == "POST": await handle_attachments(scope, receive, send) + elif path.startswith("/api/v1/attachments/") and method == "GET": + # Parse attachment ID from path: /api/v1/attachments/{id} + try: + attachment_id = int(path.split("/")[-1]) + await handle_attachment_download(scope, receive, send, attachment_id) + except ValueError: + await send_json_response(send, 400, { + "success": False, + "error": "Invalid attachment ID", + "code": "INVALID_REQUEST", + }) else: await handle_not_found(scope, receive, send) diff --git a/src/grist_mcp/tools/session.py b/src/grist_mcp/tools/session.py index 3b5066f..deeb4e5 100644 --- a/src/grist_mcp/tools/session.py +++ b/src/grist_mcp/tools/session.py @@ -8,9 +8,10 @@ PROXY_DOCUMENTATION = { "description": "HTTP proxy API for bulk data operations. Use request_session_token to get a short-lived token, then call the proxy endpoint directly from scripts.", "endpoints": { "proxy": "POST /api/v1/proxy - JSON operations (CRUD, schema)", - "attachments": "POST /api/v1/attachments - File uploads (multipart/form-data)", + "attachments_upload": "POST /api/v1/attachments - File uploads (multipart/form-data)", + "attachments_download": "GET /api/v1/attachments/{id} - File downloads (binary response)", }, - "endpoint_note": "The full URL is returned in the 'proxy_url' field of request_session_token response. Replace /proxy with /attachments for file uploads.", + "endpoint_note": "The full URL is returned in the 'proxy_url' field of request_session_token response. Replace /proxy with /attachments for file operations.", "authentication": "Bearer token in Authorization header", "attachment_upload": { "endpoint": "POST /api/v1/attachments", @@ -27,6 +28,20 @@ response = requests.post( ) attachment_id = response.json()['data']['attachment_id'] # Link to record: update_records with {'Attachment': [attachment_id]}""", + }, + "attachment_download": { + "endpoint": "GET /api/v1/attachments/{attachment_id}", + "permission": "read", + "description": "Download attachment by ID. Returns binary content with appropriate Content-Type and Content-Disposition headers.", + "response_headers": ["Content-Type", "Content-Disposition"], + "example_curl": "curl -H 'Authorization: Bearer TOKEN' URL/api/v1/attachments/42 -o file.pdf", + "example_python": """import requests +response = requests.get( + f'{base_url}/api/v1/attachments/42', + headers={'Authorization': f'Bearer {token}'} +) +with open('downloaded.pdf', 'wb') as f: + f.write(response.content)""", }, "request_format": { "method": "Operation name (required)", diff --git a/tests/unit/test_grist_client.py b/tests/unit/test_grist_client.py index 2ece984..962d197 100644 --- a/tests/unit/test_grist_client.py +++ b/tests/unit/test_grist_client.py @@ -236,3 +236,59 @@ async def test_upload_attachment_default_content_type(client, httpx_mock: HTTPXM assert result["attachment_id"] == 99 assert result["size_bytes"] == 3 + + +@pytest.mark.asyncio +async def test_download_attachment(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="https://grist.example.com/api/docs/abc123/attachments/42/download", + method="GET", + content=b"PDF content here", + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="invoice.pdf"', + }, + ) + + result = await client.download_attachment(42) + + assert result["content"] == b"PDF content here" + assert result["content_type"] == "application/pdf" + assert result["filename"] == "invoice.pdf" + + +@pytest.mark.asyncio +async def test_download_attachment_no_filename(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="https://grist.example.com/api/docs/abc123/attachments/99/download", + method="GET", + content=b"binary data", + headers={ + "content-type": "application/octet-stream", + }, + ) + + result = await client.download_attachment(99) + + assert result["content"] == b"binary data" + assert result["content_type"] == "application/octet-stream" + assert result["filename"] is None + + +@pytest.mark.asyncio +async def test_download_attachment_unquoted_filename(client, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="https://grist.example.com/api/docs/abc123/attachments/55/download", + method="GET", + content=b"image data", + headers={ + "content-type": "image/png", + "content-disposition": "attachment; filename=photo.png", + }, + ) + + result = await client.download_attachment(55) + + assert result["content"] == b"image data" + assert result["content_type"] == "image/png" + assert result["filename"] == "photo.png" diff --git a/tests/unit/test_tools_session.py b/tests/unit/test_tools_session.py index 86a22f3..4c02e76 100644 --- a/tests/unit/test_tools_session.py +++ b/tests/unit/test_tools_session.py @@ -41,12 +41,14 @@ async def test_get_proxy_documentation_returns_complete_spec(): assert "description" in result assert "endpoints" in result assert "proxy" in result["endpoints"] - assert "attachments" in result["endpoints"] + assert "attachments_upload" in result["endpoints"] + assert "attachments_download" in result["endpoints"] assert "authentication" in result assert "methods" in result assert "add_records" in result["methods"] assert "get_records" in result["methods"] assert "attachment_upload" in result + assert "attachment_download" in result assert "example_script" in result diff --git a/uv.lock b/uv.lock index 2a5cfba..3ce6b57 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ wheels = [ [[package]] name = "grist-mcp" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "httpx" },