Files
grist-mcp-server/docs/plans/2026-01-02-session-proxy-impl.md

1472 lines
42 KiB
Markdown

# Session Token Proxy Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Enable agents to delegate bulk data operations to scripts via short-lived session tokens and an HTTP proxy endpoint.
**Architecture:** Add `SessionTokenManager` for in-memory token storage, `get_proxy_documentation` and `request_session_token` MCP tools, and a `POST /api/v1/proxy` HTTP endpoint that validates session tokens and dispatches to existing tool functions.
**Tech Stack:** Python 3.14+, MCP SDK, httpx, pytest + pytest-asyncio
---
## Task 1: SessionTokenManager - Token Creation
**Files:**
- Create: `src/grist_mcp/session.py`
- Create: `tests/unit/test_session.py`
**Step 1: Write the failing test for token creation**
Create `tests/unit/test_session.py`:
```python
import pytest
from datetime import datetime, timedelta, timezone
from grist_mcp.session import SessionTokenManager, SessionToken
def test_create_token_returns_valid_session_token():
manager = SessionTokenManager()
token = manager.create_token(
agent_name="test-agent",
document="sales",
permissions=["read", "write"],
ttl_seconds=300,
)
assert token.token.startswith("sess_")
assert len(token.token) > 20
assert token.document == "sales"
assert token.permissions == ["read", "write"]
assert token.agent_name == "test-agent"
assert token.expires_at > datetime.now(timezone.utc)
assert token.expires_at < datetime.now(timezone.utc) + timedelta(seconds=310)
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py::test_create_token_returns_valid_session_token -v`
Expected: FAIL with "No module named 'grist_mcp.session'"
**Step 3: Write minimal implementation**
Create `src/grist_mcp/session.py`:
```python
"""Session token management for HTTP proxy access."""
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass
class SessionToken:
"""A short-lived session token for proxy access."""
token: str
document: str
permissions: list[str]
agent_name: str
created_at: datetime
expires_at: datetime
class SessionTokenManager:
"""Manages creation and validation of session tokens."""
def __init__(self):
self._tokens: dict[str, SessionToken] = {}
def create_token(
self,
agent_name: str,
document: str,
permissions: list[str],
ttl_seconds: int,
) -> SessionToken:
"""Create a new session token."""
now = datetime.now(timezone.utc)
token_str = f"sess_{secrets.token_urlsafe(32)}"
session = SessionToken(
token=token_str,
document=document,
permissions=permissions,
agent_name=agent_name,
created_at=now,
expires_at=now + timedelta(seconds=ttl_seconds),
)
self._tokens[token_str] = session
return session
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py::test_create_token_returns_valid_session_token -v`
Expected: PASS
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/session.py tests/unit/test_session.py
git commit -m "feat(session): add SessionTokenManager with token creation"
```
---
## Task 2: SessionTokenManager - TTL Capping
**Files:**
- Modify: `src/grist_mcp/session.py`
- Modify: `tests/unit/test_session.py`
**Step 1: Write the failing test for TTL capping**
Add to `tests/unit/test_session.py`:
```python
def test_create_token_caps_ttl_at_maximum():
manager = SessionTokenManager()
# Request 2 hours, should be capped at 1 hour
token = manager.create_token(
agent_name="test-agent",
document="sales",
permissions=["read"],
ttl_seconds=7200,
)
# Should be capped at 3600 seconds (1 hour)
max_expires = datetime.now(timezone.utc) + timedelta(seconds=3610)
assert token.expires_at < max_expires
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py::test_create_token_caps_ttl_at_maximum -v`
Expected: FAIL (token expires_at will be ~2 hours, not capped)
**Step 3: Update implementation to cap TTL**
In `src/grist_mcp/session.py`, update `create_token`:
```python
MAX_TTL_SECONDS = 3600 # 1 hour
DEFAULT_TTL_SECONDS = 300 # 5 minutes
```
Add at module level, then modify `create_token`:
```python
def create_token(
self,
agent_name: str,
document: str,
permissions: list[str],
ttl_seconds: int = DEFAULT_TTL_SECONDS,
) -> SessionToken:
"""Create a new session token.
TTL is capped at MAX_TTL_SECONDS (1 hour).
"""
now = datetime.now(timezone.utc)
token_str = f"sess_{secrets.token_urlsafe(32)}"
# Cap TTL at maximum
effective_ttl = min(ttl_seconds, MAX_TTL_SECONDS)
session = SessionToken(
token=token_str,
document=document,
permissions=permissions,
agent_name=agent_name,
created_at=now,
expires_at=now + timedelta(seconds=effective_ttl),
)
self._tokens[token_str] = session
return session
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py -v`
Expected: PASS (both tests)
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/session.py tests/unit/test_session.py
git commit -m "feat(session): cap TTL at 1 hour maximum"
```
---
## Task 3: SessionTokenManager - Token Validation
**Files:**
- Modify: `src/grist_mcp/session.py`
- Modify: `tests/unit/test_session.py`
**Step 1: Write the failing test for valid token**
Add to `tests/unit/test_session.py`:
```python
def test_validate_token_returns_session_for_valid_token():
manager = SessionTokenManager()
created = manager.create_token(
agent_name="test-agent",
document="sales",
permissions=["read"],
ttl_seconds=300,
)
session = manager.validate_token(created.token)
assert session is not None
assert session.document == "sales"
assert session.agent_name == "test-agent"
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py::test_validate_token_returns_session_for_valid_token -v`
Expected: FAIL with "SessionTokenManager has no attribute 'validate_token'"
**Step 3: Write minimal implementation**
Add to `SessionTokenManager` class in `src/grist_mcp/session.py`:
```python
def validate_token(self, token: str) -> SessionToken | None:
"""Validate a session token.
Returns the SessionToken if valid and not expired, None otherwise.
Also removes expired tokens lazily.
"""
session = self._tokens.get(token)
if session is None:
return None
now = datetime.now(timezone.utc)
if session.expires_at < now:
# Token expired, remove it
del self._tokens[token]
return None
return session
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py -v`
Expected: PASS (all tests)
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/session.py tests/unit/test_session.py
git commit -m "feat(session): add token validation"
```
---
## Task 4: SessionTokenManager - Invalid and Expired Token Handling
**Files:**
- Modify: `tests/unit/test_session.py`
**Step 1: Write failing tests for invalid and expired tokens**
Add to `tests/unit/test_session.py`:
```python
def test_validate_token_returns_none_for_unknown_token():
manager = SessionTokenManager()
session = manager.validate_token("sess_unknown_token")
assert session is None
def test_validate_token_returns_none_for_expired_token():
manager = SessionTokenManager()
created = manager.create_token(
agent_name="test-agent",
document="sales",
permissions=["read"],
ttl_seconds=1,
)
# Wait for expiry (we'll use time manipulation instead)
import time
time.sleep(1.1)
session = manager.validate_token(created.token)
assert session is None
```
**Step 2: Run tests to verify they pass**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_session.py -v`
Expected: PASS (implementation already handles these cases)
**Step 3: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add tests/unit/test_session.py
git commit -m "test(session): add tests for invalid and expired tokens"
```
---
## Task 5: get_proxy_documentation MCP Tool
**Files:**
- Create: `src/grist_mcp/tools/session.py`
- Create: `tests/unit/test_tools_session.py`
- Modify: `src/grist_mcp/server.py`
**Step 1: Write the failing test for documentation tool**
Create `tests/unit/test_tools_session.py`:
```python
import pytest
from grist_mcp.tools.session import get_proxy_documentation
@pytest.mark.asyncio
async def test_get_proxy_documentation_returns_complete_spec():
result = await get_proxy_documentation()
assert "description" in result
assert "endpoint" in result
assert result["endpoint"] == "POST /api/v1/proxy"
assert "authentication" in result
assert "methods" in result
assert "add_records" in result["methods"]
assert "get_records" in result["methods"]
assert "example_script" in result
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_tools_session.py::test_get_proxy_documentation_returns_complete_spec -v`
Expected: FAIL with "No module named 'grist_mcp.tools.session'"
**Step 3: Write the implementation**
Create `src/grist_mcp/tools/session.py`:
```python
"""Session token tools for HTTP proxy access."""
from grist_mcp.auth import Agent, Authenticator, AuthError
from grist_mcp.session import SessionTokenManager
PROXY_DOCUMENTATION = {
"description": "HTTP proxy API for bulk data operations. Use request_session_token to get a short-lived token, then call the proxy endpoint directly from scripts.",
"endpoint": "POST /api/v1/proxy",
"authentication": "Bearer token in Authorization header",
"request_format": {
"method": "Operation name (required)",
"table": "Table name (required for most operations)",
},
"methods": {
"get_records": {
"description": "Fetch records from a table",
"fields": {
"table": "string",
"filter": "object (optional)",
"sort": "string (optional)",
"limit": "integer (optional)",
},
},
"sql_query": {
"description": "Run a read-only SQL query",
"fields": {"query": "string"},
},
"list_tables": {
"description": "List all tables in the document",
"fields": {},
},
"describe_table": {
"description": "Get column information for a table",
"fields": {"table": "string"},
},
"add_records": {
"description": "Add records to a table",
"fields": {"table": "string", "records": "array of objects"},
},
"update_records": {
"description": "Update existing records",
"fields": {"table": "string", "records": "array of {id, fields}"},
},
"delete_records": {
"description": "Delete records by ID",
"fields": {"table": "string", "record_ids": "array of integers"},
},
"create_table": {
"description": "Create a new table",
"fields": {"table_id": "string", "columns": "array of {id, type}"},
},
"add_column": {
"description": "Add a column to a table",
"fields": {
"table": "string",
"column_id": "string",
"column_type": "string",
"formula": "string (optional)",
},
},
"modify_column": {
"description": "Modify a column's type or formula",
"fields": {
"table": "string",
"column_id": "string",
"type": "string (optional)",
"formula": "string (optional)",
},
},
"delete_column": {
"description": "Delete a column",
"fields": {"table": "string", "column_id": "string"},
},
},
"response_format": {
"success": {"success": True, "data": "..."},
"error": {"success": False, "error": "message", "code": "ERROR_CODE"},
},
"error_codes": [
"UNAUTHORIZED",
"INVALID_TOKEN",
"TOKEN_EXPIRED",
"INVALID_REQUEST",
"GRIST_ERROR",
],
"example_script": """#!/usr/bin/env python3
import requests
import sys
token = sys.argv[1]
host = sys.argv[2]
response = requests.post(
f'{host}/api/v1/proxy',
headers={'Authorization': f'Bearer {token}'},
json={
'method': 'add_records',
'table': 'Orders',
'records': [{'item': 'Widget', 'qty': 100}]
}
)
print(response.json())
""",
}
async def get_proxy_documentation() -> dict:
"""Return complete documentation for the HTTP proxy API."""
return PROXY_DOCUMENTATION
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_tools_session.py -v`
Expected: PASS
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/tools/session.py tests/unit/test_tools_session.py
git commit -m "feat(tools): add get_proxy_documentation tool"
```
---
## Task 6: request_session_token MCP Tool
**Files:**
- Modify: `src/grist_mcp/tools/session.py`
- Modify: `tests/unit/test_tools_session.py`
**Step 1: Write the failing test**
Add to `tests/unit/test_tools_session.py`:
```python
from grist_mcp.auth import Authenticator, Agent
from grist_mcp.config import Config, Document, Token, TokenScope
from grist_mcp.session import SessionTokenManager
from grist_mcp.tools.session import request_session_token
@pytest.fixture
def sample_config():
return Config(
documents={
"sales": Document(
url="https://grist.example.com",
doc_id="abc123",
api_key="key",
),
},
tokens=[
Token(
token="agent-token",
name="test-agent",
scope=[
TokenScope(document="sales", permissions=["read", "write"]),
],
),
],
)
@pytest.fixture
def auth_and_agent(sample_config):
auth = Authenticator(sample_config)
agent = auth.authenticate("agent-token")
return auth, agent
@pytest.mark.asyncio
async def test_request_session_token_creates_valid_token(auth_and_agent):
auth, agent = auth_and_agent
manager = SessionTokenManager()
result = await request_session_token(
agent=agent,
auth=auth,
token_manager=manager,
document="sales",
permissions=["read", "write"],
ttl_seconds=300,
)
assert "token" in result
assert result["token"].startswith("sess_")
assert result["document"] == "sales"
assert result["permissions"] == ["read", "write"]
assert "expires_at" in result
assert result["proxy_url"] == "/api/v1/proxy"
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_tools_session.py::test_request_session_token_creates_valid_token -v`
Expected: FAIL with "cannot import name 'request_session_token'"
**Step 3: Write the implementation**
Add to `src/grist_mcp/tools/session.py`:
```python
async def request_session_token(
agent: Agent,
auth: Authenticator,
token_manager: SessionTokenManager,
document: str,
permissions: list[str],
ttl_seconds: int = 300,
) -> dict:
"""Request a short-lived session token for HTTP proxy access.
The token can only grant permissions the agent already has.
"""
# Verify agent has access to the document
# Check each requested permission
from grist_mcp.auth import Permission
for perm_str in permissions:
try:
perm = Permission(perm_str)
except ValueError:
raise AuthError(f"Invalid permission: {perm_str}")
auth.authorize(agent, document, perm)
# Create the session token
session = token_manager.create_token(
agent_name=agent.name,
document=document,
permissions=permissions,
ttl_seconds=ttl_seconds,
)
return {
"token": session.token,
"document": session.document,
"permissions": session.permissions,
"expires_at": session.expires_at.isoformat(),
"proxy_url": "/api/v1/proxy",
}
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_tools_session.py -v`
Expected: PASS
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/tools/session.py tests/unit/test_tools_session.py
git commit -m "feat(tools): add request_session_token tool"
```
---
## Task 7: request_session_token Permission Validation
**Files:**
- Modify: `tests/unit/test_tools_session.py`
**Step 1: Write tests for permission denial**
Add to `tests/unit/test_tools_session.py`:
```python
from grist_mcp.auth import AuthError
@pytest.mark.asyncio
async def test_request_session_token_denies_escalation(auth_and_agent):
auth, agent = auth_and_agent
manager = SessionTokenManager()
# Agent only has read/write on sales, not schema
with pytest.raises(AuthError, match="Permission denied"):
await request_session_token(
agent=agent,
auth=auth,
token_manager=manager,
document="sales",
permissions=["schema"],
ttl_seconds=300,
)
@pytest.mark.asyncio
async def test_request_session_token_denies_unknown_document(auth_and_agent):
auth, agent = auth_and_agent
manager = SessionTokenManager()
with pytest.raises(AuthError, match="Document not in scope"):
await request_session_token(
agent=agent,
auth=auth,
token_manager=manager,
document="unknown",
permissions=["read"],
ttl_seconds=300,
)
```
**Step 2: Run tests to verify they pass**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_tools_session.py -v`
Expected: PASS (implementation already handles these via auth.authorize)
**Step 3: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add tests/unit/test_tools_session.py
git commit -m "test(tools): add permission validation tests for session token"
```
---
## Task 8: Register Session Tools in Server
**Files:**
- Modify: `src/grist_mcp/server.py`
- Modify: `tests/unit/test_server.py`
**Step 1: Write failing test for tool registration**
Add to `tests/unit/test_server.py`:
```python
@pytest.mark.asyncio
async def test_create_server_registers_session_tools(sample_config):
from grist_mcp.session import SessionTokenManager
auth = Authenticator(sample_config)
agent = auth.authenticate("valid-token")
token_manager = SessionTokenManager()
server = create_server(auth, agent, token_manager)
tools = await server.list_tools()
tool_names = [t.name for t in tools]
assert "get_proxy_documentation" in tool_names
assert "request_session_token" in tool_names
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_server.py::test_create_server_registers_session_tools -v`
Expected: FAIL (create_server doesn't accept token_manager yet)
**Step 3: Update server.py to accept token_manager and register tools**
Modify `src/grist_mcp/server.py`:
1. Add import at top:
```python
from grist_mcp.session import SessionTokenManager
from grist_mcp.tools.session import get_proxy_documentation as _get_proxy_documentation
from grist_mcp.tools.session import request_session_token as _request_session_token
```
2. Update function signature:
```python
def create_server(auth: Authenticator, agent: Agent, token_manager: SessionTokenManager | None = None) -> Server:
```
3. Add tools to list_tools():
```python
Tool(
name="get_proxy_documentation",
description="Get complete documentation for the HTTP proxy API",
inputSchema={"type": "object", "properties": {}, "required": []},
),
Tool(
name="request_session_token",
description="Request a short-lived token for direct HTTP API access. Use this to delegate bulk data operations to scripts.",
inputSchema={
"type": "object",
"properties": {
"document": {
"type": "string",
"description": "Document name to grant access to",
},
"permissions": {
"type": "array",
"items": {"type": "string", "enum": ["read", "write", "schema"]},
"description": "Permission levels to grant",
},
"ttl_seconds": {
"type": "integer",
"description": "Token lifetime in seconds (max 3600, default 300)",
},
},
"required": ["document", "permissions"],
},
),
```
4. Add handlers to call_tool():
```python
elif name == "get_proxy_documentation":
result = await _get_proxy_documentation()
elif name == "request_session_token":
if token_manager is None:
return [TextContent(type="text", text="Session tokens not enabled")]
result = await _request_session_token(
_current_agent, auth, token_manager,
arguments["document"],
arguments["permissions"],
ttl_seconds=arguments.get("ttl_seconds", 300),
)
```
**Step 4: Run all server tests**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_server.py -v`
Expected: PASS
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/server.py tests/unit/test_server.py
git commit -m "feat(server): register session token tools"
```
---
## Task 9: HTTP Proxy Handler - Request Parsing
**Files:**
- Create: `src/grist_mcp/proxy.py`
- Create: `tests/unit/test_proxy.py`
**Step 1: Write failing test for request parsing**
Create `tests/unit/test_proxy.py`:
```python
import pytest
from grist_mcp.proxy import parse_proxy_request, ProxyRequest, ProxyError
def test_parse_proxy_request_valid_add_records():
body = {
"method": "add_records",
"table": "Orders",
"records": [{"item": "Widget", "qty": 10}],
}
request = parse_proxy_request(body)
assert request.method == "add_records"
assert request.table == "Orders"
assert request.records == [{"item": "Widget", "qty": 10}]
def test_parse_proxy_request_missing_method():
body = {"table": "Orders"}
with pytest.raises(ProxyError) as exc_info:
parse_proxy_request(body)
assert exc_info.value.code == "INVALID_REQUEST"
assert "method" in str(exc_info.value)
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_proxy.py -v`
Expected: FAIL with "No module named 'grist_mcp.proxy'"
**Step 3: Write the implementation**
Create `src/grist_mcp/proxy.py`:
```python
"""HTTP proxy handler for session token access."""
from dataclasses import dataclass, field
from typing import Any
class ProxyError(Exception):
"""Error during proxy request processing."""
def __init__(self, message: str, code: str):
self.message = message
self.code = code
super().__init__(message)
@dataclass
class ProxyRequest:
"""Parsed proxy request."""
method: str
table: str | None = None
records: list[dict] | None = None
record_ids: list[int] | None = None
filter: dict | None = None
sort: str | None = None
limit: int | None = None
query: str | None = None
table_id: str | None = None
columns: list[dict] | None = None
column_id: str | None = None
column_type: str | None = None
formula: str | None = None
type: str | None = None
METHODS_REQUIRING_TABLE = {
"get_records", "describe_table", "add_records", "update_records",
"delete_records", "add_column", "modify_column", "delete_column",
}
def parse_proxy_request(body: dict[str, Any]) -> ProxyRequest:
"""Parse and validate a proxy request body."""
if "method" not in body:
raise ProxyError("Missing required field: method", "INVALID_REQUEST")
method = body["method"]
if method in METHODS_REQUIRING_TABLE and "table" not in body:
raise ProxyError(f"Missing required field 'table' for method '{method}'", "INVALID_REQUEST")
return ProxyRequest(
method=method,
table=body.get("table"),
records=body.get("records"),
record_ids=body.get("record_ids"),
filter=body.get("filter"),
sort=body.get("sort"),
limit=body.get("limit"),
query=body.get("query"),
table_id=body.get("table_id"),
columns=body.get("columns"),
column_id=body.get("column_id"),
column_type=body.get("column_type"),
formula=body.get("formula"),
type=body.get("type"),
)
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_proxy.py -v`
Expected: PASS
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/proxy.py tests/unit/test_proxy.py
git commit -m "feat(proxy): add request parsing"
```
---
## Task 10: HTTP Proxy Handler - Method Dispatch
**Files:**
- Modify: `src/grist_mcp/proxy.py`
- Modify: `tests/unit/test_proxy.py`
**Step 1: Write failing test for method dispatch**
Add to `tests/unit/test_proxy.py`:
```python
from unittest.mock import AsyncMock, MagicMock
from grist_mcp.proxy import dispatch_proxy_request
from grist_mcp.session import SessionToken
from datetime import datetime, timezone
@pytest.fixture
def mock_session():
return SessionToken(
token="sess_test",
document="sales",
permissions=["read", "write"],
agent_name="test-agent",
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc),
)
@pytest.fixture
def mock_auth():
auth = MagicMock()
doc = MagicMock()
doc.url = "https://grist.example.com"
doc.doc_id = "abc123"
doc.api_key = "key"
auth.get_document.return_value = doc
return auth
@pytest.mark.asyncio
async def test_dispatch_add_records(mock_session, mock_auth):
request = ProxyRequest(
method="add_records",
table="Orders",
records=[{"item": "Widget"}],
)
mock_client = AsyncMock()
mock_client.add_records.return_value = [1, 2, 3]
result = await dispatch_proxy_request(
request, mock_session, mock_auth, client=mock_client
)
assert result["success"] is True
assert result["data"]["record_ids"] == [1, 2, 3]
mock_client.add_records.assert_called_once_with("Orders", [{"item": "Widget"}])
```
**Step 2: Run test to verify it fails**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_proxy.py::test_dispatch_add_records -v`
Expected: FAIL with "cannot import name 'dispatch_proxy_request'"
**Step 3: Write the implementation**
Add to `src/grist_mcp/proxy.py`:
```python
from grist_mcp.auth import Authenticator, Permission
from grist_mcp.session import SessionToken
from grist_mcp.grist_client import GristClient
# Map methods to required permissions
METHOD_PERMISSIONS = {
"list_tables": "read",
"describe_table": "read",
"get_records": "read",
"sql_query": "read",
"add_records": "write",
"update_records": "write",
"delete_records": "write",
"create_table": "schema",
"add_column": "schema",
"modify_column": "schema",
"delete_column": "schema",
}
async def dispatch_proxy_request(
request: ProxyRequest,
session: SessionToken,
auth: Authenticator,
client: GristClient | None = None,
) -> dict[str, Any]:
"""Dispatch a proxy request to the appropriate handler."""
# Check permission
required_perm = METHOD_PERMISSIONS.get(request.method)
if required_perm is None:
raise ProxyError(f"Unknown method: {request.method}", "INVALID_REQUEST")
if required_perm not in session.permissions:
raise ProxyError(
f"Permission '{required_perm}' required for {request.method}",
"UNAUTHORIZED",
)
# Create client if not provided
if client is None:
doc = auth.get_document(session.document)
client = GristClient(doc)
# Dispatch to appropriate method
try:
if request.method == "list_tables":
data = await client.list_tables()
return {"success": True, "data": {"tables": data}}
elif request.method == "describe_table":
data = await client.describe_table(request.table)
return {"success": True, "data": {"columns": data}}
elif request.method == "get_records":
data = await client.get_records(
request.table,
filter=request.filter,
sort=request.sort,
limit=request.limit,
)
return {"success": True, "data": {"records": data}}
elif request.method == "sql_query":
if request.query is None:
raise ProxyError("Missing required field: query", "INVALID_REQUEST")
data = await client.sql_query(request.query)
return {"success": True, "data": data}
elif request.method == "add_records":
if request.records is None:
raise ProxyError("Missing required field: records", "INVALID_REQUEST")
data = await client.add_records(request.table, request.records)
return {"success": True, "data": {"record_ids": data}}
elif request.method == "update_records":
if request.records is None:
raise ProxyError("Missing required field: records", "INVALID_REQUEST")
await client.update_records(request.table, request.records)
return {"success": True, "data": {"updated": len(request.records)}}
elif request.method == "delete_records":
if request.record_ids is None:
raise ProxyError("Missing required field: record_ids", "INVALID_REQUEST")
await client.delete_records(request.table, request.record_ids)
return {"success": True, "data": {"deleted": len(request.record_ids)}}
elif request.method == "create_table":
if request.table_id is None or request.columns is None:
raise ProxyError("Missing required fields: table_id, columns", "INVALID_REQUEST")
data = await client.create_table(request.table_id, request.columns)
return {"success": True, "data": {"table_id": data}}
elif request.method == "add_column":
if request.column_id is None or request.column_type is None:
raise ProxyError("Missing required fields: column_id, column_type", "INVALID_REQUEST")
await client.add_column(
request.table, request.column_id, request.column_type,
formula=request.formula,
)
return {"success": True, "data": {"column_id": request.column_id}}
elif request.method == "modify_column":
if request.column_id is None:
raise ProxyError("Missing required field: column_id", "INVALID_REQUEST")
await client.modify_column(
request.table, request.column_id,
type=request.type,
formula=request.formula,
)
return {"success": True, "data": {"column_id": request.column_id}}
elif request.method == "delete_column":
if request.column_id is None:
raise ProxyError("Missing required field: column_id", "INVALID_REQUEST")
await client.delete_column(request.table, request.column_id)
return {"success": True, "data": {"deleted": request.column_id}}
else:
raise ProxyError(f"Unknown method: {request.method}", "INVALID_REQUEST")
except ProxyError:
raise
except Exception as e:
raise ProxyError(str(e), "GRIST_ERROR")
```
**Step 4: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_proxy.py -v`
Expected: PASS
**Step 5: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/proxy.py tests/unit/test_proxy.py
git commit -m "feat(proxy): add method dispatch"
```
---
## Task 11: HTTP Proxy Handler - Permission Denial
**Files:**
- Modify: `tests/unit/test_proxy.py`
**Step 1: Write test for permission denial**
Add to `tests/unit/test_proxy.py`:
```python
@pytest.mark.asyncio
async def test_dispatch_denies_without_permission(mock_auth):
# Session only has read permission
session = SessionToken(
token="sess_test",
document="sales",
permissions=["read"], # No write
agent_name="test-agent",
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc),
)
request = ProxyRequest(
method="add_records", # Requires write
table="Orders",
records=[{"item": "Widget"}],
)
with pytest.raises(ProxyError) as exc_info:
await dispatch_proxy_request(request, session, mock_auth)
assert exc_info.value.code == "UNAUTHORIZED"
```
**Step 2: Run test to verify it passes**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/test_proxy.py -v`
Expected: PASS (implementation already handles this)
**Step 3: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add tests/unit/test_proxy.py
git commit -m "test(proxy): add permission denial test"
```
---
## Task 12: HTTP Proxy Endpoint in main.py
**Files:**
- Modify: `src/grist_mcp/main.py`
**Step 1: Add proxy imports and handler**
Add imports at top of `src/grist_mcp/main.py`:
```python
from grist_mcp.session import SessionTokenManager
from grist_mcp.proxy import parse_proxy_request, dispatch_proxy_request, ProxyError
```
**Step 2: Update create_app to create token manager**
In `create_app`, after `auth = Authenticator(config)`:
```python
token_manager = SessionTokenManager()
```
**Step 3: Add proxy handler function**
Add inside `create_app`, after `handle_not_found`:
```python
async def handle_proxy(scope: Scope, receive: Receive, send: Send) -> None:
# Extract token
token = _get_bearer_token(scope)
if not token:
await send_json_response(send, 401, {
"success": False,
"error": "Missing Authorization header",
"code": "INVALID_TOKEN",
})
return
# Validate session token
session = token_manager.validate_token(token)
if session is None:
await send_json_response(send, 401, {
"success": False,
"error": "Invalid or expired token",
"code": "TOKEN_EXPIRED",
})
return
# Read request body
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body", False):
break
try:
import json as json_mod
request_data = json_mod.loads(body)
except json_mod.JSONDecodeError:
await send_json_response(send, 400, {
"success": False,
"error": "Invalid JSON",
"code": "INVALID_REQUEST",
})
return
# Parse and dispatch
try:
request = parse_proxy_request(request_data)
result = await dispatch_proxy_request(request, session, auth)
await send_json_response(send, 200, result)
except ProxyError as e:
status = 403 if e.code == "UNAUTHORIZED" else 400
await send_json_response(send, status, {
"success": False,
"error": e.message,
"code": e.code,
})
```
**Step 4: Add helper for JSON responses**
Add after `send_error` function:
```python
async def send_json_response(send: Send, status: int, data: dict) -> None:
"""Send a JSON response."""
body = json.dumps(data).encode()
await send({
"type": "http.response.start",
"status": status,
"headers": [[b"content-type", b"application/json"]],
})
await send({
"type": "http.response.body",
"body": body,
})
```
**Step 5: Add route for /api/v1/proxy**
In the `app` function, add before the `else` clause:
```python
elif path == "/api/v1/proxy" and method == "POST":
await handle_proxy(scope, receive, send)
```
**Step 6: Update create_server call**
Change the `create_server` call in `handle_sse`:
```python
server = create_server(auth, agent, token_manager)
```
**Step 7: Run all tests**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/ -v`
Expected: PASS
**Step 8: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add src/grist_mcp/main.py
git commit -m "feat(main): add /api/v1/proxy HTTP endpoint"
```
---
## Task 13: Integration Test - Full Flow
**Files:**
- Create: `tests/integration/test_session_proxy.py`
**Step 1: Write integration test**
Create `tests/integration/test_session_proxy.py`:
```python
"""Integration tests for session token proxy."""
import os
import pytest
import httpx
GRIST_MCP_URL = os.environ.get("GRIST_MCP_URL", "http://localhost:3000")
GRIST_MCP_TOKEN = os.environ.get("GRIST_MCP_TOKEN")
@pytest.fixture
def mcp_client():
"""Client for MCP SSE endpoint."""
return httpx.Client(
base_url=GRIST_MCP_URL,
headers={"Authorization": f"Bearer {GRIST_MCP_TOKEN}"},
)
@pytest.fixture
def proxy_client():
"""Client for proxy endpoint (session token set per-test)."""
return httpx.Client(base_url=GRIST_MCP_URL)
@pytest.mark.integration
def test_full_session_proxy_flow(mcp_client, proxy_client):
"""Test: request token via MCP, use token to call proxy."""
# This test requires a running grist-mcp server with proper config
# Skip if not configured
if not GRIST_MCP_TOKEN:
pytest.skip("GRIST_MCP_TOKEN not set")
# Step 1: Request session token (would be via MCP in real usage)
# For integration test, we test the proxy endpoint directly
# This is a placeholder - full MCP integration would use SSE
# Step 2: Use proxy endpoint
# Note: Need a valid session token to test this fully
# For now, verify endpoint exists and rejects bad tokens
response = proxy_client.post(
"/api/v1/proxy",
headers={"Authorization": "Bearer invalid_token"},
json={"method": "list_tables"},
)
assert response.status_code == 401
data = response.json()
assert data["success"] is False
assert data["code"] in ["INVALID_TOKEN", "TOKEN_EXPIRED"]
```
**Step 2: Commit**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add tests/integration/test_session_proxy.py
git commit -m "test(integration): add session proxy integration test"
```
---
## Task 14: Final Verification
**Step 1: Run all unit tests**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run pytest tests/unit/ -v`
Expected: All tests pass
**Step 2: Run linting (if configured)**
Run: `cd /home/bballou/grist-mcp/.worktrees/session-proxy && uv run ruff check src/`
Expected: No errors (or fix any that appear)
**Step 3: Update CHANGELOG.md**
Add to CHANGELOG.md under a new version section:
```markdown
## [Unreleased]
### Added
- Session token proxy: agents can request short-lived tokens for bulk operations
- `get_proxy_documentation` MCP tool: returns complete proxy API spec
- `request_session_token` MCP tool: creates scoped session tokens
- `POST /api/v1/proxy` HTTP endpoint: accepts session tokens for direct API access
```
**Step 4: Commit changelog**
```bash
cd /home/bballou/grist-mcp/.worktrees/session-proxy
git add CHANGELOG.md
git commit -m "docs: update CHANGELOG for session proxy feature"
```
---
## Summary
This implementation adds:
1. **SessionTokenManager** (`src/grist_mcp/session.py`) - in-memory token storage with TTL
2. **Session tools** (`src/grist_mcp/tools/session.py`) - `get_proxy_documentation` and `request_session_token`
3. **Proxy handler** (`src/grist_mcp/proxy.py`) - request parsing and method dispatch
4. **HTTP endpoint** - `POST /api/v1/proxy` route in main.py
5. **Tests** - unit tests for all components, integration test scaffold
Total: 14 tasks, ~50 commits