Files
grist-mcp-server/docs/plans/2025-12-30-pre-deployment-testing-impl.md

27 KiB

Pre-Deployment Testing Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Create a pre-deployment test pipeline with Makefile orchestration, mock Grist server, and MCP protocol integration tests.

Architecture: Makefile orchestrates unit tests, Docker builds, and integration tests. Integration tests use the MCP Python SDK to connect to the containerized grist-mcp server, which talks to a mock Grist API server. Both run in docker-compose on an isolated network.

Tech Stack: Python 3.14, pytest, MCP SDK, Starlette (mock server), Docker Compose, Make


Task 1: Add Health Endpoint to grist-mcp

The integration tests need to poll for service readiness. Add a /health endpoint.

Files:

  • Modify: src/grist_mcp/main.py:42-47

Step 1: Add health endpoint to main.py

In src/grist_mcp/main.py, add a health route to the Starlette app:

from starlette.responses import JSONResponse

async def handle_health(request):
    return JSONResponse({"status": "ok"})

And add the route:

return Starlette(
    routes=[
        Route("/health", endpoint=handle_health),
        Route("/sse", endpoint=handle_sse),
        Route("/messages", endpoint=handle_messages, methods=["POST"]),
    ]
)

Step 2: Run existing tests

Run: uv run pytest tests/test_server.py -v Expected: PASS (health endpoint doesn't break existing tests)

Step 3: Commit

git add src/grist_mcp/main.py
git commit -m "feat: add /health endpoint for service readiness checks"

Task 2: Create Mock Grist Server

Files:

  • Create: tests/integration/mock_grist/__init__.py
  • Create: tests/integration/mock_grist/server.py
  • Create: tests/integration/mock_grist/Dockerfile
  • Create: tests/integration/mock_grist/requirements.txt

Step 1: Create directory structure

mkdir -p tests/integration/mock_grist

Step 2: Create requirements.txt

Create tests/integration/mock_grist/requirements.txt:

starlette>=0.41.0
uvicorn>=0.32.0

Step 3: Create init.py

Create empty tests/integration/mock_grist/__init__.py:

Step 4: Create server.py

Create tests/integration/mock_grist/server.py:

"""Mock Grist API server for integration testing."""

import json
import logging
import os
from datetime import datetime

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

logging.basicConfig(level=logging.INFO, format="%(asctime)s [MOCK-GRIST] %(message)s")
logger = logging.getLogger(__name__)

# Mock data
MOCK_TABLES = {
    "People": {
        "columns": [
            {"id": "Name", "fields": {"type": "Text"}},
            {"id": "Age", "fields": {"type": "Int"}},
            {"id": "Email", "fields": {"type": "Text"}},
        ],
        "records": [
            {"id": 1, "fields": {"Name": "Alice", "Age": 30, "Email": "alice@example.com"}},
            {"id": 2, "fields": {"Name": "Bob", "Age": 25, "Email": "bob@example.com"}},
        ],
    },
    "Tasks": {
        "columns": [
            {"id": "Title", "fields": {"type": "Text"}},
            {"id": "Done", "fields": {"type": "Bool"}},
        ],
        "records": [
            {"id": 1, "fields": {"Title": "Write tests", "Done": False}},
            {"id": 2, "fields": {"Title": "Deploy", "Done": False}},
        ],
    },
}

# Track requests for test assertions
request_log: list[dict] = []


def log_request(method: str, path: str, body: dict | None = None):
    """Log a request for later inspection."""
    entry = {
        "timestamp": datetime.utcnow().isoformat(),
        "method": method,
        "path": path,
        "body": body,
    }
    request_log.append(entry)
    logger.info(f"{method} {path}" + (f" body={json.dumps(body)}" if body else ""))


async def health(request):
    """Health check endpoint."""
    return JSONResponse({"status": "ok"})


async def get_request_log(request):
    """Return the request log for test assertions."""
    return JSONResponse(request_log)


async def clear_request_log(request):
    """Clear the request log."""
    request_log.clear()
    return JSONResponse({"status": "cleared"})


async def list_tables(request):
    """GET /api/docs/{doc_id}/tables"""
    doc_id = request.path_params["doc_id"]
    log_request("GET", f"/api/docs/{doc_id}/tables")
    tables = [{"id": name} for name in MOCK_TABLES.keys()]
    return JSONResponse({"tables": tables})


async def get_table_columns(request):
    """GET /api/docs/{doc_id}/tables/{table_id}/columns"""
    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}/columns")

    if table_id not in MOCK_TABLES:
        return JSONResponse({"error": "Table not found"}, status_code=404)

    return JSONResponse({"columns": MOCK_TABLES[table_id]["columns"]})


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

    if table_id not in MOCK_TABLES:
        return JSONResponse({"error": "Table not found"}, status_code=404)

    return JSONResponse({"records": MOCK_TABLES[table_id]["records"]})


async def add_records(request):
    """POST /api/docs/{doc_id}/tables/{table_id}/records"""
    doc_id = request.path_params["doc_id"]
    table_id = request.path_params["table_id"]
    body = await request.json()
    log_request("POST", f"/api/docs/{doc_id}/tables/{table_id}/records", body)

    # Return mock IDs for new records
    new_ids = [{"id": 100 + i} for i in range(len(body.get("records", [])))]
    return JSONResponse({"records": new_ids})


async def update_records(request):
    """PATCH /api/docs/{doc_id}/tables/{table_id}/records"""
    doc_id = request.path_params["doc_id"]
    table_id = request.path_params["table_id"]
    body = await request.json()
    log_request("PATCH", f"/api/docs/{doc_id}/tables/{table_id}/records", body)
    return JSONResponse({})


async def delete_records(request):
    """POST /api/docs/{doc_id}/tables/{table_id}/data/delete"""
    doc_id = request.path_params["doc_id"]
    table_id = request.path_params["table_id"]
    body = await request.json()
    log_request("POST", f"/api/docs/{doc_id}/tables/{table_id}/data/delete", body)
    return JSONResponse({})


async def sql_query(request):
    """GET /api/docs/{doc_id}/sql"""
    doc_id = request.path_params["doc_id"]
    query = request.query_params.get("q", "")
    log_request("GET", f"/api/docs/{doc_id}/sql?q={query}")

    # Return mock SQL results
    return JSONResponse({
        "records": [
            {"fields": {"Name": "Alice", "Age": 30}},
            {"fields": {"Name": "Bob", "Age": 25}},
        ]
    })


async def create_tables(request):
    """POST /api/docs/{doc_id}/tables"""
    doc_id = request.path_params["doc_id"]
    body = await request.json()
    log_request("POST", f"/api/docs/{doc_id}/tables", body)

    # Return the created tables with their IDs
    tables = [{"id": t["id"]} for t in body.get("tables", [])]
    return JSONResponse({"tables": tables})


async def add_column(request):
    """POST /api/docs/{doc_id}/tables/{table_id}/columns"""
    doc_id = request.path_params["doc_id"]
    table_id = request.path_params["table_id"]
    body = await request.json()
    log_request("POST", f"/api/docs/{doc_id}/tables/{table_id}/columns", body)

    columns = [{"id": c["id"]} for c in body.get("columns", [])]
    return JSONResponse({"columns": columns})


async def modify_column(request):
    """PATCH /api/docs/{doc_id}/tables/{table_id}/columns/{col_id}"""
    doc_id = request.path_params["doc_id"]
    table_id = request.path_params["table_id"]
    col_id = request.path_params["col_id"]
    body = await request.json()
    log_request("PATCH", f"/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}", body)
    return JSONResponse({})


async def delete_column(request):
    """DELETE /api/docs/{doc_id}/tables/{table_id}/columns/{col_id}"""
    doc_id = request.path_params["doc_id"]
    table_id = request.path_params["table_id"]
    col_id = request.path_params["col_id"]
    log_request("DELETE", f"/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}")
    return JSONResponse({})


app = Starlette(
    routes=[
        # Test control endpoints
        Route("/health", endpoint=health),
        Route("/_test/requests", endpoint=get_request_log),
        Route("/_test/requests/clear", endpoint=clear_request_log, methods=["POST"]),

        # Grist API endpoints
        Route("/api/docs/{doc_id}/tables", endpoint=list_tables),
        Route("/api/docs/{doc_id}/tables", endpoint=create_tables, methods=["POST"]),
        Route("/api/docs/{doc_id}/tables/{table_id}/columns", endpoint=get_table_columns),
        Route("/api/docs/{doc_id}/tables/{table_id}/columns", endpoint=add_column, methods=["POST"]),
        Route("/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}", endpoint=modify_column, methods=["PATCH"]),
        Route("/api/docs/{doc_id}/tables/{table_id}/columns/{col_id}", endpoint=delete_column, methods=["DELETE"]),
        Route("/api/docs/{doc_id}/tables/{table_id}/records", endpoint=get_records),
        Route("/api/docs/{doc_id}/tables/{table_id}/records", endpoint=add_records, methods=["POST"]),
        Route("/api/docs/{doc_id}/tables/{table_id}/records", endpoint=update_records, methods=["PATCH"]),
        Route("/api/docs/{doc_id}/tables/{table_id}/data/delete", endpoint=delete_records, methods=["POST"]),
        Route("/api/docs/{doc_id}/sql", endpoint=sql_query),
    ]
)


if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", "8484"))
    logger.info(f"Starting mock Grist server on port {port}")
    uvicorn.run(app, host="0.0.0.0", port=port)

Step 5: Create Dockerfile

Create tests/integration/mock_grist/Dockerfile:

FROM python:3.14-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .

ENV PORT=8484
EXPOSE 8484

CMD ["python", "server.py"]

Step 6: Commit

git add tests/integration/mock_grist/
git commit -m "feat: add mock Grist server for integration testing"

Task 3: Create Integration Test Configuration

Files:

  • Create: tests/integration/__init__.py
  • Create: tests/integration/config.test.yaml

Step 1: Create init.py

Create empty tests/integration/__init__.py:

Step 2: Create config.test.yaml

Create tests/integration/config.test.yaml:

documents:
  test-doc:
    url: http://mock-grist:8484
    doc_id: test-doc-id
    api_key: test-api-key

tokens:
  - token: test-token
    name: test-agent
    scope:
      - document: test-doc
        permissions: [read, write, schema]

Step 3: Commit

git add tests/integration/__init__.py tests/integration/config.test.yaml
git commit -m "feat: add integration test configuration"

Task 4: Create Docker Compose Test Configuration

Files:

  • Create: docker-compose.test.yaml

Step 1: Create docker-compose.test.yaml

Create docker-compose.test.yaml:

services:
  grist-mcp:
    build: .
    ports:
      - "3000:3000"
    environment:
      - CONFIG_PATH=/app/config.yaml
      - GRIST_MCP_TOKEN=test-token
      - PORT=3000
    volumes:
      - ./tests/integration/config.test.yaml:/app/config.yaml:ro
    depends_on:
      mock-grist:
        condition: service_started
    networks:
      - test-net
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
      interval: 5s
      timeout: 5s
      retries: 5

  mock-grist:
    build: tests/integration/mock_grist
    ports:
      - "8484:8484"
    environment:
      - PORT=8484
    networks:
      - test-net
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8484/health')"]
      interval: 5s
      timeout: 5s
      retries: 5

networks:
  test-net:
    driver: bridge

Step 2: Commit

git add docker-compose.test.yaml
git commit -m "feat: add docker-compose for integration testing"

Task 5: Create Integration Test Fixtures

Files:

  • Create: tests/integration/conftest.py

Step 1: Create conftest.py

Create tests/integration/conftest.py:

"""Fixtures for integration tests."""

import asyncio
import time

import httpx
import pytest
from mcp import ClientSession
from mcp.client.sse import sse_client


GRIST_MCP_URL = "http://localhost:3000"
MOCK_GRIST_URL = "http://localhost:8484"
MAX_WAIT_SECONDS = 30


def wait_for_service(url: str, timeout: int = MAX_WAIT_SECONDS) -> bool:
    """Wait for a service to become healthy."""
    start = time.time()
    while time.time() - start < timeout:
        try:
            response = httpx.get(f"{url}/health", timeout=2.0)
            if response.status_code == 200:
                return True
        except httpx.RequestError:
            pass
        time.sleep(0.5)
    return False


@pytest.fixture(scope="session")
def services_ready():
    """Ensure both services are healthy before running tests."""
    if not wait_for_service(MOCK_GRIST_URL):
        pytest.fail(f"Mock Grist server not ready at {MOCK_GRIST_URL}")
    if not wait_for_service(GRIST_MCP_URL):
        pytest.fail(f"grist-mcp server not ready at {GRIST_MCP_URL}")
    return True


@pytest.fixture
async def mcp_client(services_ready):
    """Create an MCP client connected to grist-mcp via SSE."""
    async with sse_client(f"{GRIST_MCP_URL}/sse") as (read_stream, write_stream):
        async with ClientSession(read_stream, write_stream) as session:
            await session.initialize()
            yield session


@pytest.fixture
def mock_grist_client(services_ready):
    """HTTP client for interacting with mock Grist test endpoints."""
    with httpx.Client(base_url=MOCK_GRIST_URL, timeout=10.0) as client:
        yield client


@pytest.fixture(autouse=True)
def clear_mock_grist_log(mock_grist_client):
    """Clear the mock Grist request log before each test."""
    mock_grist_client.post("/_test/requests/clear")
    yield

Step 2: Commit

git add tests/integration/conftest.py
git commit -m "feat: add integration test fixtures with MCP client"

Task 6: Create MCP Protocol Tests

Files:

  • Create: tests/integration/test_mcp_protocol.py

Step 1: Create test_mcp_protocol.py

Create tests/integration/test_mcp_protocol.py:

"""Test MCP protocol compliance over SSE transport."""

import pytest


@pytest.mark.asyncio
async def test_mcp_connection_initializes(mcp_client):
    """Test that MCP client can connect and initialize."""
    # If we get here, connection and initialization succeeded
    assert mcp_client is not None


@pytest.mark.asyncio
async def test_list_tools_returns_all_tools(mcp_client):
    """Test that list_tools returns all expected tools."""
    result = await mcp_client.list_tools()
    tool_names = [tool.name for tool in result.tools]

    expected_tools = [
        "list_documents",
        "list_tables",
        "describe_table",
        "get_records",
        "sql_query",
        "add_records",
        "update_records",
        "delete_records",
        "create_table",
        "add_column",
        "modify_column",
        "delete_column",
    ]

    for expected in expected_tools:
        assert expected in tool_names, f"Missing tool: {expected}"

    assert len(result.tools) == 12


@pytest.mark.asyncio
async def test_list_tools_has_descriptions(mcp_client):
    """Test that all tools have descriptions."""
    result = await mcp_client.list_tools()

    for tool in result.tools:
        assert tool.description, f"Tool {tool.name} has no description"
        assert len(tool.description) > 10, f"Tool {tool.name} description too short"


@pytest.mark.asyncio
async def test_list_tools_has_input_schemas(mcp_client):
    """Test that all tools have input schemas."""
    result = await mcp_client.list_tools()

    for tool in result.tools:
        assert tool.inputSchema is not None, f"Tool {tool.name} has no inputSchema"
        assert "type" in tool.inputSchema, f"Tool {tool.name} schema missing type"

Step 2: Commit

git add tests/integration/test_mcp_protocol.py
git commit -m "feat: add MCP protocol compliance tests"

Task 7: Create Tool Integration Tests

Files:

  • Create: tests/integration/test_tools_integration.py

Step 1: Create test_tools_integration.py

Create tests/integration/test_tools_integration.py:

"""Test tool calls through MCP client to verify Grist API interactions."""

import json

import pytest


@pytest.mark.asyncio
async def test_list_documents(mcp_client):
    """Test list_documents returns accessible documents."""
    result = await mcp_client.call_tool("list_documents", {})

    assert len(result.content) == 1
    data = json.loads(result.content[0].text)

    assert "documents" in data
    assert len(data["documents"]) == 1
    assert data["documents"][0]["name"] == "test-doc"
    assert "read" in data["documents"][0]["permissions"]


@pytest.mark.asyncio
async def test_list_tables(mcp_client, mock_grist_client):
    """Test list_tables calls correct Grist API endpoint."""
    result = await mcp_client.call_tool("list_tables", {"document": "test-doc"})

    # Check response
    data = json.loads(result.content[0].text)
    assert "tables" in data
    assert "People" in data["tables"]
    assert "Tasks" in data["tables"]

    # Verify mock received correct request
    log = mock_grist_client.get("/_test/requests").json()
    assert len(log) >= 1
    assert log[-1]["method"] == "GET"
    assert "/tables" in log[-1]["path"]


