2 Commits

Author SHA1 Message Date
c868e8a7fa chore: bump version to 1.4.0 and update changelog
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2026-01-12 12:14:49 -05:00
734cc0a525 feat: add attachment download via proxy endpoint
Add GET /api/v1/attachments/{id} endpoint for downloading attachments
through the MCP proxy. This complements the existing upload endpoint and
enables complete attachment workflows via the proxy API.
2026-01-12 12:13:23 -05:00
8 changed files with 215 additions and 5 deletions

View File

@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.0] - 2026-01-12
### Added
#### Attachment Download via Proxy
- **`GET /api/v1/attachments/{id}`**: New HTTP endpoint for downloading attachments
- Returns binary content with appropriate `Content-Type` and `Content-Disposition` headers
- Requires read permission in session token
- Complements the existing upload endpoint for complete attachment workflows
#### Usage
```bash
# Get session token with read permission
TOKEN=$(curl -s ... | jq -r '.token')
# Download attachment
curl -H "Authorization: Bearer $TOKEN" \
https://example.com/api/v1/attachments/42 \
-o downloaded.pdf
```
```python
# Python example
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)
```
## [1.3.0] - 2026-01-03
### Added

View File

@@ -1,6 +1,6 @@
[project]
name = "grist-mcp"
version = "1.3.0"
version = "1.4.0"
description = "MCP server for AI agents to interact with Grist documents"
requires-python = ">=3.14"
dependencies = [

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)",

View File

@@ -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"

View File

@@ -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

2
uv.lock generated
View File

@@ -153,7 +153,7 @@ wheels = [
[[package]]
name = "grist-mcp"
version = "1.2.0"
version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },