Compare commits
2 Commits
v1.3.0
...
v1.4.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| c868e8a7fa | |||
| 734cc0a525 |
33
CHANGELOG.md
33
CHANGELOG.md
@@ -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/),
|
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).
|
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
|
## [1.3.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "grist-mcp"
|
name = "grist-mcp"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
description = "MCP server for AI agents to interact with Grist documents"
|
description = "MCP server for AI agents to interact with Grist documents"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -149,6 +149,38 @@ class GristClient:
|
|||||||
"size_bytes": len(content),
|
"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
|
# Schema operations
|
||||||
|
|
||||||
async def create_table(self, table_id: str, columns: list[dict]) -> str:
|
async def create_table(self, table_id: str, columns: list[dict]) -> str:
|
||||||
|
|||||||
@@ -363,6 +363,67 @@ def create_app(config: Config):
|
|||||||
"code": "GRIST_ERROR",
|
"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:
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
if scope["type"] != "http":
|
if scope["type"] != "http":
|
||||||
return
|
return
|
||||||
@@ -380,6 +441,17 @@ def create_app(config: Config):
|
|||||||
await handle_proxy(scope, receive, send)
|
await handle_proxy(scope, receive, send)
|
||||||
elif path == "/api/v1/attachments" and method == "POST":
|
elif path == "/api/v1/attachments" and method == "POST":
|
||||||
await handle_attachments(scope, receive, send)
|
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:
|
else:
|
||||||
await handle_not_found(scope, receive, send)
|
await handle_not_found(scope, receive, send)
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"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": {
|
"endpoints": {
|
||||||
"proxy": "POST /api/v1/proxy - JSON operations (CRUD, schema)",
|
"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",
|
"authentication": "Bearer token in Authorization header",
|
||||||
"attachment_upload": {
|
"attachment_upload": {
|
||||||
"endpoint": "POST /api/v1/attachments",
|
"endpoint": "POST /api/v1/attachments",
|
||||||
@@ -27,6 +28,20 @@ response = requests.post(
|
|||||||
)
|
)
|
||||||
attachment_id = response.json()['data']['attachment_id']
|
attachment_id = response.json()['data']['attachment_id']
|
||||||
# Link to record: update_records with {'Attachment': [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": {
|
"request_format": {
|
||||||
"method": "Operation name (required)",
|
"method": "Operation name (required)",
|
||||||
|
|||||||
@@ -236,3 +236,59 @@ async def test_upload_attachment_default_content_type(client, httpx_mock: HTTPXM
|
|||||||
|
|
||||||
assert result["attachment_id"] == 99
|
assert result["attachment_id"] == 99
|
||||||
assert result["size_bytes"] == 3
|
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"
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ async def test_get_proxy_documentation_returns_complete_spec():
|
|||||||
assert "description" in result
|
assert "description" in result
|
||||||
assert "endpoints" in result
|
assert "endpoints" in result
|
||||||
assert "proxy" in result["endpoints"]
|
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 "authentication" in result
|
||||||
assert "methods" in result
|
assert "methods" in result
|
||||||
assert "add_records" in result["methods"]
|
assert "add_records" in result["methods"]
|
||||||
assert "get_records" in result["methods"]
|
assert "get_records" in result["methods"]
|
||||||
assert "attachment_upload" in result
|
assert "attachment_upload" in result
|
||||||
|
assert "attachment_download" in result
|
||||||
assert "example_script" in result
|
assert "example_script" in result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user