5.0 KiB
5.0 KiB
Attachment Upload Feature Design
Date: 2026-01-03 Status: Approved
Summary
Add an upload_attachment MCP tool to upload files to Grist documents and receive an attachment ID for linking to records.
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Content encoding | Base64 string | MCP tools use JSON; binary must be encoded |
| Batch support | Single file only | YAGNI; caller can loop if needed |
| Linking behavior | Upload only, return ID | Single responsibility; use existing update_records to link |
| Download support | Not included | YAGNI; can add later if needed |
| Permission level | Write | Attachments are data, not schema |
| Proxy support | MCP tool only | Reduces scope; scripts can use Grist API directly |
Tool Interface
Input Schema
{
"type": "object",
"properties": {
"document": {
"type": "string",
"description": "Document name"
},
"filename": {
"type": "string",
"description": "Filename with extension (e.g., 'invoice.pdf')"
},
"content_base64": {
"type": "string",
"description": "File content as base64-encoded string"
},
"content_type": {
"type": "string",
"description": "MIME type (optional, auto-detected from filename if omitted)"
}
},
"required": ["document", "filename", "content_base64"]
}
Response
{
"attachment_id": 42,
"filename": "invoice.pdf",
"size_bytes": 30720
}
Usage Example
# 1. Upload attachment
result = upload_attachment(
document="accounting",
filename="Invoice-001.pdf",
content_base64="JVBERi0xLjQK..."
)
# 2. Link to record via existing update_records tool
update_records("Bills", [{
"id": 1,
"fields": {"Attachment": [result["attachment_id"]]}
}])
Implementation
Files to Modify
src/grist_mcp/grist_client.py- Addupload_attachment()methodsrc/grist_mcp/tools/write.py- Add tool functionsrc/grist_mcp/server.py- Register tool
GristClient Method
async def upload_attachment(
self,
filename: str,
content: bytes,
content_type: str | None = None
) -> dict:
"""Upload a file attachment. Returns attachment metadata."""
if content_type is None:
content_type = "application/octet-stream"
files = {"upload": (filename, content, content_type)}
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self._base_url}/attachments",
headers=self._headers,
files=files,
)
response.raise_for_status()
# Grist returns list of attachment IDs
attachment_ids = response.json()
return {
"attachment_id": attachment_ids[0],
"filename": filename,
"size_bytes": len(content),
}
Tool Function
import base64
import mimetypes
async def upload_attachment(
agent: Agent,
auth: Authenticator,
document: str,
filename: str,
content_base64: str,
content_type: str | None = None,
client: GristClient | None = None,
) -> dict:
"""Upload a file attachment to a document."""
auth.authorize(agent, document, Permission.WRITE)
# Decode base64
try:
content = base64.b64decode(content_base64)
except Exception:
raise ValueError("Invalid base64 encoding")
# Auto-detect MIME type if not provided
if content_type is None:
content_type, _ = mimetypes.guess_type(filename)
if content_type is None:
content_type = "application/octet-stream"
if client is None:
doc = auth.get_document(document)
client = GristClient(doc)
return await client.upload_attachment(filename, content, content_type)
Error Handling
| Error | Cause | Response |
|---|---|---|
| Invalid base64 | Malformed content_base64 | ValueError: Invalid base64 encoding |
| Authorization | Agent lacks write permission | AuthError (existing pattern) |
| Grist API error | Upload fails | httpx.HTTPStatusError (existing pattern) |
Testing
Unit Tests
tests/unit/test_tools_write.py:
test_upload_attachment_success- Valid base64, returns attachment_idtest_upload_attachment_invalid_base64- Raises ValueErrortest_upload_attachment_auth_required- Verifies write permission checktest_upload_attachment_mime_detection- Auto-detects type from filename
tests/unit/test_grist_client.py:
test_upload_attachment_api_call- Correct multipart request formattest_upload_attachment_with_explicit_content_type- Passes through MIME type
Mock Approach
Mock httpx.AsyncClient responses; no Grist server needed for unit tests.
Future Considerations
Not included in this implementation (YAGNI):
- Batch upload (multiple files)
- Download attachment
- Proxy API support
- Size limit validation (rely on Grist's limits)
These can be added if real use cases emerge.