4 Commits

Author SHA1 Message Date
bf8f301ded chore: bump version to 1.4.1 and update changelog
All checks were successful
Build and Push Docker Image / build (push) Successful in 9s
Document the reference column filter support feature.
2026-01-14 17:57:33 -05:00
a97930848b feat: normalize filter values to array format for Grist API
The Grist API requires all filter values to be arrays. This change adds
automatic normalization of filter values in get_records, wrapping single
values in lists before sending to the API.

This fixes 400 errors when filtering on Ref columns with single integer IDs.

Changes:
- Add filters.py module with normalize_filter function
- Update get_records to normalize filters before API call
- Add Orders table with Ref column to mock Grist server
- Add filter validation to mock server (rejects non-array values)
- Fix shell script shebangs for portability (#!/usr/bin/env bash)
2026-01-14 17:56:18 -05:00
c868e8a7fa chore: bump version to 1.4.0 and update changelog
All checks were successful
Build and Push Docker Image / build (push) Successful in 11s
2026-01-12 12:14:49 -05:00
734cc0a525 feat: add attachment download via proxy endpoint
Add GET /api/v1/attachments/{id} endpoint for downloading attachments
through the MCP proxy. This complements the existing upload endpoint and
enables complete attachment workflows via the proxy API.
2026-01-12 12:13:23 -05:00
16 changed files with 483 additions and 10 deletions

View File

@@ -5,6 +5,63 @@ 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

View File

@@ -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 = [

View File

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

View File

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

View File

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

View File

@@ -363,6 +363,67 @@ def create_app(config: Config):
"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
@@ -380,6 +441,17 @@ def create_app(config: Config):
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)

View 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()}

View File

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

View File

@@ -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.",
"endpoints": {
"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",
"attachment_upload": {
"endpoint": "POST /api/v1/attachments",
@@ -27,6 +28,20 @@ response = requests.post(
)
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)",

View File

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

View File

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

View 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],
}

View File

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

View File

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

View File

@@ -41,12 +41,14 @@ async def test_get_proxy_documentation_returns_complete_spec():
assert "description" in result
assert "endpoints" in result
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 "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

2
uv.lock generated
View File

@@ -153,7 +153,7 @@ wheels = [
[[package]]
name = "grist-mcp"
version = "1.2.0"
version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },