# 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