diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cb13e..ae7f81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,26 +9,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -#### Attachment Upload -- **`upload_attachment` MCP tool**: Upload files to Grist documents -- Base64-encoded content input (required for JSON-based MCP protocol) +#### Attachment Upload via Proxy +- **`POST /api/v1/attachments`**: New HTTP endpoint for file uploads +- Uses `multipart/form-data` for efficient binary transfer (no base64 overhead) - Automatic MIME type detection from filename - Returns attachment ID for linking to records via `update_records` +- Requires write permission in session token #### Usage -```python -# 1. Upload attachment -result = upload_attachment( - document="accounting", - filename="invoice.pdf", - content_base64="JVBERi0xLjQK..." -) -# Returns: {"attachment_id": 42, "filename": "invoice.pdf", "size_bytes": 31395} +```bash +# Get session token with write permission +TOKEN=$(curl -s ... | jq -r '.token') -# 2. Link to record -update_records(document="accounting", table="Bills", records=[ - {"id": 1, "fields": {"Attachment": [42]}} -]) +# Upload file +curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@invoice.pdf" \ + https://example.com/api/v1/attachments + +# Returns: {"success": true, "data": {"attachment_id": 42, "filename": "invoice.pdf", "size_bytes": 31395}} +``` + +```python +# Python example +import requests + +response = requests.post( + f'{proxy_url.replace("/proxy", "/attachments")}', + headers={'Authorization': f'Bearer {token}'}, + files={'file': open('invoice.pdf', 'rb')} +) +attachment_id = response.json()['data']['attachment_id'] + +# Link to record via proxy +requests.post(proxy_url, headers={'Authorization': f'Bearer {token}'}, json={ + 'method': 'update_records', + 'table': 'Bills', + 'records': [{'id': 1, 'fields': {'Attachment': [attachment_id]}}] +}) ``` ## [1.2.0] - 2026-01-02 diff --git a/src/grist_mcp/main.py b/src/grist_mcp/main.py index 1ffbcc3..7376cc5 100644 --- a/src/grist_mcp/main.py +++ b/src/grist_mcp/main.py @@ -14,6 +14,7 @@ from grist_mcp.config import Config, load_config from grist_mcp.auth import Authenticator, AuthError from grist_mcp.session import SessionTokenManager from grist_mcp.proxy import parse_proxy_request, dispatch_proxy_request, ProxyError +from grist_mcp.grist_client import GristClient from grist_mcp.logging import setup_logging @@ -59,6 +60,62 @@ async def send_json_response(send: Send, status: int, data: dict) -> None: }) +def _parse_multipart(content_type: str, body: bytes) -> tuple[str | None, bytes | None]: + """Parse multipart/form-data to extract uploaded file. + + Returns (filename, content) or (None, None) if parsing fails. + """ + import re + + # Extract boundary from content-type + match = re.search(r'boundary=([^\s;]+)', content_type) + if not match: + return None, None + + boundary = match.group(1).encode() + if boundary.startswith(b'"') and boundary.endswith(b'"'): + boundary = boundary[1:-1] + + # Split by boundary + parts = body.split(b'--' + boundary) + + for part in parts: + if b'Content-Disposition' not in part: + continue + + # Split headers from content + if b'\r\n\r\n' in part: + header_section, content = part.split(b'\r\n\r\n', 1) + elif b'\n\n' in part: + header_section, content = part.split(b'\n\n', 1) + else: + continue + + headers = header_section.decode('utf-8', errors='replace') + + # Check if this is a file upload + if 'filename=' not in headers: + continue + + # Extract filename + filename_match = re.search(r'filename="([^"]+)"', headers) + if not filename_match: + filename_match = re.search(r"filename=([^\s;]+)", headers) + if not filename_match: + continue + + filename = filename_match.group(1) + + # Remove trailing boundary marker and whitespace + content = content.rstrip() + if content.endswith(b'--'): + content = content[:-2].rstrip() + + return filename, content + + return None, None + + CONFIG_TEMPLATE = """\ # grist-mcp configuration # @@ -229,6 +286,83 @@ def create_app(config: Config): "code": e.code, }) + async def handle_attachments(scope: Scope, receive: Receive, send: Send) -> None: + """Handle file attachment uploads via multipart/form-data.""" + # 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 write permission + if "write" not in session.permissions: + await send_json_response(send, 403, { + "success": False, + "error": "Write permission required for attachment upload", + "code": "UNAUTHORIZED", + }) + return + + # Get content-type header + headers = dict(scope.get("headers", [])) + content_type = headers.get(b"content-type", b"").decode() + + if not content_type.startswith("multipart/form-data"): + await send_json_response(send, 400, { + "success": False, + "error": "Content-Type must be multipart/form-data", + "code": "INVALID_REQUEST", + }) + return + + # Read request body + body = b"" + while True: + message = await receive() + body += message.get("body", b"") + if not message.get("more_body", False): + break + + # Parse multipart + filename, content = _parse_multipart(content_type, body) + if filename is None or content is None: + await send_json_response(send, 400, { + "success": False, + "error": "No file found in request", + "code": "INVALID_REQUEST", + }) + return + + # Upload to Grist + try: + doc = auth.get_document(session.document) + client = GristClient(doc) + result = await client.upload_attachment(filename, content) + await send_json_response(send, 200, { + "success": True, + "data": result, + }) + 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 @@ -244,6 +378,8 @@ def create_app(config: Config): await handle_messages(scope, receive, send) elif path == "/api/v1/proxy" and method == "POST": await handle_proxy(scope, receive, send) + elif path == "/api/v1/attachments" and method == "POST": + await handle_attachments(scope, receive, send) else: await handle_not_found(scope, receive, send) diff --git a/src/grist_mcp/server.py b/src/grist_mcp/server.py index 0284633..70353a3 100644 --- a/src/grist_mcp/server.py +++ b/src/grist_mcp/server.py @@ -22,7 +22,6 @@ from grist_mcp.tools.read import sql_query as _sql_query from grist_mcp.tools.write import add_records as _add_records from grist_mcp.tools.write import update_records as _update_records from grist_mcp.tools.write import delete_records as _delete_records -from grist_mcp.tools.write import upload_attachment as _upload_attachment from grist_mcp.tools.schema import create_table as _create_table from grist_mcp.tools.schema import add_column as _add_column from grist_mcp.tools.schema import modify_column as _modify_column @@ -219,32 +218,6 @@ def create_server( "required": ["document", "table", "column_id"], }, ), - Tool( - name="upload_attachment", - description="Upload a file attachment to a Grist document. Returns attachment ID for linking to records via update_records.", - inputSchema={ - "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)", - }, - }, - "required": ["document", "filename", "content_base64"], - }, - ), Tool( name="get_proxy_documentation", description="Get complete documentation for the HTTP proxy API", @@ -351,12 +324,6 @@ def create_server( _current_agent, auth, arguments["document"], arguments["table"], arguments["column_id"], ) - elif name == "upload_attachment": - result = await _upload_attachment( - _current_agent, auth, arguments["document"], - arguments["filename"], arguments["content_base64"], - content_type=arguments.get("content_type"), - ) elif name == "get_proxy_documentation": result = await _get_proxy_documentation() elif name == "request_session_token": diff --git a/src/grist_mcp/tools/session.py b/src/grist_mcp/tools/session.py index f3c059d..3b5066f 100644 --- a/src/grist_mcp/tools/session.py +++ b/src/grist_mcp/tools/session.py @@ -6,9 +6,28 @@ from grist_mcp.session import SessionTokenManager 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.", - "endpoint": "POST /api/v1/proxy", - "endpoint_note": "The full URL is returned in the 'proxy_url' field of request_session_token response", + "endpoints": { + "proxy": "POST /api/v1/proxy - JSON operations (CRUD, schema)", + "attachments": "POST /api/v1/attachments - File uploads (multipart/form-data)", + }, + "endpoint_note": "The full URL is returned in the 'proxy_url' field of request_session_token response. Replace /proxy with /attachments for file uploads.", "authentication": "Bearer token in Authorization header", + "attachment_upload": { + "endpoint": "POST /api/v1/attachments", + "content_type": "multipart/form-data", + "permission": "write", + "description": "Upload file attachments to the document. Returns attachment_id for linking to records via update_records.", + "response": {"success": True, "data": {"attachment_id": 42, "filename": "invoice.pdf", "size_bytes": 31395}}, + "example_curl": "curl -X POST -H 'Authorization: Bearer TOKEN' -F 'file=@invoice.pdf' URL/api/v1/attachments", + "example_python": """import requests +response = requests.post( + f'{proxy_url.replace("/proxy", "/attachments")}', + headers={'Authorization': f'Bearer {token}'}, + files={'file': open('invoice.pdf', 'rb')} +) +attachment_id = response.json()['data']['attachment_id'] +# Link to record: update_records with {'Attachment': [attachment_id]}""", + }, "request_format": { "method": "Operation name (required)", "table": "Table name (required for most operations)", diff --git a/src/grist_mcp/tools/write.py b/src/grist_mcp/tools/write.py index 4545106..7041b12 100644 --- a/src/grist_mcp/tools/write.py +++ b/src/grist_mcp/tools/write.py @@ -1,7 +1,4 @@ -"""Write tools - create, update, delete records, upload attachments.""" - -import base64 -import mimetypes +"""Write tools - create, update, delete records.""" from grist_mcp.auth import Agent, Authenticator, Permission from grist_mcp.grist_client import GristClient @@ -62,50 +59,3 @@ async def delete_records( await client.delete_records(table, record_ids) return {"deleted": True} - - -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. - - Args: - agent: The authenticated agent. - auth: Authenticator for permission checks. - document: Document name. - filename: Filename with extension. - content_base64: File content as base64-encoded string. - content_type: MIME type (auto-detected from filename if omitted). - client: Optional GristClient instance. - - Returns: - Dict with attachment_id, filename, and size_bytes. - - Raises: - ValueError: If content_base64 is not valid base64. - """ - auth.authorize(agent, document, Permission.WRITE) - - # Decode base64 content - 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) diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index f30e71e..ede381f 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -52,14 +52,13 @@ tokens: assert "add_column" in tool_names assert "modify_column" in tool_names assert "delete_column" in tool_names - assert "upload_attachment" in tool_names # Session tools (always registered) assert "get_proxy_documentation" in tool_names assert "request_session_token" in tool_names - # Should have all 15 tools - assert len(result.root.tools) == 15 + # Should have all 14 tools + assert len(result.root.tools) == 14 @pytest.mark.asyncio diff --git a/tests/unit/test_tools_session.py b/tests/unit/test_tools_session.py index 25c9731..86a22f3 100644 --- a/tests/unit/test_tools_session.py +++ b/tests/unit/test_tools_session.py @@ -39,12 +39,14 @@ async def test_get_proxy_documentation_returns_complete_spec(): result = await get_proxy_documentation() assert "description" in result - assert "endpoint" in result - assert result["endpoint"] == "POST /api/v1/proxy" + assert "endpoints" in result + assert "proxy" in result["endpoints"] + assert "attachments" 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 "example_script" in result diff --git a/tests/unit/test_tools_write.py b/tests/unit/test_tools_write.py index 5f18ed4..8541e54 100644 --- a/tests/unit/test_tools_write.py +++ b/tests/unit/test_tools_write.py @@ -1,9 +1,7 @@ -import base64 - import pytest from unittest.mock import AsyncMock -from grist_mcp.tools.write import add_records, update_records, delete_records, upload_attachment +from grist_mcp.tools.write import add_records, update_records, delete_records from grist_mcp.auth import Authenticator, AuthError from grist_mcp.config import Config, Document, Token, TokenScope @@ -96,105 +94,3 @@ async def test_delete_records(auth, mock_client): ) assert result == {"deleted": True} - - -# Upload attachment tests - -@pytest.fixture -def mock_client_with_attachment(): - client = AsyncMock() - client.upload_attachment.return_value = { - "attachment_id": 42, - "filename": "invoice.pdf", - "size_bytes": 1024, - } - return client - - -@pytest.mark.asyncio -async def test_upload_attachment_success(auth, mock_client_with_attachment): - agent = auth.authenticate("write-token") - content = b"PDF content" - content_base64 = base64.b64encode(content).decode() - - result = await upload_attachment( - agent, auth, "budget", - filename="invoice.pdf", - content_base64=content_base64, - client=mock_client_with_attachment, - ) - - assert result == { - "attachment_id": 42, - "filename": "invoice.pdf", - "size_bytes": 1024, - } - mock_client_with_attachment.upload_attachment.assert_called_once_with( - "invoice.pdf", content, "application/pdf" - ) - - -@pytest.mark.asyncio -async def test_upload_attachment_invalid_base64(auth, mock_client_with_attachment): - agent = auth.authenticate("write-token") - - with pytest.raises(ValueError, match="Invalid base64 encoding"): - await upload_attachment( - agent, auth, "budget", - filename="test.txt", - content_base64="not-valid-base64!!!", - client=mock_client_with_attachment, - ) - - -@pytest.mark.asyncio -async def test_upload_attachment_auth_required(auth, mock_client_with_attachment): - agent = auth.authenticate("read-token") - content_base64 = base64.b64encode(b"test").decode() - - with pytest.raises(AuthError, match="Permission denied"): - await upload_attachment( - agent, auth, "budget", - filename="test.txt", - content_base64=content_base64, - client=mock_client_with_attachment, - ) - - -@pytest.mark.asyncio -async def test_upload_attachment_mime_detection(auth, mock_client_with_attachment): - agent = auth.authenticate("write-token") - content = b"PNG content" - content_base64 = base64.b64encode(content).decode() - - await upload_attachment( - agent, auth, "budget", - filename="image.png", - content_base64=content_base64, - client=mock_client_with_attachment, - ) - - # Should auto-detect image/png from filename - mock_client_with_attachment.upload_attachment.assert_called_once_with( - "image.png", content, "image/png" - ) - - -@pytest.mark.asyncio -async def test_upload_attachment_explicit_content_type(auth, mock_client_with_attachment): - agent = auth.authenticate("write-token") - content = b"custom content" - content_base64 = base64.b64encode(content).decode() - - await upload_attachment( - agent, auth, "budget", - filename="file.dat", - content_base64=content_base64, - content_type="application/custom", - client=mock_client_with_attachment, - ) - - # Should use explicit content type - mock_client_with_attachment.upload_attachment.assert_called_once_with( - "file.dat", content, "application/custom" - )