@pytest.mark.asyncio
async def test_describe_table(mcp_client, mock_grist_client):
    """Test describe_table returns column information."""
    result = await mcp_client.call_tool(
        "describe_table",
        {"document": "test-doc", "table": "People"}
    )

    data = json.loads(result.content[0].text)
    assert "columns" in data

    column_ids = [c["id"] for c in data["columns"]]
    assert "Name" in column_ids
    assert "Age" in column_ids

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    assert any("/columns" in entry["path"] for entry in log)


@pytest.mark.asyncio
async def test_get_records(mcp_client, mock_grist_client):
    """Test get_records fetches records from table."""
    result = await mcp_client.call_tool(
        "get_records",
        {"document": "test-doc", "table": "People"}
    )

    data = json.loads(result.content[0].text)
    assert "records" in data
    assert len(data["records"]) == 2
    assert data["records"][0]["Name"] == "Alice"

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    assert any("/records" in entry["path"] and entry["method"] == "GET" for entry in log)


@pytest.mark.asyncio
async def test_sql_query(mcp_client, mock_grist_client):
    """Test sql_query executes SQL and returns results."""
    result = await mcp_client.call_tool(
        "sql_query",
        {"document": "test-doc", "query": "SELECT Name, Age FROM People"}
    )

    data = json.loads(result.content[0].text)
    assert "records" in data
    assert len(data["records"]) >= 1

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    assert any("/sql" in entry["path"] for entry in log)


@pytest.mark.asyncio
async def test_add_records(mcp_client, mock_grist_client):
    """Test add_records sends correct payload to Grist."""
    new_records = [
        {"Name": "Charlie", "Age": 35, "Email": "charlie@example.com"}
    ]

    result = await mcp_client.call_tool(
        "add_records",
        {"document": "test-doc", "table": "People", "records": new_records}
    )

    data = json.loads(result.content[0].text)
    assert "record_ids" in data
    assert len(data["record_ids"]) == 1

    # Verify API call body
    log = mock_grist_client.get("/_test/requests").json()
    post_requests = [e for e in log if e["method"] == "POST" and "/records" in e["path"]]
    assert len(post_requests) >= 1
    assert post_requests[-1]["body"]["records"][0]["fields"]["Name"] == "Charlie"


@pytest.mark.asyncio
async def test_update_records(mcp_client, mock_grist_client):
    """Test update_records sends correct payload to Grist."""
    updates = [
        {"id": 1, "fields": {"Age": 31}}
    ]

    result = await mcp_client.call_tool(
        "update_records",
        {"document": "test-doc", "table": "People", "records": updates}
    )

    data = json.loads(result.content[0].text)
    assert "updated" in data

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    patch_requests = [e for e in log if e["method"] == "PATCH" and "/records" in e["path"]]
    assert len(patch_requests) >= 1


@pytest.mark.asyncio
async def test_delete_records(mcp_client, mock_grist_client):
    """Test delete_records sends correct IDs to Grist."""
    result = await mcp_client.call_tool(
        "delete_records",
        {"document": "test-doc", "table": "People", "record_ids": [1, 2]}
    )

    data = json.loads(result.content[0].text)
    assert "deleted" in data

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    delete_requests = [e for e in log if "/data/delete" in e["path"]]
    assert len(delete_requests) >= 1
    assert delete_requests[-1]["body"] == [1, 2]


@pytest.mark.asyncio
async def test_create_table(mcp_client, mock_grist_client):
    """Test create_table sends correct schema to Grist."""
    columns = [
        {"id": "Title", "type": "Text"},
        {"id": "Count", "type": "Int"},
    ]

    result = await mcp_client.call_tool(
        "create_table",
        {"document": "test-doc", "table_id": "NewTable", "columns": columns}
    )

    data = json.loads(result.content[0].text)
    assert "table_id" in data

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    post_tables = [e for e in log if e["method"] == "POST" and e["path"].endswith("/tables")]
    assert len(post_tables) >= 1


@pytest.mark.asyncio
async def test_add_column(mcp_client, mock_grist_client):
    """Test add_column sends correct column definition."""
    result = await mcp_client.call_tool(
        "add_column",
        {
            "document": "test-doc",
            "table": "People",
            "column_id": "Phone",
            "column_type": "Text",
        }
    )

    data = json.loads(result.content[0].text)
    assert "column_id" in data

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    post_cols = [e for e in log if e["method"] == "POST" and "/columns" in e["path"]]
    assert len(post_cols) >= 1


