13 Commits

Author SHA1 Message Date
16691e1d21 Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into master
All checks were successful
Build and Push Docker Image / build (push) Successful in 8s
Reviewed-on: #1
2026-01-01 14:08:06 -05:00
204d00caf4 feat: add host_header config for Docker networking
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
When Grist validates the Host header (common with reverse proxy setups),
internal Docker networking fails because requests arrive with
Host: container-name instead of the external domain.

The new host_header config option allows overriding the Host header
sent to Grist while still connecting via internal Docker hostnames.
2026-01-01 14:06:31 -05:00
ca03d22b97 fix: handle missing config file gracefully in Docker
All checks were successful
Build and Push Docker Image / build (push) Successful in 14s
2026-01-01 12:51:25 -05:00
107db82c52 docs: update README with step-by-step first-time setup 2026-01-01 12:10:17 -05:00
4b89837b43 chore: remove logging and resource limits from prod config 2026-01-01 12:07:47 -05:00
5aaa943010 Add renovate.json 2026-01-01 16:50:55 +00:00
c8cea249bc chore: use ghcr.io image for production deployment
- Update prod docker-compose to pull from ghcr.io/xe138/grist-mcp-server
- Remove debug step from Gitea workflow
2026-01-01 11:48:39 -05:00
ae894ff52e debug: add environment diagnostics
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m56s
2026-01-01 11:29:06 -05:00
7b7eea2f67 fix: use ubuntu-docker runner with Docker CLI 2026-01-01 11:20:21 -05:00
0f2544c960 fix: use git clone instead of actions/checkout for host runner
Some checks failed
Build and Push Docker Image / build (push) Failing after 1s
2026-01-01 11:15:15 -05:00
d7ce2ad962 fix: use linux_x64 host runner for Docker access
Some checks failed
Build and Push Docker Image / test (push) Failing after 1s
Build and Push Docker Image / build (push) Has been skipped
2026-01-01 11:12:43 -05:00
5892eb5cda debug: add environment diagnostics to build job
Some checks failed
Build and Push Docker Image / test (push) Successful in 6s
Build and Push Docker Image / build (push) Failing after 6s
2026-01-01 10:59:00 -05:00
9b55dedec5 test: add test job to match ffmpeg-worker structure
Some checks failed
Build and Push Docker Image / test (push) Successful in 6s
Build and Push Docker Image / build (push) Failing after 6s
2026-01-01 10:49:16 -05:00
8 changed files with 177 additions and 161 deletions

View File

@@ -11,9 +11,11 @@ env:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-docker
steps: steps:
- uses: actions/checkout@v4 - name: Checkout repository
run: |
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Extract version from tag - name: Extract version from tag
id: version id: version

268
README.md
View File

