feat: add upload_attachment MCP tool
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
Add support for uploading file attachments to Grist documents: - GristClient.upload_attachment() method using multipart/form-data - upload_attachment tool function with base64 decoding and MIME detection - Tool registration in server.py - Comprehensive unit tests (7 new tests) Returns attachment ID for linking to records via update_records. Bumps version to 1.3.0.
This commit is contained in:
@@ -196,3 +196,43 @@ def test_sql_validation_rejects_multiple_statements(client):
|
||||
def test_sql_validation_allows_trailing_semicolon(client):
|
||||
# Should not raise
|
||||
client._validate_sql_query("SELECT * FROM users;")
|
||||
|
||||
|
||||
# Attachment tests
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_attachment(client, httpx_mock: HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://grist.example.com/api/docs/abc123/attachments",
|
||||
method="POST",
|
||||
json=[42],
|
||||
)
|
||||
|
||||
result = await client.upload_attachment(
|
||||
filename="invoice.pdf",
|
||||
content=b"PDF content here",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"attachment_id": 42,
|
||||
"filename": "invoice.pdf",
|
||||
"size_bytes": 16,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_attachment_default_content_type(client, httpx_mock: HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://grist.example.com/api/docs/abc123/attachments",
|
||||
method="POST",
|
||||
json=[99],
|
||||
)
|
||||
|
||||
result = await client.upload_attachment(
|
||||
filename="data.bin",
|
||||
content=b"\x00\x01\x02",
|
||||
)
|
||||
|
||||
assert result["attachment_id"] == 99
|
||||
assert result["size_bytes"] == 3
|
||||
|
||||
@@ -52,13 +52,14 @@ 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 14 tools
|
||||
assert len(result.root.tools) == 14
|
||||
# Should have all 15 tools
|
||||
assert len(result.root.tools) == 15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from grist_mcp.tools.write import add_records, update_records, delete_records
|
||||
from grist_mcp.tools.write import add_records, update_records, delete_records, upload_attachment
|
||||
from grist_mcp.auth import Authenticator, AuthError
|
||||
from grist_mcp.config import Config, Document, Token, TokenScope
|
||||
|
||||
@@ -94,3 +96,105 @@ 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