@pytest.mark.asyncio
async def test_modify_column(mcp_client, mock_grist_client):
    """Test modify_column sends correct update."""
    result = await mcp_client.call_tool(
        "modify_column",
        {
            "document": "test-doc",
            "table": "People",
            "column_id": "Age",
            "type": "Numeric",
        }
    )

    data = json.loads(result.content[0].text)
    assert "modified" in data

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    patch_cols = [e for e in log if e["method"] == "PATCH" and "/columns/" in e["path"]]
    assert len(patch_cols) >= 1


@pytest.mark.asyncio
async def test_delete_column(mcp_client, mock_grist_client):
    """Test delete_column calls correct endpoint."""
    result = await mcp_client.call_tool(
        "delete_column",
        {
            "document": "test-doc",
            "table": "People",
            "column_id": "Email",
        }
    )

    data = json.loads(result.content[0].text)
    assert "deleted" in data

    # Verify API call
    log = mock_grist_client.get("/_test/requests").json()
    delete_cols = [e for e in log if e["method"] == "DELETE" and "/columns/" in e["path"]]
    assert len(delete_cols) >= 1


@pytest.mark.asyncio
async def test_unauthorized_document_fails(mcp_client):
    """Test that accessing unauthorized document returns error."""
    result = await mcp_client.call_tool(
        "list_tables",
        {"document": "unauthorized-doc"}
    )

    assert "error" in result.content[0].text.lower() or "authorization" in result.content[0].text.lower()

Step 2: Commit

git add tests/integration/test_tools_integration.py
git commit -m "feat: add tool integration tests with Grist API validation"

Task 8: Create Makefile

Files:

  • Create: Makefile

Step 1: Create Makefile

Create Makefile:

.PHONY: help test build integration-up integration-test integration-down integration pre-deploy clean

# Default target
help: ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'

test: ## Run unit tests
	uv run pytest tests/ -v --ignore=tests/integration

build: ## Build Docker images for testing
	docker compose -f docker-compose.test.yaml build

integration-up: ## Start integration test containers
	docker compose -f docker-compose.test.yaml up -d
	@echo "Waiting for services to be ready..."
	@sleep 5

integration-test: ## Run integration tests (containers must be up)
	uv run pytest tests/integration/ -v

integration-down: ## Stop and remove test containers
	docker compose -f docker-compose.test.yaml down -v

integration: build integration-up ## Full integration cycle (build, up, test, down)
	@$(MAKE) integration-test || ($(MAKE) integration-down && exit 1)
	@$(MAKE) integration-down

pre-deploy: test integration ## Full pre-deployment pipeline (unit tests + integration)
	@echo "Pre-deployment checks passed!"

clean: ## Remove all test artifacts and containers
	docker compose -f docker-compose.test.yaml down -v --rmi local 2>/dev/null || true
	find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
	find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true

Step 2: Verify Makefile syntax

Run: make help Expected: List of available targets with descriptions

Step 3: Commit

git add Makefile
git commit -m "feat: add Makefile for test orchestration"

Task 9: Run Full Pre-Deploy Pipeline

Step 1: Run unit tests

Run: make test Expected: All unit tests pass

Step 2: Run full pre-deploy

Run: make pre-deploy Expected: Unit tests pass, Docker builds succeed, integration tests pass, containers cleaned up

Step 3: Commit any fixes needed

If any tests fail, fix them and commit:

git add -A
git commit -m "fix: resolve integration test issues"

Summary

Files created:

  • src/grist_mcp/main.py - Modified with /health endpoint
  • tests/integration/mock_grist/__init__.py
  • tests/integration/mock_grist/server.py
  • tests/integration/mock_grist/Dockerfile
  • tests/integration/mock_grist/requirements.txt
  • tests/integration/__init__.py
  • tests/integration/config.test.yaml
  • tests/integration/conftest.py
  • tests/integration/test_mcp_protocol.py
  • tests/integration/test_tools_integration.py
  • docker-compose.test.yaml
  • Makefile

Usage:

make help        # Show all targets
make test        # Unit tests only
make integration # Integration tests only
make pre-deploy  # Full pipeline
make clean       # Cleanup