feat: replace MCP attachment tool with proxy endpoint
All checks were successful
Build and Push Docker Image / build (push) Successful in 8s

The MCP tool approach was impractical because it required the LLM to
generate large base64 strings token-by-token, causing timeouts.

Changes:
- Remove upload_attachment MCP tool
- Add POST /api/v1/attachments endpoint for multipart/form-data uploads
- Update proxy documentation to show both endpoints
- Uses existing GristClient.upload_attachment() method
- Requires write permission in session token
This commit is contained in:
2026-01-03 20:26:36 -05:00
parent 848cfd684f
commit a7c87128ef
8 changed files with 198 additions and 211 deletions

View File

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

View File

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

View File

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

View File

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