Enables agents to delegate bulk data operations to scripts, bypassing LLM generation time for data-intensive operations. Scripts authenticate via short-lived session tokens requested through MCP, then call a simplified HTTP proxy endpoint.
7.5 KiB
Session Token Proxy Design
Problem
When an agent needs to insert, update, or query thousands of records, the LLM must generate all that JSON in its response. This is slow regardless of how fast the actual API call is. The LLM generation time is the bottleneck.
Solution
Add a "session token" mechanism that lets agents delegate bulk data operations to scripts that call grist-mcp directly over HTTP, bypassing LLM generation entirely.
Flow
1. Agent calls MCP tool:
request_session_token(document="sales", permissions=["write"], ttl_seconds=300)
2. Server generates token, stores in memory:
{"sess_abc123...": {document: "sales", permissions: ["write"], expires: <timestamp>}}
3. Server returns token to agent:
{"token": "sess_abc123...", "expires_in": 300, "proxy_url": "/api/v1/proxy"}
4. Agent spawns script with token:
python bulk_insert.py --token sess_abc123... --file data.csv
5. Script calls grist-mcp HTTP endpoint:
POST /api/v1/proxy
Authorization: Bearer sess_abc123...
{"table": "Orders", "method": "add_records", "records": [...]}
6. Server validates token, executes against Grist, returns result
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Token scope | Single document + permission level | Simpler than multi-doc; matches existing permission model |
| Token storage | In-memory dict | Appropriate for short-lived tokens; restart invalidates (acceptable) |
| HTTP interface | Wrapped endpoint /api/v1/proxy |
Simpler than mirroring Grist API paths |
| Request format | Discrete fields (table, method, etc.) | Scripts don't need to know Grist internals or doc IDs |
| Document in request | Implicit from token | Token is scoped to one document; no need to specify |
| Server architecture | Single process, add routes | Already running HTTP for SSE; just add routes |
MCP Tool: request_session_token
Input schema:
{
"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 (cannot exceed agent's permissions)"
},
"ttl_seconds": {
"type": "integer",
"description": "Token lifetime in seconds (max 3600, default 300)"
}
},
"required": ["document", "permissions"]
}
Response:
{
"token": "sess_a1b2c3d4...",
"document": "sales",
"permissions": ["write"],
"expires_at": "2025-01-02T15:30:00Z",
"proxy_url": "/api/v1/proxy"
}
Validation:
- Agent must have access to the requested document
- Requested permissions cannot exceed agent's permissions for that document
- TTL capped at 3600 seconds (1 hour), default 300 seconds (5 minutes)
Proxy Endpoint
Endpoint: POST /api/v1/proxy
Authentication: Authorization: Bearer <session_token>
Request body - method determines required fields:
# Read operations
{"method": "get_records", "table": "Orders", "filter": {...}, "sort": "date", "limit": 1000}
{"method": "sql_query", "query": "SELECT * FROM Orders WHERE amount > 100"}
{"method": "list_tables"}
{"method": "describe_table", "table": "Orders"}
# Write operations
{"method": "add_records", "table": "Orders", "records": [{...}, {...}]}
{"method": "update_records", "table": "Orders", "records": [{"id": 1, "fields": {...}}]}
{"method": "delete_records", "table": "Orders", "record_ids": [1, 2, 3]}
# Schema operations
{"method": "create_table", "table_id": "NewTable", "columns": [{...}]}
{"method": "add_column", "table": "Orders", "column_id": "status", "column_type": "Text"}
{"method": "modify_column", "table": "Orders", "column_id": "status", "type": "Choice"}
{"method": "delete_column", "table": "Orders", "column_id": "old_field"}
Response format:
{"success": true, "data": {...}}
{"success": false, "error": "Permission denied", "code": "UNAUTHORIZED"}
Error codes:
UNAUTHORIZED- Permission denied for this operationINVALID_TOKEN- Token format invalid or not foundTOKEN_EXPIRED- Token has expiredINVALID_REQUEST- Malformed request bodyGRIST_ERROR- Error from Grist API
Implementation Architecture
New Files
src/grist_mcp/session.py - Session token management:
@dataclass
class SessionToken:
token: str
document: str
permissions: list[str]
agent_name: str
created_at: datetime
expires_at: datetime
class SessionTokenManager:
def __init__(self):
self._tokens: dict[str, SessionToken] = {}
def create_token(self, agent: Agent, document: str,
permissions: list[str], ttl_seconds: int) -> SessionToken:
"""Create a new session token. Validates permissions against agent's scope."""
...
def validate_token(self, token: str) -> SessionToken | None:
"""Validate token and return session info. Returns None if invalid/expired."""
# Also cleans up this token if expired
...
def cleanup_expired(self) -> int:
"""Remove all expired tokens. Returns count removed."""
...
src/grist_mcp/proxy.py - HTTP proxy handler:
async def handle_proxy(
scope: Scope,
receive: Receive,
send: Send,
token_manager: SessionTokenManager,
auth: Authenticator
) -> None:
"""Handle POST /api/v1/proxy requests."""
# 1. Extract Bearer token from Authorization header
# 2. Validate session token
# 3. Parse request body (method, table, etc.)
# 4. Check permissions for requested method
# 5. Build GristClient for the token's document
# 6. Dispatch to appropriate tool function
# 7. Return JSON response
Modified Files
src/grist_mcp/main.py:
- Import
SessionTokenManagerandhandle_proxy - Instantiate
SessionTokenManagerincreate_app() - Add route:
elif path == "/api/v1/proxy" and method == "POST" - Pass
token_managertocreate_server()
src/grist_mcp/server.py:
- Accept
token_managerparameter increate_server() - Add
request_session_tokentool tolist_tools() - Add handler in
call_tool()that creates token viatoken_manager.create_token()
Security
-
No privilege escalation - Session token can only grant permissions the agent already has for the document. Validated at token creation.
-
Short-lived by default - 5 minute default TTL, 1 hour maximum cap.
-
Token format - Prefixed with
sess_to distinguish from agent tokens. Generated withsecrets.token_urlsafe(32). -
Lazy cleanup - Expired tokens removed during validation. No background task needed.
-
Audit logging - Token creation and proxy requests logged with agent name, document, method.
Testing
Unit Tests
tests/unit/test_session.py:
- Token creation with valid permissions
- Token creation fails when exceeding agent permissions
- Token validation succeeds for valid token
- Token validation fails for expired token
- Token validation fails for unknown token
- TTL capping at maximum
- Cleanup removes expired tokens
tests/unit/test_proxy.py:
- Request parsing for each method type
- Error response for invalid token
- Error response for expired token
- Error response for permission denied
- Error response for malformed request
- Successful dispatch to each tool function (mocked)
Integration Tests
tests/integration/test_session_proxy.py:
- Full flow: MCP token request → HTTP proxy call → Grist operation
- Verify data actually written to Grist
- Verify token expiry prevents access