From 0a6f699d3055a32b115f682bc1fa8342aa5dbcfc Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 3 Dec 2025 14:45:55 -0500 Subject: [PATCH] feat: add read tools (list_tables, describe_table, get_records, sql_query) --- src/grist_mcp/tools/read.py | 78 +++++++++++++++++++++++++++++++++++ tests/test_tools_read.py | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/grist_mcp/tools/read.py create mode 100644 tests/test_tools_read.py diff --git a/src/grist_mcp/tools/read.py b/src/grist_mcp/tools/read.py new file mode 100644 index 0000000..c330e91 --- /dev/null +++ b/src/grist_mcp/tools/read.py @@ -0,0 +1,78 @@ +"""Read tools - query tables and records.""" + +from grist_mcp.auth import Agent, Authenticator, Permission +from grist_mcp.grist_client import GristClient + + +async def list_tables( + agent: Agent, + auth: Authenticator, + document: str, + client: GristClient | None = None, +) -> dict: + """List all tables in a document.""" + auth.authorize(agent, document, Permission.READ) + + if client is None: + doc = auth.get_document(document) + client = GristClient(doc) + + tables = await client.list_tables() + return {"tables": tables} + + +async def describe_table( + agent: Agent, + auth: Authenticator, + document: str, + table: str, + client: GristClient | None = None, +) -> dict: + """Get column information for a table.""" + auth.authorize(agent, document, Permission.READ) + + if client is None: + doc = auth.get_document(document) + client = GristClient(doc) + + columns = await client.describe_table(table) + return {"table": table, "columns": columns} + + +async def get_records( + agent: Agent, + auth: Authenticator, + document: str, + table: str, + filter: dict | None = None, + sort: str | None = None, + limit: int | None = None, + client: GristClient | None = None, +) -> dict: + """Fetch records from a table.""" + auth.authorize(agent, document, Permission.READ) + + if client is None: + doc = auth.get_document(document) + client = GristClient(doc) + + records = await client.get_records(table, filter=filter, sort=sort, limit=limit) + return {"records": records} + + +async def sql_query( + agent: Agent, + auth: Authenticator, + document: str, + query: str, + client: GristClient | None = None, +) -> dict: + """Run a read-only SQL query.""" + auth.authorize(agent, document, Permission.READ) + + if client is None: + doc = auth.get_document(document) + client = GristClient(doc) + + records = await client.sql_query(query) + return {"records": records} diff --git a/tests/test_tools_read.py b/tests/test_tools_read.py new file mode 100644 index 0000000..a36e576 --- /dev/null +++ b/tests/test_tools_read.py @@ -0,0 +1,82 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from grist_mcp.tools.read import list_tables, describe_table, get_records, sql_query +from grist_mcp.auth import Authenticator, Agent, Permission +from grist_mcp.config import Config, Document, Token, TokenScope + + +@pytest.fixture +def config(): + return Config( + documents={ + "budget": Document( + url="https://grist.example.com", + doc_id="abc123", + api_key="key", + ), + }, + tokens=[ + Token( + token="test-token", + name="test-agent", + scope=[TokenScope(document="budget", permissions=["read"])], + ), + ], + ) + + +@pytest.fixture +def auth(config): + return Authenticator(config) + + +@pytest.fixture +def agent(auth): + return auth.authenticate("test-token") + + +@pytest.fixture +def mock_client(): + client = AsyncMock() + client.list_tables.return_value = ["Table1", "Table2"] + client.describe_table.return_value = [ + {"id": "Name", "type": "Text", "formula": ""}, + ] + client.get_records.return_value = [ + {"id": 1, "Name": "Alice"}, + ] + client.sql_query.return_value = [{"Name": "Alice"}] + return client + + +@pytest.mark.asyncio +async def test_list_tables(agent, auth, mock_client): + result = await list_tables(agent, auth, "budget", client=mock_client) + + assert result == {"tables": ["Table1", "Table2"]} + mock_client.list_tables.assert_called_once() + + +@pytest.mark.asyncio +async def test_describe_table(agent, auth, mock_client): + result = await describe_table(agent, auth, "budget", "Table1", client=mock_client) + + assert result == { + "table": "Table1", + "columns": [{"id": "Name", "type": "Text", "formula": ""}], + } + + +@pytest.mark.asyncio +async def test_get_records(agent, auth, mock_client): + result = await get_records(agent, auth, "budget", "Table1", client=mock_client) + + assert result == {"records": [{"id": 1, "Name": "Alice"}]} + + +@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) + + assert result == {"records": [{"Name": "Alice"}]}