@@ -15,50 +15,33 @@ grist-mcp is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/)
- **Security**: Token-based authentication with per-document permission scopes (read, write, schema) - **Security**: Token-based authentication with per-document permission scopes (read, write, schema)
- **Multi-tenant**: Support multiple Grist instances and documents - **Multi-tenant**: Support multiple Grist instances and documents
## Requirements ## Quick Start (Docker)
- Python 3.14+ ### Prerequisites
- Docker and Docker Compose
- Access to one or more Grist documents with API keys - Access to one or more Grist documents with API keys
## Installation ### 1. Create configuration directory
```bash ```bash
# Clone the repository mkdir grist-mcp && cd grist-mcp
git clone https://github.com/your-org/grist-mcp.git
cd grist-mcp
# Install with uv
uv sync --dev
``` ```
## Configuration ### 2. Download configuration files
Create a `config.yaml` file based on the example:
```bash ```bash
# Download docker-compose.yml
curl -O https://raw.githubusercontent.com/Xe138/grist-mcp-server/master/deploy/prod/docker-compose.yml
# Download example config
curl -O https://raw.githubusercontent.com/Xe138/grist-mcp-server/master/config.yaml.example
cp config.yaml.example config.yaml cp config.yaml.example config.yaml
``` ```
### Configuration Structure ### 3. Generate tokens
```yaml Generate a secure token for your agent:
# Document definitions
documents:
my-document:
url: https://docs.getgrist.com # Grist instance URL
doc_id: abcd1234 # Document ID from URL
api_key: ${GRIST_API_KEY} # API key (supports env vars)
# Agent tokens with access scopes
tokens:
- token: your-secret-token # Unique token for this agent
name: my-agent # Human-readable name
scope:
- document: my-document
permissions: [read, write] # Allowed: read, write, schema
```
### Generating Tokens
```bash ```bash
python -c "import secrets; print(secrets.token_urlsafe(32))" python -c "import secrets; print(secrets.token_urlsafe(32))"
@@ -66,34 +49,53 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
openssl rand -base64 32 openssl rand -base64 32
``` ```
### Environment Variables ### 4. Configure config.yaml
- `CONFIG_PATH`: Path to config file (default: `/app/config.yaml`) Edit `config.yaml` to define your Grist documents and agent tokens:
- `GRIST_MCP_TOKEN`: Agent token for authentication
- Config file supports `${VAR}` syntax for API keys
## Usage ```yaml
# Document definitions
documents:
my-document: # Friendly name (used in token scopes)
url: https://docs.getgrist.com # Your Grist instance URL
doc_id: abcd1234efgh5678 # Document ID from the URL
api_key: your-grist-api-key # Grist API key (or use ${ENV_VAR} syntax)
### Running the Server # Agent tokens with access scopes
tokens:
The server uses SSE (Server-Sent Events) transport over HTTP: - token: your-generated-token-here # The token you generated in step 3
name: my-agent # Human-readable name
```bash scope:
# Set your agent token - document: my-document # Must match a document name above
export GRIST_MCP_TOKEN="your-agent-token" permissions: [read, write] # Allowed: read, write, schema
# Run with custom config path (defaults to port 3000)
CONFIG_PATH=./config.yaml uv run python -m grist_mcp.main
# Or specify a custom port
PORT=8080 CONFIG_PATH=./config.yaml uv run python -m grist_mcp.main
``` ```
The server exposes two endpoints: **Finding your Grist document ID**: Open your Grist document in a browser. The URL will look like:
- `http://localhost:3000/sse` - SSE connection endpoint `https://docs.getgrist.com/abcd1234efgh5678/My-Document` - the document ID is `abcd1234efgh5678`.
- `http://localhost:3000/messages` - Message posting endpoint
### MCP Client Configuration **Getting a Grist API key**: In Grist, go to Profile Settings → API → Create API Key.
### 5. Create .env file
Create a `.env` file with your agent token:
```bash
# .env
GRIST_MCP_TOKEN=your-generated-token-here
PORT=3000
```
The `GRIST_MCP_TOKEN` must match one of the tokens defined in `config.yaml`.
### 6. Start the server
```bash
docker compose up -d
```
The server will be available at `http://localhost:3000`.
### 7. Configure your MCP client
Add to your MCP client configuration (e.g., Claude Desktop): Add to your MCP client configuration (e.g., Claude Desktop):
@@ -101,24 +103,13 @@ Add to your MCP client configuration (e.g., Claude Desktop):
{ {
"mcpServers": { "mcpServers": {
"grist": { "grist": {
"type": "sse",
"url": "http://localhost:3000/sse" "url": "http://localhost:3000/sse"
} }
} }
} }
``` ```
For remote deployments, use the server's public URL:
```json
{
"mcpServers": {
"grist": {
"url": "https://your-server.example.com/sse"
}
}
}
```
## Available Tools ## Available Tools
### Discovery ### Discovery
@@ -149,6 +140,54 @@ For remote deployments, use the server's public URL:
| `modify_column` | Change a column's type or formula | | `modify_column` | Change a column's type or formula |
| `delete_column` | Remove a column from a table | | `delete_column` | Remove a column from a table |
## Configuration Reference
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `GRIST_MCP_TOKEN` | Agent authentication token (required) | - |
| `CONFIG_PATH` | Path to config file inside container | `/app/config.yaml` |
### config.yaml Structure
```yaml
# Document definitions (each is self-contained)
documents:
budget-2024:
url: https://work.getgrist.com
doc_id: mK7xB2pQ9mN4v
api_key: ${GRIST_WORK_API_KEY} # Supports environment variable substitution
personal-tracker:
url: https://docs.getgrist.com
doc_id: pN0zE5sT2qP7x
api_key: ${GRIST_PERSONAL_API_KEY}
# Agent tokens with access scopes
tokens:
- token: your-secure-token-here
name: finance-agent
scope:
- document: budget-2024
permissions: [read, write] # Can read and write
- token: another-token-here
name: readonly-agent
scope:
- document: budget-2024
permissions: [read] # Read only
- document: personal-tracker
permissions: [read, write, schema] # Full access
```
### Permission Levels
- `read`: Query tables and records, run SQL queries
- `write`: Add, update, delete records
- `schema`: Create tables, add/modify/delete columns
## Security ## Security
- **Token-based auth**: Each agent has a unique token with specific document access - **Token-based auth**: Each agent has a unique token with specific document access
@@ -159,10 +198,30 @@ For remote deployments, use the server's public URL:
## Development ## Development
### Running Tests ### Requirements
- Python 3.14+
- uv package manager
### Local Setup
```bash ```bash
uv run pytest -v # Clone the repository
git clone https://github.com/Xe138/grist-mcp-server.git
cd grist-mcp-server
# Install dependencies
uv sync --dev
# Run tests
make test-unit
```
### Running Locally
```bash
export GRIST_MCP_TOKEN="your-agent-token"
CONFIG_PATH=./config.yaml uv run python -m grist_mcp.main
``` ```
### Project Structure ### Project Structure
@@ -170,7 +229,6 @@ uv run pytest -v
``` ```
grist-mcp/ grist-mcp/
├── src/grist_mcp/ ├── src/grist_mcp/
│ ├── __init__.py
│ ├── main.py # Entry point │ ├── main.py # Entry point
│ ├── server.py # MCP server setup and tool registration │ ├── server.py # MCP server setup and tool registration
│ ├── config.py # Configuration loading │ ├── config.py # Configuration loading
@@ -182,73 +240,13 @@ grist-mcp/
│ ├── write.py # Write operations │ ├── write.py # Write operations
│ └── schema.py # Schema operations │ └── schema.py # Schema operations
├── tests/ ├── tests/
├── config.yaml.example │ ├── unit/ # Unit tests
└── pyproject.toml │ └── integration/ # Integration tests
``` ├── deploy/
│ ├── dev/ # Development docker-compose
## Docker Deployment │ ├── test/ # Test docker-compose
│ └── prod/ # Production docker-compose
### Prerequisites └── config.yaml.example
- Docker and Docker Compose
### Quick Start
```bash
# 1. Copy example files
cp .env.example .env
cp config.yaml.example config.yaml
# 2. Edit .env with your tokens and API keys
# - Set GRIST_MCP_TOKEN to a secure agent token
# - Set your Grist API keys
# 3. Edit config.yaml with your document settings
# - Configure your Grist documents
# - Set up token scopes and permissions
# 4. Start the server
docker compose up -d
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `GRIST_MCP_TOKEN` | Agent authentication token (required) | - |
| `CONFIG_PATH` | Path to config file inside container | `/app/config.yaml` |
| `GRIST_*_API_KEY` | Grist API keys referenced in config.yaml | - |
### Using Prebuilt Images
To use a prebuilt image from a container registry:
```yaml
# docker-compose.yaml
services:
grist-mcp:
image: your-registry/grist-mcp:latest
ports:
- "${PORT:-3000}:3000"
volumes:
- ./config.yaml:/app/config.yaml:ro
env_file:
- .env
restart: unless-stopped
```
### Building Locally
```bash
# Build the image
docker build -t grist-mcp .
# Run directly
docker run -p 3000:3000 \
-v $(pwd)/config.yaml:/app/config.yaml:ro \
--env-file .env \
grist-mcp
``` ```
## License ## License

View File

@@ -23,6 +23,14 @@ documents:
doc_id: pN0zE5sT2qP7x doc_id: pN0zE5sT2qP7x
api_key: ${GRIST_PERSONAL_API_KEY} api_key: ${GRIST_PERSONAL_API_KEY}
# Docker networking example: connect via internal hostname,
# but send the external domain in the Host header
docker-grist:
url: http://grist:8080
doc_id: abc123
api_key: ${GRIST_API_KEY}
host_header: grist.example.com # Required when Grist validates Host header
# Agent tokens with access scopes # Agent tokens with access scopes
tokens: tokens:
- token: REPLACE_WITH_GENERATED_TOKEN - token: REPLACE_WITH_GENERATED_TOKEN

View File

@@ -1,9 +1,7 @@
# Production environment - resource limits, logging, restart policy # Production environment
services: services:
grist-mcp: grist-mcp:
build: image: ghcr.io/xe138/grist-mcp-server:latest
context: ../..
dockerfile: Dockerfile
ports: ports:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
volumes: volumes:
@@ -12,18 +10,6 @@ services:
- CONFIG_PATH=/app/config.yaml - CONFIG_PATH=/app/config.yaml
- EXTERNAL_PORT=${PORT:-3000} - EXTERNAL_PORT=${PORT:-3000}
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "1"
reservations:
memory: 128M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
interval: 30s interval: 30s

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -14,6 +14,7 @@ class Document:
url: str url: str
doc_id: str doc_id: str
api_key: str api_key: str
host_header: str | None = None # Override Host header for Docker networking
@dataclass @dataclass
@@ -78,6 +79,7 @@ def load_config(config_path: str) -> Config:
url=doc_data["url"], url=doc_data["url"],
doc_id=doc_data["doc_id"], doc_id=doc_data["doc_id"],
api_key=doc_data["api_key"], api_key=doc_data["api_key"],
host_header=doc_data.get("host_header"),
) )
# Parse tokens # Parse tokens

View File

@@ -17,6 +17,8 @@ class GristClient:
self._doc = document self._doc = document
self._base_url = f"{document.url.rstrip('/')}/api/docs/{document.doc_id}" self._base_url = f"{document.url.rstrip('/')}/api/docs/{document.doc_id}"
self._headers = {"Authorization": f"Bearer {document.api_key}"} self._headers = {"Authorization": f"Bearer {document.api_key}"}
if document.host_header:
self._headers["Host"] = document.host_header
self._timeout = timeout self._timeout = timeout
async def _request(self, method: str, path: str, **kwargs) -> dict: async def _request(self, method: str, path: str, **kwargs) -> dict:

View File

@@ -74,19 +74,34 @@ def _ensure_config(config_path: str) -> bool:
# Check if path is a directory (Docker creates this when mounting missing file) # Check if path is a directory (Docker creates this when mounting missing file)
if os.path.isdir(path): if os.path.isdir(path):
os.rmdir(path) print(f"ERROR: Config path is a directory: {path}")
print()
print("This usually means the config file doesn't exist on the host.")
print("Please create the config file before starting the container:")
print()
print(f" mkdir -p $(dirname {config_path})")
print(f" cat > {config_path} << 'EOF'")
print(CONFIG_TEMPLATE)
print("EOF")
print()
return False
if os.path.exists(path): if os.path.exists(path):
return True return True
# Create template config # Create template config
with open(path, "w") as f: try:
f.write(CONFIG_TEMPLATE) with open(path, "w") as f:
f.write(CONFIG_TEMPLATE)
print(f"Created template configuration at: {path}") print(f"Created template configuration at: {path}")
print() print()
print("Please edit this file to configure your Grist documents and agent tokens,") print("Please edit this file to configure your Grist documents and agent tokens,")
print("then restart the server.") print("then restart the server.")
except PermissionError:
print(f"ERROR: Cannot create config file at: {path}")
print()
print("Please create the config file manually before starting the container.")
print()
return False return False