Compare commits

...

16 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
75bae256f2 refactor: separate Gitea and GitHub workflows
Some checks failed
Build and Push Docker Image / build (push) Failing after 7s
- Add .gitea/workflows/release.yml for Gitea builds
  - Uses plain docker commands (no action dependencies)
  - Pushes to git.prettyhefty.com registry
- Simplify .github/workflows/build.yaml for GitHub only
  - Remove Gitea detection logic
  - Only push latest tag for non-prerelease versions
2026-01-01 10:46:12 -05:00
a490eab625 revert: restore Gitea build (needs Docker on runner) 2026-01-01 10:41:16 -05:00
1bf18b9ce2 fix: skip Docker build on Gitea runner (no Docker installed) 2026-01-01 10:39:37 -05:00
9 changed files with 229 additions and 195 deletions

View File

@@ -0,0 +1,45 @@
name: Build and Push Docker Image
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: git.prettyhefty.com
IMAGE_NAME: bill/grist-mcp
jobs:
build:
runs-on: ubuntu-docker
steps:
- name: Checkout repository
run: |
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
if [[ "$VERSION" == *-alpha* ]] || [[ "$VERSION" == *-beta* ]] || [[ "$VERSION" == *-rc* ]]; then
echo "IS_PRERELEASE=true" >> $GITHUB_OUTPUT
else
echo "IS_PRERELEASE=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ gitea.actor }} --password-stdin
- name: Build and push Docker image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} .
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
if [ "${{ steps.version.outputs.IS_PRERELEASE }}" = "false" ]; then
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
fi
- name: List images
run: docker images | grep grist-mcp

View File

@@ -6,7 +6,8 @@ on:
- 'v*.*.*' - 'v*.*.*'
env: env:
IMAGE_NAME: grist-mcp REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: build:
@@ -19,53 +20,27 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Log in to Container Registry
uses: docker/setup-buildx-action@v3
- name: Determine registry
id: registry
run: |
if [ "${GITEA_ACTIONS}" = "true" ]; then
# Gitea: use server URL as registry
REGISTRY="${{ github.server_url }}"
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
echo "registry=${REGISTRY}" >> $GITHUB_OUTPUT
echo "is_gitea=true" >> $GITHUB_OUTPUT
else
# GitHub: use GHCR
echo "registry=ghcr.io" >> $GITHUB_OUTPUT
echo "is_gitea=false" >> $GITHUB_OUTPUT
fi
- name: Log in to GitHub Container Registry
if: steps.registry.outputs.is_gitea == 'false'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Gitea Container Registry - name: Extract metadata for Docker
if: steps.registry.outputs.is_gitea == 'true'
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.registry }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ steps.registry.outputs.registry }}/${{ github.repository }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=raw,value=latest,enable=${{ !contains(github.ref, '-alpha') && !contains(github.ref, '-beta') && !contains(github.ref, '-rc') }}
type=raw,value=latest
- name: Build and push - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .

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