Compare commits
5 Commits
v1.3.0-alp
...
v1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| bf8f301ded | |||
| a97930848b | |||
| c868e8a7fa | |||
| 734cc0a525 | |||
| a7c87128ef |
105
CHANGELOG.md
105
CHANGELOG.md
@@ -5,30 +5,105 @@ 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.1] - 2026-01-14
|
||||
|
||||
### Added
|
||||
|
||||
#### Reference Column Filter Support
|
||||
- **Filter normalization**: `get_records` now automatically normalizes filter values to array format
|
||||
- Fixes 400 errors when filtering on `Ref:*` (reference/foreign key) columns
|
||||
- Single values are wrapped in arrays before sending to Grist API
|
||||
|
||||
#### Usage
|
||||
```python
|
||||
# Before: Failed with 400 Bad Request
|
||||
get_records(document="accounting", table="TransactionLines", filter={"Transaction": 44})
|
||||
|
||||
# After: Works - filter normalized to {"Transaction": [44]}
|
||||
get_records(document="accounting", table="TransactionLines", filter={"Transaction": 44})
|
||||
|
||||
# Multiple values also supported
|
||||
get_records(document="accounting", table="TransactionLines", filter={"Transaction": [44, 45, 46]})
|
||||
```
|
||||
|
||||
### Fixed
|
||||
- Shell script shebangs updated to `#!/usr/bin/env bash` for portability across environments
|
||||
|
||||
## [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
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "grist-mcp"
|
||||
version = "1.3.0"
|
||||
version = "1.4.1"
|
||||
description = "MCP server for AI agents to interact with Grist documents"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/get-test-instance-id.sh
|
||||
# Generate a unique instance ID from git branch for parallel test isolation
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-integration-tests.sh
|
||||
# Run integration tests with branch isolation and dynamic port discovery
|
||||
set -e
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,144 @@ 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 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
|
||||
@@ -244,6 +439,19 @@ 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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
37
src/grist_mcp/tools/filters.py
Normal file
37
src/grist_mcp/tools/filters.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Filter normalization for Grist API queries."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalize_filter_value(value: Any) -> list:
|
||||
"""Ensure a filter value is a list.
|
||||
|
||||
Grist API expects filter values to be arrays.
|
||||
|
||||
Args:
|
||||
value: Single value or list of values.
|
||||
|
||||
Returns:
|
||||
Value wrapped in list, or original list if already a list.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def normalize_filter(filter: dict | None) -> dict | None:
|
||||
"""Normalize filter values to array format for Grist API.
|
||||
|
||||
Grist expects all filter values to be arrays. This function
|
||||
wraps single values in lists.
|
||||
|
||||
Args:
|
||||
filter: Filter dict with column names as keys.
|
||||
|
||||
Returns:
|
||||
Normalized filter dict, or None if input was None.
|
||||
"""
|
||||
if not filter:
|
||||
return filter
|
||||
|
||||
return {key: normalize_filter_value(value) for key, value in filter.items()}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from grist_mcp.auth import Agent, Authenticator, Permission
|
||||
from grist_mcp.grist_client import GristClient
|
||||
from grist_mcp.tools.filters import normalize_filter
|
||||
|
||||
|
||||
async def list_tables(
|
||||
@@ -56,7 +57,10 @@ async def get_records(
|
||||
doc = auth.get_document(document)
|
||||
client = GristClient(doc)
|
||||
|
||||
records = await client.get_records(table, filter=filter, sort=sort, limit=limit)
|
||||
# Normalize filter values to array format for Grist API
|
||||
normalized_filter = normalize_filter(filter)
|
||||
|
||||
records = await client.get_records(table, filter=normalized_filter, sort=sort, limit=limit)
|
||||
return {"records": records}
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,43 @@ 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_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 operations.",
|
||||
"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]}""",
|
||||
},
|
||||
"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)",
|
||||
"table": "Table name (required for most operations)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,18 @@ MOCK_TABLES = {
|
||||
{"id": 2, "fields": {"Title": "Deploy", "Done": False}},
|
||||
],
|
||||
},
|
||||
"Orders": {
|
||||
"columns": [
|
||||
{"id": "OrderNum", "fields": {"type": "Int"}},
|
||||
{"id": "Customer", "fields": {"type": "Ref:People"}},
|
||||
{"id": "Amount", "fields": {"type": "Numeric"}},
|
||||
],
|
||||
"records": [
|
||||
{"id": 1, "fields": {"OrderNum": 1001, "Customer": 1, "Amount": 100.0}},
|
||||
{"id": 2, "fields": {"OrderNum": 1002, "Customer": 2, "Amount": 200.0}},
|
||||
{"id": 3, "fields": {"OrderNum": 1003, "Customer": 1, "Amount": 150.0}},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Track requests for test assertions
|
||||
@@ -93,12 +105,40 @@ async def get_records(request):
|
||||
"""GET /api/docs/{doc_id}/tables/{table_id}/records"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables/{table_id}/records")
|
||||
filter_param = request.query_params.get("filter")
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables/{table_id}/records?filter={filter_param}")
|
||||
|
||||
if table_id not in MOCK_TABLES:
|
||||
return JSONResponse({"error": "Table not found"}, status_code=404)
|
||||
|
||||
return JSONResponse({"records": MOCK_TABLES[table_id]["records"]})
|
||||
records = MOCK_TABLES[table_id]["records"]
|
||||
|
||||
# Apply filtering if provided
|
||||
if filter_param:
|
||||
try:
|
||||
filters = json.loads(filter_param)
|
||||
# Validate filter format: all values must be arrays (Grist API requirement)
|
||||
for key, values in filters.items():
|
||||
if not isinstance(values, list):
|
||||
return JSONResponse(
|
||||
{"error": f"Filter values must be arrays, got {type(values).__name__} for '{key}'"},
|
||||
status_code=400
|
||||
)
|
||||
# Apply filters: record matches if field value is in the filter list
|
||||
filtered_records = []
|
||||
for record in records:
|
||||
match = True
|
||||
for key, allowed_values in filters.items():
|
||||
if record["fields"].get(key) not in allowed_values:
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
filtered_records.append(record)
|
||||
records = filtered_records
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid filter JSON"}, status_code=400)
|
||||
|
||||
return JSONResponse({"records": records})
|
||||
|
||||
|
||||
async def add_records(request):
|
||||
|
||||
@@ -90,6 +90,36 @@ async def test_all_tools(services_ready):
|
||||
log = get_mock_request_log()
|
||||
assert any("/records" in entry["path"] and entry["method"] == "GET" for entry in log)
|
||||
|
||||
# Test get_records with Ref column filter
|
||||
# This tests that single values are normalized to arrays for the Grist API
|
||||
clear_mock_request_log()
|
||||
result = await client.call_tool(
|
||||
"get_records",
|
||||
{"document": "test-doc", "table": "Orders", "filter": {"Customer": 1}}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "records" in data
|
||||
# Should return only orders for Customer 1 (orders 1 and 3)
|
||||
assert len(data["records"]) == 2
|
||||
for record in data["records"]:
|
||||
assert record["Customer"] == 1
|
||||
log = get_mock_request_log()
|
||||
# Verify the filter was sent as array format
|
||||
filter_requests = [e for e in log if "/records" in e["path"] and "filter=" in e["path"]]
|
||||
assert len(filter_requests) >= 1
|
||||
# The filter value should be [1] not 1
|
||||
assert "[1]" in filter_requests[0]["path"]
|
||||
|
||||
# Test get_records with multiple filter values
|
||||
clear_mock_request_log()
|
||||
result = await client.call_tool(
|
||||
"get_records",
|
||||
{"document": "test-doc", "table": "Orders", "filter": {"Customer": [1, 2]}}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "records" in data
|
||||
assert len(data["records"]) == 3 # All 3 orders (customers 1 and 2)
|
||||
|
||||
# Test sql_query
|
||||
clear_mock_request_log()
|
||||
result = await client.call_tool(
|
||||
|
||||
89
tests/unit/test_filters.py
Normal file
89
tests/unit/test_filters.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Unit tests for filter normalization."""
|
||||
|
||||
import pytest
|
||||
|
||||
from grist_mcp.tools.filters import normalize_filter, normalize_filter_value
|
||||
|
||||
|
||||
class TestNormalizeFilterValue:
|
||||
"""Tests for normalize_filter_value function."""
|
||||
|
||||
def test_int_becomes_list(self):
|
||||
assert normalize_filter_value(5) == [5]
|
||||
|
||||
def test_string_becomes_list(self):
|
||||
assert normalize_filter_value("foo") == ["foo"]
|
||||
|
||||
def test_float_becomes_list(self):
|
||||
assert normalize_filter_value(3.14) == [3.14]
|
||||
|
||||
def test_list_unchanged(self):
|
||||
assert normalize_filter_value([1, 2, 3]) == [1, 2, 3]
|
||||
|
||||
def test_empty_list_unchanged(self):
|
||||
assert normalize_filter_value([]) == []
|
||||
|
||||
def test_single_item_list_unchanged(self):
|
||||
assert normalize_filter_value([42]) == [42]
|
||||
|
||||
def test_mixed_type_list_unchanged(self):
|
||||
assert normalize_filter_value([1, "foo", 3.14]) == [1, "foo", 3.14]
|
||||
|
||||
|
||||
class TestNormalizeFilter:
|
||||
"""Tests for normalize_filter function."""
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert normalize_filter(None) is None
|
||||
|
||||
def test_empty_dict_returns_empty_dict(self):
|
||||
assert normalize_filter({}) == {}
|
||||
|
||||
def test_single_int_value_wrapped(self):
|
||||
result = normalize_filter({"Transaction": 44})
|
||||
assert result == {"Transaction": [44]}
|
||||
|
||||
def test_single_string_value_wrapped(self):
|
||||
result = normalize_filter({"Status": "active"})
|
||||
assert result == {"Status": ["active"]}
|
||||
|
||||
def test_list_value_unchanged(self):
|
||||
result = normalize_filter({"Transaction": [44, 45, 46]})
|
||||
assert result == {"Transaction": [44, 45, 46]}
|
||||
|
||||
def test_mixed_columns_all_normalized(self):
|
||||
"""Both ref and non-ref columns are normalized to arrays."""
|
||||
result = normalize_filter({
|
||||
"Transaction": 44, # Ref column (int)
|
||||
"Debit": 500, # Non-ref column (int)
|
||||
"Memo": "test", # Non-ref column (str)
|
||||
})
|
||||
assert result == {
|
||||
"Transaction": [44],
|
||||
"Debit": [500],
|
||||
"Memo": ["test"],
|
||||
}
|
||||
|
||||
def test_multiple_values_list_unchanged(self):
|
||||
"""Filter with multiple values passes through."""
|
||||
result = normalize_filter({
|
||||
"Status": ["pending", "active"],
|
||||
"Priority": [1, 2, 3],
|
||||
})
|
||||
assert result == {
|
||||
"Status": ["pending", "active"],
|
||||
"Priority": [1, 2, 3],
|
||||
}
|
||||
|
||||
def test_mixed_single_and_list_values(self):
|
||||
"""Mix of single values and lists."""
|
||||
result = normalize_filter({
|
||||
"Transaction": 44, # Single int
|
||||
"Status": ["open", "closed"], # List
|
||||
"Amount": 100.50, # Single float
|
||||
})
|
||||
assert result == {
|
||||
"Transaction": [44],
|
||||
"Status": ["open", "closed"],
|
||||
"Amount": [100.50],
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,6 +75,45 @@ async def test_get_records(agent, auth, mock_client):
|
||||
assert result == {"records": [{"id": 1, "Name": "Alice"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_records_normalizes_filter(agent, auth, mock_client):
|
||||
"""Test that filter values are normalized to array format for Grist API."""
|
||||
mock_client.get_records.return_value = [{"id": 1, "Customer": 5}]
|
||||
|
||||
await get_records(
|
||||
agent, auth, "budget", "Orders",
|
||||
filter={"Customer": 5, "Status": "active"},
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
# Verify filter was normalized: single values wrapped in lists
|
||||
mock_client.get_records.assert_called_once_with(
|
||||
"Orders",
|
||||
filter={"Customer": [5], "Status": ["active"]},
|
||||
sort=None,
|
||||
limit=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_records_preserves_list_filter(agent, auth, mock_client):
|
||||
"""Test that filter values already in list format are preserved."""
|
||||
mock_client.get_records.return_value = []
|
||||
|
||||
await get_records(
|
||||
agent, auth, "budget", "Orders",
|
||||
filter={"Customer": [5, 6, 7]},
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
mock_client.get_records.assert_called_once_with(
|
||||
"Orders",
|
||||
filter={"Customer": [5, 6, 7]},
|
||||
sort=None,
|
||||
limit=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_query(agent, auth, mock_client):
|
||||
result = await sql_query(agent, auth, "budget", "SELECT * FROM Table1", client=mock_client)
|
||||
|
||||
@@ -39,12 +39,16 @@ 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_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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user