2136 lines
57 KiB
Markdown
2136 lines
57 KiB
Markdown
# Grist MCP Server Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a dockerized MCP server that allows AI agents to interact with Grist documents using scoped access tokens.
|
|
|
|
**Architecture:** Single MCP server reading config from YAML, validating bearer tokens against defined scopes, proxying requests to Grist API. All configuration file-based, no database.
|
|
|
|
**Tech Stack:** Python 3.14, uv, mcp SDK, httpx, pyyaml, pytest
|
|
|
|
---
|
|
|
|
## Task 1: Project Setup
|
|
|
|
**Files:**
|
|
- Create: `pyproject.toml`
|
|
- Create: `src/grist_mcp/__init__.py`
|
|
- Create: `.gitignore`
|
|
|
|
**Step 1: Create pyproject.toml**
|
|
|
|
```toml
|
|
[project]
|
|
name = "grist-mcp"
|
|
version = "0.1.0"
|
|
description = "MCP server for AI agents to interact with Grist documents"
|
|
requires-python = ">=3.14"
|
|
dependencies = [
|
|
"mcp>=1.0.0",
|
|
"httpx>=0.27.0",
|
|
"pyyaml>=6.0",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"pytest>=8.0.0",
|
|
"pytest-asyncio>=0.24.0",
|
|
"pytest-httpx>=0.32.0",
|
|
]
|
|
|
|
[build-system]
|
|
requires = ["hatchling"]
|
|
build-backend = "hatchling.build"
|
|
|
|
[tool.pytest.ini_options]
|
|
asyncio_mode = "auto"
|
|
testpaths = ["tests"]
|
|
```
|
|
|
|
**Step 2: Create package init**
|
|
|
|
```python
|
|
# src/grist_mcp/__init__.py
|
|
"""Grist MCP Server - AI agent access to Grist documents."""
|
|
|
|
__version__ = "0.1.0"
|
|
```
|
|
|
|
**Step 3: Create .gitignore**
|
|
|
|
```
|
|
__pycache__/
|
|
*.py[cod]
|
|
.venv/
|
|
.env
|
|
config.yaml
|
|
*.egg-info/
|
|
dist/
|
|
.pytest_cache/
|
|
```
|
|
|
|
**Step 4: Initialize uv and install dependencies**
|
|
|
|
Run: `uv sync`
|
|
Expected: Creates `.venv/` and `uv.lock`
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add pyproject.toml src/grist_mcp/__init__.py .gitignore uv.lock
|
|
git commit -m "feat: initialize project with uv and dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Config Schema and Parsing
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/config.py`
|
|
- Create: `tests/test_config.py`
|
|
- Create: `config.yaml.example`
|
|
|
|
**Step 1: Write failing test for config loading**
|
|
|
|
```python
|
|
# tests/test_config.py
|
|
import pytest
|
|
from grist_mcp.config import load_config, Config, Document, Token, TokenScope
|
|
|
|
|
|
def test_load_config_parses_documents(tmp_path):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
documents:
|
|
my-doc:
|
|
url: https://grist.example.com
|
|
doc_id: abc123
|
|
api_key: secret-key
|
|
|
|
tokens: []
|
|
""")
|
|
|
|
config = load_config(str(config_file))
|
|
|
|
assert "my-doc" in config.documents
|
|
doc = config.documents["my-doc"]
|
|
assert doc.url == "https://grist.example.com"
|
|
assert doc.doc_id == "abc123"
|
|
assert doc.api_key == "secret-key"
|
|
|
|
|
|
def test_load_config_parses_tokens(tmp_path):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
documents:
|
|
budget:
|
|
url: https://grist.example.com
|
|
doc_id: abc123
|
|
api_key: key123
|
|
|
|
tokens:
|
|
- token: my-secret-token
|
|
name: test-agent
|
|
scope:
|
|
- document: budget
|
|
permissions: [read, write]
|
|
""")
|
|
|
|
config = load_config(str(config_file))
|
|
|
|
assert len(config.tokens) == 1
|
|
token = config.tokens[0]
|
|
assert token.token == "my-secret-token"
|
|
assert token.name == "test-agent"
|
|
assert len(token.scope) == 1
|
|
assert token.scope[0].document == "budget"
|
|
assert token.scope[0].permissions == ["read", "write"]
|
|
|
|
|
|
def test_load_config_substitutes_env_vars(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("TEST_API_KEY", "env-secret-key")
|
|
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
documents:
|
|
my-doc:
|
|
url: https://grist.example.com
|
|
doc_id: abc123
|
|
api_key: ${TEST_API_KEY}
|
|
|
|
tokens: []
|
|
""")
|
|
|
|
config = load_config(str(config_file))
|
|
|
|
assert config.documents["my-doc"].api_key == "env-secret-key"
|
|
|
|
|
|
def test_load_config_raises_on_missing_env_var(tmp_path):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
documents:
|
|
my-doc:
|
|
url: https://grist.example.com
|
|
doc_id: abc123
|
|
api_key: ${MISSING_VAR}
|
|
|
|
tokens: []
|
|
""")
|
|
|
|
with pytest.raises(ValueError, match="MISSING_VAR"):
|
|
load_config(str(config_file))
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `uv run pytest tests/test_config.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.config'"
|
|
|
|
**Step 3: Implement config module**
|
|
|
|
```python
|
|
# src/grist_mcp/config.py
|
|
"""Configuration loading and parsing."""
|
|
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
@dataclass
|
|
class Document:
|
|
"""A Grist document configuration."""
|
|
url: str
|
|
doc_id: str
|
|
api_key: str
|
|
|
|
|
|
@dataclass
|
|
class TokenScope:
|
|
"""Access scope for a single document."""
|
|
document: str
|
|
permissions: list[str]
|
|
|
|
|
|
@dataclass
|
|
class Token:
|
|
"""An agent token with its access scopes."""
|
|
token: str
|
|
name: str
|
|
scope: list[TokenScope]
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
"""Full server configuration."""
|
|
documents: dict[str, Document]
|
|
tokens: list[Token]
|
|
|
|
|
|
def _substitute_env_vars(value: str) -> str:
|
|
"""Replace ${VAR} patterns with environment variable values."""
|
|
pattern = r'\$\{([^}]+)\}'
|
|
|
|
def replacer(match: re.Match) -> str:
|
|
var_name = match.group(1)
|
|
env_value = os.environ.get(var_name)
|
|
if env_value is None:
|
|
raise ValueError(f"Environment variable not set: {var_name}")
|
|
return env_value
|
|
|
|
return re.sub(pattern, replacer, value)
|
|
|
|
|
|
def _substitute_env_vars_recursive(obj):
|
|
"""Recursively substitute env vars in a data structure."""
|
|
if isinstance(obj, str):
|
|
return _substitute_env_vars(obj)
|
|
elif isinstance(obj, dict):
|
|
return {k: _substitute_env_vars_recursive(v) for k, v in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [_substitute_env_vars_recursive(item) for item in obj]
|
|
return obj
|
|
|
|
|
|
def load_config(config_path: str) -> Config:
|
|
"""Load and parse configuration from YAML file."""
|
|
path = Path(config_path)
|
|
raw = yaml.safe_load(path.read_text())
|
|
|
|
# Substitute environment variables
|
|
raw = _substitute_env_vars_recursive(raw)
|
|
|
|
# Parse documents
|
|
documents = {}
|
|
for name, doc_data in raw.get("documents", {}).items():
|
|
documents[name] = Document(
|
|
url=doc_data["url"],
|
|
doc_id=doc_data["doc_id"],
|
|
api_key=doc_data["api_key"],
|
|
)
|
|
|
|
# Parse tokens
|
|
tokens = []
|
|
for token_data in raw.get("tokens", []):
|
|
scope = [
|
|
TokenScope(
|
|
document=s["document"],
|
|
permissions=s["permissions"],
|
|
)
|
|
for s in token_data.get("scope", [])
|
|
]
|
|
tokens.append(Token(
|
|
token=token_data["token"],
|
|
name=token_data["name"],
|
|
scope=scope,
|
|
))
|
|
|
|
return Config(documents=documents, tokens=tokens)
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `uv run pytest tests/test_config.py -v`
|
|
Expected: All 4 tests PASS
|
|
|
|
**Step 5: Create example config file**
|
|
|
|
```yaml
|
|
# config.yaml.example
|
|
|
|
# ============================================================
|
|
# Token Generation:
|
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
# openssl rand -base64 32
|
|
# ============================================================
|
|
|
|
# Document definitions (each is self-contained)
|
|
documents:
|
|
budget-2024:
|
|
url: https://work.getgrist.com
|
|
doc_id: mK7xB2pQ9mN4v
|
|
api_key: ${GRIST_WORK_API_KEY}
|
|
|
|
expenses:
|
|
url: https://work.getgrist.com
|
|
doc_id: nL8yC3qR0oO5w
|
|
api_key: ${GRIST_WORK_API_KEY}
|
|
|
|
personal-tracker:
|
|
url: https://docs.getgrist.com
|
|
doc_id: pN0zE5sT2qP7x
|
|
api_key: ${GRIST_PERSONAL_API_KEY}
|
|
|
|
# Agent tokens with access scopes
|
|
tokens:
|
|
- token: REPLACE_WITH_GENERATED_TOKEN
|
|
name: finance-agent
|
|
scope:
|
|
- document: budget-2024
|
|
permissions: [read, write]
|
|
- document: expenses
|
|
permissions: [read]
|
|
|
|
- token: REPLACE_WITH_ANOTHER_TOKEN
|
|
name: analytics-agent
|
|
scope:
|
|
- document: personal-tracker
|
|
permissions: [read, write, schema]
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/config.py tests/test_config.py config.yaml.example
|
|
git commit -m "feat: add config loading with env var substitution"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Authentication and Authorization
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/auth.py`
|
|
- Create: `tests/test_auth.py`
|
|
|
|
**Step 1: Write failing tests for auth**
|
|
|
|
```python
|
|
# tests/test_auth.py
|
|
import pytest
|
|
from grist_mcp.auth import Authenticator, AuthError, Permission
|
|
from grist_mcp.config import Config, Document, Token, TokenScope
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_config():
|
|
return Config(
|
|
documents={
|
|
"budget": Document(
|
|
url="https://grist.example.com",
|
|
doc_id="abc123",
|
|
api_key="doc-api-key",
|
|
),
|
|
"expenses": Document(
|
|
url="https://grist.example.com",
|
|
doc_id="def456",
|
|
api_key="doc-api-key",
|
|
),
|
|
},
|
|
tokens=[
|
|
Token(
|
|
token="valid-token",
|
|
name="test-agent",
|
|
scope=[
|
|
TokenScope(document="budget", permissions=["read", "write"]),
|
|
TokenScope(document="expenses", permissions=["read"]),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
def test_authenticate_valid_token(sample_config):
|
|
auth = Authenticator(sample_config)
|
|
agent = auth.authenticate("valid-token")
|
|
|
|
assert agent.name == "test-agent"
|
|
assert agent.token == "valid-token"
|
|
|
|
|
|
def test_authenticate_invalid_token(sample_config):
|
|
auth = Authenticator(sample_config)
|
|
|
|
with pytest.raises(AuthError, match="Invalid token"):
|
|
auth.authenticate("bad-token")
|
|
|
|
|
|
def test_authorize_allowed_document_and_permission(sample_config):
|
|
auth = Authenticator(sample_config)
|
|
agent = auth.authenticate("valid-token")
|
|
|
|
# Should not raise
|
|
auth.authorize(agent, "budget", Permission.READ)
|
|
auth.authorize(agent, "budget", Permission.WRITE)
|
|
auth.authorize(agent, "expenses", Permission.READ)
|
|
|
|
|
|
def test_authorize_denied_document(sample_config):
|
|
auth = Authenticator(sample_config)
|
|
agent = auth.authenticate("valid-token")
|
|
|
|
with pytest.raises(AuthError, match="Document not in scope"):
|
|
auth.authorize(agent, "unknown-doc", Permission.READ)
|
|
|
|
|
|
def test_authorize_denied_permission(sample_config):
|
|
auth = Authenticator(sample_config)
|
|
agent = auth.authenticate("valid-token")
|
|
|
|
# expenses only has read permission
|
|
with pytest.raises(AuthError, match="Permission denied"):
|
|
auth.authorize(agent, "expenses", Permission.WRITE)
|
|
|
|
|
|
def test_get_accessible_documents(sample_config):
|
|
auth = Authenticator(sample_config)
|
|
agent = auth.authenticate("valid-token")
|
|
|
|
docs = auth.get_accessible_documents(agent)
|
|
|
|
assert len(docs) == 2
|
|
assert {"name": "budget", "permissions": ["read", "write"]} in docs
|
|
assert {"name": "expenses", "permissions": ["read"]} in docs
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `uv run pytest tests/test_auth.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.auth'"
|
|
|
|
**Step 3: Implement auth module**
|
|
|
|
```python
|
|
# src/grist_mcp/auth.py
|
|
"""Authentication and authorization."""
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
from grist_mcp.config import Config, Token
|
|
|
|
|
|
class Permission(Enum):
|
|
"""Document permission levels."""
|
|
READ = "read"
|
|
WRITE = "write"
|
|
SCHEMA = "schema"
|
|
|
|
|
|
class AuthError(Exception):
|
|
"""Authentication or authorization error."""
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class Agent:
|
|
"""An authenticated agent."""
|
|
token: str
|
|
name: str
|
|
_token_obj: Token
|
|
|
|
|
|
class Authenticator:
|
|
"""Handles token validation and permission checking."""
|
|
|
|
def __init__(self, config: Config):
|
|
self._config = config
|
|
self._token_map = {t.token: t for t in config.tokens}
|
|
|
|
def authenticate(self, token: str) -> Agent:
|
|
"""Validate token and return Agent object."""
|
|
token_obj = self._token_map.get(token)
|
|
if token_obj is None:
|
|
raise AuthError("Invalid token")
|
|
|
|
return Agent(
|
|
token=token,
|
|
name=token_obj.name,
|
|
_token_obj=token_obj,
|
|
)
|
|
|
|
def authorize(self, agent: Agent, document: str, permission: Permission) -> None:
|
|
"""Check if agent has permission on document. Raises AuthError if not."""
|
|
# Find the scope entry for this document
|
|
scope_entry = None
|
|
for scope in agent._token_obj.scope:
|
|
if scope.document == document:
|
|
scope_entry = scope
|
|
break
|
|
|
|
if scope_entry is None:
|
|
raise AuthError("Document not in scope")
|
|
|
|
if permission.value not in scope_entry.permissions:
|
|
raise AuthError("Permission denied")
|
|
|
|
def get_accessible_documents(self, agent: Agent) -> list[dict]:
|
|
"""Return list of documents agent can access with their permissions."""
|
|
return [
|
|
{"name": scope.document, "permissions": scope.permissions}
|
|
for scope in agent._token_obj.scope
|
|
]
|
|
|
|
def get_document(self, document_name: str):
|
|
"""Get document config by name."""
|
|
return self._config.documents.get(document_name)
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `uv run pytest tests/test_auth.py -v`
|
|
Expected: All 6 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/auth.py tests/test_auth.py
|
|
git commit -m "feat: add authentication and authorization"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Grist API Client
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/grist_client.py`
|
|
- Create: `tests/test_grist_client.py`
|
|
|
|
**Step 1: Write failing tests for Grist client**
|
|
|
|
```python
|
|
# tests/test_grist_client.py
|
|
import pytest
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from grist_mcp.grist_client import GristClient
|
|
from grist_mcp.config import Document
|
|
|
|
|
|
@pytest.fixture
|
|
def doc():
|
|
return Document(
|
|
url="https://grist.example.com",
|
|
doc_id="abc123",
|
|
api_key="test-api-key",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(doc):
|
|
return GristClient(doc)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tables(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables",
|
|
json={"tables": [{"id": "Table1"}, {"id": "Table2"}]},
|
|
)
|
|
|
|
tables = await client.list_tables()
|
|
|
|
assert tables == ["Table1", "Table2"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_describe_table(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
|
|
json={
|
|
"columns": [
|
|
{"id": "Name", "fields": {"type": "Text", "formula": ""}},
|
|
{"id": "Amount", "fields": {"type": "Numeric", "formula": "$Price * $Qty"}},
|
|
]
|
|
},
|
|
)
|
|
|
|
columns = await client.describe_table("Table1")
|
|
|
|
assert len(columns) == 2
|
|
assert columns[0] == {"id": "Name", "type": "Text", "formula": ""}
|
|
assert columns[1] == {"id": "Amount", "type": "Numeric", "formula": "$Price * $Qty"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_records(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/records",
|
|
json={
|
|
"records": [
|
|
{"id": 1, "fields": {"Name": "Alice", "Amount": 100}},
|
|
{"id": 2, "fields": {"Name": "Bob", "Amount": 200}},
|
|
]
|
|
},
|
|
)
|
|
|
|
records = await client.get_records("Table1")
|
|
|
|
assert len(records) == 2
|
|
assert records[0] == {"id": 1, "Name": "Alice", "Amount": 100}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_records(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/records",
|
|
method="POST",
|
|
json={"records": [{"id": 3}, {"id": 4}]},
|
|
)
|
|
|
|
ids = await client.add_records("Table1", [
|
|
{"Name": "Charlie", "Amount": 300},
|
|
{"Name": "Diana", "Amount": 400},
|
|
])
|
|
|
|
assert ids == [3, 4]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_records(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/records",
|
|
method="PATCH",
|
|
json={},
|
|
)
|
|
|
|
# Should not raise
|
|
await client.update_records("Table1", [
|
|
{"id": 1, "fields": {"Amount": 150}},
|
|
])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_records(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/data/delete",
|
|
method="POST",
|
|
json={},
|
|
)
|
|
|
|
# Should not raise
|
|
await client.delete_records("Table1", [1, 2])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sql_query(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/sql",
|
|
method="GET",
|
|
json={
|
|
"statement": "SELECT * FROM Table1",
|
|
"records": [
|
|
{"fields": {"Name": "Alice", "Amount": 100}},
|
|
],
|
|
},
|
|
)
|
|
|
|
result = await client.sql_query("SELECT * FROM Table1")
|
|
|
|
assert result == [{"Name": "Alice", "Amount": 100}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_table(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables",
|
|
method="POST",
|
|
json={"tables": [{"id": "NewTable"}]},
|
|
)
|
|
|
|
table_id = await client.create_table("NewTable", [
|
|
{"id": "Col1", "type": "Text"},
|
|
{"id": "Col2", "type": "Numeric"},
|
|
])
|
|
|
|
assert table_id == "NewTable"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_column(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
|
|
method="POST",
|
|
json={"columns": [{"id": "NewCol"}]},
|
|
)
|
|
|
|
col_id = await client.add_column("Table1", "NewCol", "Text", formula=None)
|
|
|
|
assert col_id == "NewCol"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_modify_column(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns/Amount",
|
|
method="PATCH",
|
|
json={},
|
|
)
|
|
|
|
# Should not raise
|
|
await client.modify_column("Table1", "Amount", type="Int", formula="$Price * $Qty")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_column(client, httpx_mock: HTTPXMock):
|
|
httpx_mock.add_response(
|
|
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns/OldCol",
|
|
method="DELETE",
|
|
json={},
|
|
)
|
|
|
|
# Should not raise
|
|
await client.delete_column("Table1", "OldCol")
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `uv run pytest tests/test_grist_client.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.grist_client'"
|
|
|
|
**Step 3: Implement Grist client**
|
|
|
|
```python
|
|
# src/grist_mcp/grist_client.py
|
|
"""Grist API client."""
|
|
|
|
import httpx
|
|
|
|
from grist_mcp.config import Document
|
|
|
|
|
|
class GristClient:
|
|
"""Async client for Grist API operations."""
|
|
|
|
def __init__(self, document: Document):
|
|
self._doc = document
|
|
self._base_url = f"{document.url.rstrip('/')}/api/docs/{document.doc_id}"
|
|
self._headers = {"Authorization": f"Bearer {document.api_key}"}
|
|
|
|
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
"""Make an authenticated request to Grist API."""
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.request(
|
|
method,
|
|
f"{self._base_url}{path}",
|
|
headers=self._headers,
|
|
**kwargs,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json() if response.content else {}
|
|
|
|
# Read operations
|
|
|
|
async def list_tables(self) -> list[str]:
|
|
"""List all tables in the document."""
|
|
data = await self._request("GET", "/tables")
|
|
return [t["id"] for t in data.get("tables", [])]
|
|
|
|
async def describe_table(self, table: str) -> list[dict]:
|
|
"""Get column information for a table."""
|
|
data = await self._request("GET", f"/tables/{table}/columns")
|
|
return [
|
|
{
|
|
"id": col["id"],
|
|
"type": col["fields"].get("type", "Any"),
|
|
"formula": col["fields"].get("formula", ""),
|
|
}
|
|
for col in data.get("columns", [])
|
|
]
|
|
|
|
async def get_records(
|
|
self,
|
|
table: str,
|
|
filter: dict | None = None,
|
|
sort: str | None = None,
|
|
limit: int | None = None,
|
|
) -> list[dict]:
|
|
"""Fetch records from a table."""
|
|
params = {}
|
|
if filter:
|
|
params["filter"] = filter
|
|
if sort:
|
|
params["sort"] = sort
|
|
if limit:
|
|
params["limit"] = limit
|
|
|
|
data = await self._request("GET", f"/tables/{table}/records", params=params)
|
|
|
|
return [
|
|
{"id": r["id"], **r["fields"]}
|
|
for r in data.get("records", [])
|
|
]
|
|
|
|
async def sql_query(self, sql: str) -> list[dict]:
|
|
"""Run a read-only SQL query."""
|
|
data = await self._request("GET", "/sql", params={"q": sql})
|
|
return [r["fields"] for r in data.get("records", [])]
|
|
|
|
# Write operations
|
|
|
|
async def add_records(self, table: str, records: list[dict]) -> list[int]:
|
|
"""Add records to a table. Returns list of new record IDs."""
|
|
payload = {
|
|
"records": [{"fields": r} for r in records]
|
|
}
|
|
data = await self._request("POST", f"/tables/{table}/records", json=payload)
|
|
return [r["id"] for r in data.get("records", [])]
|
|
|
|
async def update_records(self, table: str, records: list[dict]) -> None:
|
|
"""Update records. Each record must have 'id' and 'fields' keys."""
|
|
payload = {"records": records}
|
|
await self._request("PATCH", f"/tables/{table}/records", json=payload)
|
|
|
|
async def delete_records(self, table: str, record_ids: list[int]) -> None:
|
|
"""Delete records by ID."""
|
|
await self._request("POST", f"/tables/{table}/data/delete", json=record_ids)
|
|
|
|
# Schema operations
|
|
|
|
async def create_table(self, table_id: str, columns: list[dict]) -> str:
|
|
"""Create a new table with columns. Returns table ID."""
|
|
payload = {
|
|
"tables": [{
|
|
"id": table_id,
|
|
"columns": [
|
|
{"id": c["id"], "fields": {"type": c["type"]}}
|
|
for c in columns
|
|
],
|
|
}]
|
|
}
|
|
data = await self._request("POST", "/tables", json=payload)
|
|
return data["tables"][0]["id"]
|
|
|
|
async def add_column(
|
|
self,
|
|
table: str,
|
|
column_id: str,
|
|
column_type: str,
|
|
formula: str | None = None,
|
|
) -> str:
|
|
"""Add a column to a table. Returns column ID."""
|
|
fields = {"type": column_type}
|
|
if formula:
|
|
fields["formula"] = formula
|
|
|
|
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
|
data = await self._request("POST", f"/tables/{table}/columns", json=payload)
|
|
return data["columns"][0]["id"]
|
|
|
|
async def modify_column(
|
|
self,
|
|
table: str,
|
|
column_id: str,
|
|
type: str | None = None,
|
|
formula: str | None = None,
|
|
) -> None:
|
|
"""Modify a column's type or formula."""
|
|
fields = {}
|
|
if type is not None:
|
|
fields["type"] = type
|
|
if formula is not None:
|
|
fields["formula"] = formula
|
|
|
|
await self._request("PATCH", f"/tables/{table}/columns/{column_id}", json={"fields": fields})
|
|
|
|
async def delete_column(self, table: str, column_id: str) -> None:
|
|
"""Delete a column from a table."""
|
|
await self._request("DELETE", f"/tables/{table}/columns/{column_id}")
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `uv run pytest tests/test_grist_client.py -v`
|
|
Expected: All 12 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/grist_client.py tests/test_grist_client.py
|
|
git commit -m "feat: add Grist API client"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: MCP Tools - Discovery
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/tools/__init__.py`
|
|
- Create: `src/grist_mcp/tools/discovery.py`
|
|
- Create: `tests/test_tools_discovery.py`
|
|
|
|
**Step 1: Write failing test for list_documents tool**
|
|
|
|
```python
|
|
# tests/test_tools_discovery.py
|
|
import pytest
|
|
from grist_mcp.tools.discovery import list_documents
|
|
from grist_mcp.auth import Agent
|
|
from grist_mcp.config import Token, TokenScope
|
|
|
|
|
|
@pytest.fixture
|
|
def agent():
|
|
token_obj = Token(
|
|
token="test-token",
|
|
name="test-agent",
|
|
scope=[
|
|
TokenScope(document="budget", permissions=["read", "write"]),
|
|
TokenScope(document="expenses", permissions=["read"]),
|
|
],
|
|
)
|
|
return Agent(token="test-token", name="test-agent", _token_obj=token_obj)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_documents_returns_accessible_docs(agent):
|
|
result = await list_documents(agent)
|
|
|
|
assert result == {
|
|
"documents": [
|
|
{"name": "budget", "permissions": ["read", "write"]},
|
|
{"name": "expenses", "permissions": ["read"]},
|
|
]
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest tests/test_tools_discovery.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.tools'"
|
|
|
|
**Step 3: Implement discovery tools**
|
|
|
|
```python
|
|
# src/grist_mcp/tools/__init__.py
|
|
"""MCP tools for Grist operations."""
|
|
```
|
|
|
|
```python
|
|
# src/grist_mcp/tools/discovery.py
|
|
"""Discovery tools - list accessible documents."""
|
|
|
|
from grist_mcp.auth import Agent
|
|
|
|
|
|
async def list_documents(agent: Agent) -> dict:
|
|
"""List documents this agent can access with their permissions."""
|
|
documents = [
|
|
{"name": scope.document, "permissions": scope.permissions}
|
|
for scope in agent._token_obj.scope
|
|
]
|
|
return {"documents": documents}
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `uv run pytest tests/test_tools_discovery.py -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/tools/__init__.py src/grist_mcp/tools/discovery.py tests/test_tools_discovery.py
|
|
git commit -m "feat: add list_documents discovery tool"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: MCP Tools - Read Operations
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/tools/read.py`
|
|
- Create: `tests/test_tools_read.py`
|
|
|
|
**Step 1: Write failing tests for read tools**
|
|
|
|
```python
|
|
# tests/test_tools_read.py
|
|
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"}]}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `uv run pytest tests/test_tools_read.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.tools.read'"
|
|
|
|
**Step 3: Implement read tools**
|
|
|
|
```python
|
|
# src/grist_mcp/tools/read.py
|
|
"""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}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `uv run pytest tests/test_tools_read.py -v`
|
|
Expected: All 4 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/tools/read.py tests/test_tools_read.py
|
|
git commit -m "feat: add read tools (list_tables, describe_table, get_records, sql_query)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: MCP Tools - Write Operations
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/tools/write.py`
|
|
- Create: `tests/test_tools_write.py`
|
|
|
|
**Step 1: Write failing tests for write tools**
|
|
|
|
```python
|
|
# tests/test_tools_write.py
|
|
import pytest
|
|
from unittest.mock import AsyncMock
|
|
|
|
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
|
|
|
|
|
|
@pytest.fixture
|
|
def config():
|
|
return Config(
|
|
documents={
|
|
"budget": Document(
|
|
url="https://grist.example.com",
|
|
doc_id="abc123",
|
|
api_key="key",
|
|
),
|
|
},
|
|
tokens=[
|
|
Token(
|
|
token="write-token",
|
|
name="write-agent",
|
|
scope=[TokenScope(document="budget", permissions=["read", "write"])],
|
|
),
|
|
Token(
|
|
token="read-token",
|
|
name="read-agent",
|
|
scope=[TokenScope(document="budget", permissions=["read"])],
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def auth(config):
|
|
return Authenticator(config)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client():
|
|
client = AsyncMock()
|
|
client.add_records.return_value = [1, 2]
|
|
client.update_records.return_value = None
|
|
client.delete_records.return_value = None
|
|
return client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_records(auth, mock_client):
|
|
agent = auth.authenticate("write-token")
|
|
|
|
result = await add_records(
|
|
agent, auth, "budget", "Table1",
|
|
records=[{"Name": "Alice"}, {"Name": "Bob"}],
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"inserted_ids": [1, 2]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_records_denied_without_write(auth, mock_client):
|
|
agent = auth.authenticate("read-token")
|
|
|
|
with pytest.raises(AuthError, match="Permission denied"):
|
|
await add_records(
|
|
agent, auth, "budget", "Table1",
|
|
records=[{"Name": "Alice"}],
|
|
client=mock_client,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_records(auth, mock_client):
|
|
agent = auth.authenticate("write-token")
|
|
|
|
result = await update_records(
|
|
agent, auth, "budget", "Table1",
|
|
records=[{"id": 1, "fields": {"Name": "Updated"}}],
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"updated": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_records(auth, mock_client):
|
|
agent = auth.authenticate("write-token")
|
|
|
|
result = await delete_records(
|
|
agent, auth, "budget", "Table1",
|
|
record_ids=[1, 2],
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"deleted": True}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `uv run pytest tests/test_tools_write.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.tools.write'"
|
|
|
|
**Step 3: Implement write tools**
|
|
|
|
```python
|
|
# src/grist_mcp/tools/write.py
|
|
"""Write tools - create, update, delete records."""
|
|
|
|
from grist_mcp.auth import Agent, Authenticator, Permission
|
|
from grist_mcp.grist_client import GristClient
|
|
|
|
|
|
async def add_records(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table: str,
|
|
records: list[dict],
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Add records to a table."""
|
|
auth.authorize(agent, document, Permission.WRITE)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
ids = await client.add_records(table, records)
|
|
return {"inserted_ids": ids}
|
|
|
|
|
|
async def update_records(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table: str,
|
|
records: list[dict],
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Update existing records."""
|
|
auth.authorize(agent, document, Permission.WRITE)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
await client.update_records(table, records)
|
|
return {"updated": True}
|
|
|
|
|
|
async def delete_records(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table: str,
|
|
record_ids: list[int],
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Delete records by ID."""
|
|
auth.authorize(agent, document, Permission.WRITE)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
await client.delete_records(table, record_ids)
|
|
return {"deleted": True}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `uv run pytest tests/test_tools_write.py -v`
|
|
Expected: All 4 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/tools/write.py tests/test_tools_write.py
|
|
git commit -m "feat: add write tools (add_records, update_records, delete_records)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: MCP Tools - Schema Operations
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/tools/schema.py`
|
|
- Create: `tests/test_tools_schema.py`
|
|
|
|
**Step 1: Write failing tests for schema tools**
|
|
|
|
```python
|
|
# tests/test_tools_schema.py
|
|
import pytest
|
|
from unittest.mock import AsyncMock
|
|
|
|
from grist_mcp.tools.schema import create_table, add_column, modify_column, delete_column
|
|
from grist_mcp.auth import Authenticator, AuthError
|
|
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="schema-token",
|
|
name="schema-agent",
|
|
scope=[TokenScope(document="budget", permissions=["read", "write", "schema"])],
|
|
),
|
|
Token(
|
|
token="write-token",
|
|
name="write-agent",
|
|
scope=[TokenScope(document="budget", permissions=["read", "write"])],
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def auth(config):
|
|
return Authenticator(config)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client():
|
|
client = AsyncMock()
|
|
client.create_table.return_value = "NewTable"
|
|
client.add_column.return_value = "NewCol"
|
|
client.modify_column.return_value = None
|
|
client.delete_column.return_value = None
|
|
return client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_table(auth, mock_client):
|
|
agent = auth.authenticate("schema-token")
|
|
|
|
result = await create_table(
|
|
agent, auth, "budget", "NewTable",
|
|
columns=[{"id": "Name", "type": "Text"}],
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"table_id": "NewTable"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_table_denied_without_schema(auth, mock_client):
|
|
agent = auth.authenticate("write-token")
|
|
|
|
with pytest.raises(AuthError, match="Permission denied"):
|
|
await create_table(
|
|
agent, auth, "budget", "NewTable",
|
|
columns=[{"id": "Name", "type": "Text"}],
|
|
client=mock_client,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_column(auth, mock_client):
|
|
agent = auth.authenticate("schema-token")
|
|
|
|
result = await add_column(
|
|
agent, auth, "budget", "Table1", "NewCol", "Text",
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"column_id": "NewCol"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_modify_column(auth, mock_client):
|
|
agent = auth.authenticate("schema-token")
|
|
|
|
result = await modify_column(
|
|
agent, auth, "budget", "Table1", "Col1",
|
|
type="Int",
|
|
formula="$A + $B",
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"modified": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_column(auth, mock_client):
|
|
agent = auth.authenticate("schema-token")
|
|
|
|
result = await delete_column(
|
|
agent, auth, "budget", "Table1", "OldCol",
|
|
client=mock_client,
|
|
)
|
|
|
|
assert result == {"deleted": True}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `uv run pytest tests/test_tools_schema.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.tools.schema'"
|
|
|
|
**Step 3: Implement schema tools**
|
|
|
|
```python
|
|
# src/grist_mcp/tools/schema.py
|
|
"""Schema tools - create and modify tables and columns."""
|
|
|
|
from grist_mcp.auth import Agent, Authenticator, Permission
|
|
from grist_mcp.grist_client import GristClient
|
|
|
|
|
|
async def create_table(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table_id: str,
|
|
columns: list[dict],
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Create a new table with columns."""
|
|
auth.authorize(agent, document, Permission.SCHEMA)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
created_id = await client.create_table(table_id, columns)
|
|
return {"table_id": created_id}
|
|
|
|
|
|
async def add_column(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table: str,
|
|
column_id: str,
|
|
column_type: str,
|
|
formula: str | None = None,
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Add a column to a table."""
|
|
auth.authorize(agent, document, Permission.SCHEMA)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
created_id = await client.add_column(table, column_id, column_type, formula=formula)
|
|
return {"column_id": created_id}
|
|
|
|
|
|
async def modify_column(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table: str,
|
|
column_id: str,
|
|
type: str | None = None,
|
|
formula: str | None = None,
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Modify a column's type or formula."""
|
|
auth.authorize(agent, document, Permission.SCHEMA)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
await client.modify_column(table, column_id, type=type, formula=formula)
|
|
return {"modified": True}
|
|
|
|
|
|
async def delete_column(
|
|
agent: Agent,
|
|
auth: Authenticator,
|
|
document: str,
|
|
table: str,
|
|
column_id: str,
|
|
client: GristClient | None = None,
|
|
) -> dict:
|
|
"""Delete a column from a table."""
|
|
auth.authorize(agent, document, Permission.SCHEMA)
|
|
|
|
if client is None:
|
|
doc = auth.get_document(document)
|
|
client = GristClient(doc)
|
|
|
|
await client.delete_column(table, column_id)
|
|
return {"deleted": True}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `uv run pytest tests/test_tools_schema.py -v`
|
|
Expected: All 5 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/tools/schema.py tests/test_tools_schema.py
|
|
git commit -m "feat: add schema tools (create_table, add_column, modify_column, delete_column)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: MCP Server Entry Point
|
|
|
|
**Files:**
|
|
- Create: `src/grist_mcp/server.py`
|
|
- Create: `src/grist_mcp/main.py`
|
|
- Create: `tests/test_server.py`
|
|
|
|
**Step 1: Write failing test for server**
|
|
|
|
```python
|
|
# tests/test_server.py
|
|
import pytest
|
|
from grist_mcp.server import create_server
|
|
|
|
|
|
def test_create_server_registers_tools(tmp_path):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("""
|
|
documents:
|
|
test-doc:
|
|
url: https://grist.example.com
|
|
doc_id: abc123
|
|
api_key: test-key
|
|
|
|
tokens:
|
|
- token: test-token
|
|
name: test-agent
|
|
scope:
|
|
- document: test-doc
|
|
permissions: [read, write, schema]
|
|
""")
|
|
|
|
server = create_server(str(config_file))
|
|
|
|
# Server should have tools registered
|
|
assert server is not None
|
|
# Check tool names are registered
|
|
tool_names = [t.name for t in server.list_tools()]
|
|
assert "list_documents" in tool_names
|
|
assert "list_tables" in tool_names
|
|
assert "get_records" in tool_names
|
|
assert "add_records" in tool_names
|
|
assert "create_table" in tool_names
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `uv run pytest tests/test_server.py -v`
|
|
Expected: FAIL with "ModuleNotFoundError: No module named 'grist_mcp.server'"
|
|
|
|
**Step 3: Implement server module**
|
|
|
|
```python
|
|
# src/grist_mcp/server.py
|
|
"""MCP server setup and tool registration."""
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import Tool, TextContent
|
|
|
|
from grist_mcp.config import load_config
|
|
from grist_mcp.auth import Authenticator, AuthError, Agent
|
|
|
|
from grist_mcp.tools.discovery import list_documents as _list_documents
|
|
from grist_mcp.tools.read import list_tables as _list_tables
|
|
from grist_mcp.tools.read import describe_table as _describe_table
|
|
from grist_mcp.tools.read import get_records as _get_records
|
|
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.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
|
|
from grist_mcp.tools.schema import delete_column as _delete_column
|
|
|
|
|
|
def create_server(config_path: str) -> Server:
|
|
"""Create and configure the MCP server."""
|
|
config = load_config(config_path)
|
|
auth = Authenticator(config)
|
|
server = Server("grist-mcp")
|
|
|
|
# Current agent context (set during authentication)
|
|
_current_agent: Agent | None = None
|
|
|
|
@server.list_tools()
|
|
async def list_tools() -> list[Tool]:
|
|
return [
|
|
Tool(
|
|
name="list_documents",
|
|
description="List documents this agent can access with their permissions",
|
|
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
),
|
|
Tool(
|
|
name="list_tables",
|
|
description="List all tables in a document",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"document": {"type": "string", "description": "Document name"}},
|
|
"required": ["document"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="describe_table",
|
|
description="Get column information for a table",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
},
|
|
"required": ["document", "table"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="get_records",
|
|
description="Fetch records from a table",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"filter": {"type": "object"},
|
|
"sort": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["document", "table"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="sql_query",
|
|
description="Run a read-only SQL query against a document",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"query": {"type": "string"},
|
|
},
|
|
"required": ["document", "query"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="add_records",
|
|
description="Add records to a table",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"records": {"type": "array", "items": {"type": "object"}},
|
|
},
|
|
"required": ["document", "table", "records"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="update_records",
|
|
description="Update existing records",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"records": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer"},
|
|
"fields": {"type": "object"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"required": ["document", "table", "records"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="delete_records",
|
|
description="Delete records by ID",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"record_ids": {"type": "array", "items": {"type": "integer"}},
|
|
},
|
|
"required": ["document", "table", "record_ids"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="create_table",
|
|
description="Create a new table with columns",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table_id": {"type": "string"},
|
|
"columns": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"type": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"required": ["document", "table_id", "columns"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="add_column",
|
|
description="Add a column to a table",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"column_id": {"type": "string"},
|
|
"column_type": {"type": "string"},
|
|
"formula": {"type": "string"},
|
|
},
|
|
"required": ["document", "table", "column_id", "column_type"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="modify_column",
|
|
description="Modify a column's type or formula",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"column_id": {"type": "string"},
|
|
"type": {"type": "string"},
|
|
"formula": {"type": "string"},
|
|
},
|
|
"required": ["document", "table", "column_id"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="delete_column",
|
|
description="Delete a column from a table",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"document": {"type": "string"},
|
|
"table": {"type": "string"},
|
|
"column_id": {"type": "string"},
|
|
},
|
|
"required": ["document", "table", "column_id"],
|
|
},
|
|
),
|
|
]
|
|
|
|
@server.call_tool()
|
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
nonlocal _current_agent
|
|
|
|
if _current_agent is None:
|
|
return [TextContent(type="text", text="Error: Not authenticated")]
|
|
|
|
try:
|
|
if name == "list_documents":
|
|
result = await _list_documents(_current_agent)
|
|
elif name == "list_tables":
|
|
result = await _list_tables(_current_agent, auth, arguments["document"])
|
|
elif name == "describe_table":
|
|
result = await _describe_table(
|
|
_current_agent, auth, arguments["document"], arguments["table"]
|
|
)
|
|
elif name == "get_records":
|
|
result = await _get_records(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
filter=arguments.get("filter"),
|
|
sort=arguments.get("sort"),
|
|
limit=arguments.get("limit"),
|
|
)
|
|
elif name == "sql_query":
|
|
result = await _sql_query(
|
|
_current_agent, auth, arguments["document"], arguments["query"]
|
|
)
|
|
elif name == "add_records":
|
|
result = await _add_records(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
arguments["records"],
|
|
)
|
|
elif name == "update_records":
|
|
result = await _update_records(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
arguments["records"],
|
|
)
|
|
elif name == "delete_records":
|
|
result = await _delete_records(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
arguments["record_ids"],
|
|
)
|
|
elif name == "create_table":
|
|
result = await _create_table(
|
|
_current_agent, auth, arguments["document"], arguments["table_id"],
|
|
arguments["columns"],
|
|
)
|
|
elif name == "add_column":
|
|
result = await _add_column(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
arguments["column_id"], arguments["column_type"],
|
|
formula=arguments.get("formula"),
|
|
)
|
|
elif name == "modify_column":
|
|
result = await _modify_column(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
arguments["column_id"],
|
|
type=arguments.get("type"),
|
|
formula=arguments.get("formula"),
|
|
)
|
|
elif name == "delete_column":
|
|
result = await _delete_column(
|
|
_current_agent, auth, arguments["document"], arguments["table"],
|
|
arguments["column_id"],
|
|
)
|
|
else:
|
|
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
|
|
import json
|
|
return [TextContent(type="text", text=json.dumps(result))]
|
|
|
|
except AuthError as e:
|
|
return [TextContent(type="text", text=f"Authorization error: {e}")]
|
|
except Exception as e:
|
|
return [TextContent(type="text", text=f"Error: {e}")]
|
|
|
|
# Store auth for external access
|
|
server._auth = auth
|
|
server._set_agent = lambda agent: setattr(server, '_current_agent', agent) or setattr(type(server), '_current_agent', agent)
|
|
|
|
return server
|
|
```
|
|
|
|
```python
|
|
# src/grist_mcp/main.py
|
|
"""Main entry point for the MCP server."""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
|
|
from mcp.server.stdio import stdio_server
|
|
|
|
from grist_mcp.server import create_server
|
|
|
|
|
|
async def main():
|
|
config_path = os.environ.get("CONFIG_PATH", "/app/config.yaml")
|
|
|
|
if not os.path.exists(config_path):
|
|
print(f"Error: Config file not found at {config_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
server = create_server(config_path)
|
|
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `uv run pytest tests/test_server.py -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/grist_mcp/server.py src/grist_mcp/main.py tests/test_server.py
|
|
git commit -m "feat: add MCP server with all tools registered"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Docker Setup
|
|
|
|
**Files:**
|
|
- Create: `Dockerfile`
|
|
- Create: `docker-compose.yaml`
|
|
- Create: `.env.example`
|
|
|
|
**Step 1: Create Dockerfile**
|
|
|
|
```dockerfile
|
|
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
|
|
|
WORKDIR /app
|
|
|
|
# Copy dependency files
|
|
COPY pyproject.toml uv.lock ./
|
|
|
|
# Install dependencies
|
|
RUN uv sync --frozen --no-dev
|
|
|
|
# Copy source code
|
|
COPY src/ ./src/
|
|
|
|
# Default config path
|
|
ENV CONFIG_PATH=/app/config.yaml
|
|
|
|
# Run the server
|
|
CMD ["uv", "run", "python", "-m", "grist_mcp.main"]
|
|
```
|
|
|
|
**Step 2: Create docker-compose.yaml**
|
|
|
|
```yaml
|
|
services:
|
|
grist-mcp:
|
|
build: .
|
|
ports:
|
|
- "8080:8080"
|
|
volumes:
|
|
- ./config.yaml:/app/config.yaml:ro
|
|
env_file:
|
|
- .env
|
|
environment:
|
|
- CONFIG_PATH=/app/config.yaml
|
|
```
|
|
|
|
**Step 3: Create .env.example**
|
|
|
|
```bash
|
|
# Grist API keys - replace with your actual keys
|
|
GRIST_WORK_API_KEY=your-work-api-key-here
|
|
GRIST_PERSONAL_API_KEY=your-personal-api-key-here
|
|
```
|
|
|
|
**Step 4: Build and verify Docker image**
|
|
|
|
Run: `docker build -t grist-mcp:latest .`
|
|
Expected: Build completes successfully
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add Dockerfile docker-compose.yaml .env.example
|
|
git commit -m "feat: add Docker configuration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Tests Init and Final Verification
|
|
|
|
**Files:**
|
|
- Create: `tests/__init__.py`
|
|
- Create: `tests/conftest.py`
|
|
|
|
**Step 1: Create test init and conftest**
|
|
|
|
```python
|
|
# tests/__init__.py
|
|
"""Test suite for grist-mcp."""
|
|
```
|
|
|
|
```python
|
|
# tests/conftest.py
|
|
"""Shared test fixtures."""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_config_yaml():
|
|
return """
|
|
documents:
|
|
test-doc:
|
|
url: https://grist.example.com
|
|
doc_id: abc123
|
|
api_key: test-key
|
|
|
|
tokens:
|
|
- token: test-token
|
|
name: test-agent
|
|
scope:
|
|
- document: test-doc
|
|
permissions: [read, write, schema]
|
|
"""
|
|
```
|
|
|
|
**Step 2: Run full test suite**
|
|
|
|
Run: `uv run pytest -v`
|
|
Expected: All tests PASS (should be ~30 tests)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/__init__.py tests/conftest.py
|
|
git commit -m "test: add test configuration and run full suite"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
**Total Tasks:** 11
|
|
**Total Tests:** ~31
|
|
**Commits:** 11
|
|
|
|
**Implementation order:**
|
|
1. Project setup (pyproject.toml, uv)
|
|
2. Config parsing with env var substitution
|
|
3. Authentication and authorization
|
|
4. Grist API client
|
|
5. Discovery tools (list_documents)
|
|
6. Read tools (list_tables, describe_table, get_records, sql_query)
|
|
7. Write tools (add_records, update_records, delete_records)
|
|
8. Schema tools (create_table, add_column, modify_column, delete_column)
|
|
9. MCP server entry point
|
|
10. Docker configuration
|
|
11. Final test verification
|