feat: add config loading with env var substitution

This commit is contained in:
2025-12-03 14:26:53 -05:00
parent 2b79ab2f32
commit 43fbd2dced
3 changed files with 225 additions and 0 deletions

40
config.yaml.example Normal file
View File

@@ -0,0 +1,40 @@
# config.yaml.example
# ============================================================
# Token Generation:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# openssl rand -base64 32
# ============================================================
# Document definitions (each is self-contained)
documents:
budget-2024:
url: https://work.getgrist.com
doc_id: mK7xB2pQ9mN4v
api_key: ${GRIST_WORK_API_KEY}
expenses:
url: https://work.getgrist.com
doc_id: nL8yC3qR0oO5w
api_key: ${GRIST_WORK_API_KEY}
personal-tracker:
url: https://docs.getgrist.com
doc_id: pN0zE5sT2qP7x
api_key: ${GRIST_PERSONAL_API_KEY}
# Agent tokens with access scopes
tokens:
- token: REPLACE_WITH_GENERATED_TOKEN
name: finance-agent
scope:
- document: budget-2024
permissions: [read, write]
- document: expenses
permissions: [read]
- token: REPLACE_WITH_ANOTHER_TOKEN
name: analytics-agent
scope:
- document: personal-tracker
permissions: [read, write, schema]

99
src/grist_mcp/config.py Normal file
View File

@@ -0,0 +1,99 @@
"""Configuration loading and parsing."""
import os
import re
from dataclasses import dataclass
from pathlib import Path
import yaml
@dataclass
class Document:
"""A Grist document configuration."""
url: str
doc_id: str
api_key: str
@dataclass
class TokenScope:
"""Access scope for a single document."""
document: str
permissions: list[str]
@dataclass
class Token:
"""An agent token with its access scopes."""
token: str
name: str
scope: list[TokenScope]
@dataclass
class Config:
"""Full server configuration."""
documents: dict[str, Document]
tokens: list[Token]
def _substitute_env_vars(value: str) -> str:
"""Replace ${VAR} patterns with environment variable values."""
pattern = r'\$\{([^}]+)\}'
def replacer(match: re.Match) -> str:
var_name = match.group(1)
env_value = os.environ.get(var_name)
if env_value is None:
raise ValueError(f"Environment variable not set: {var_name}")
return env_value
return re.sub(pattern, replacer, value)
def _substitute_env_vars_recursive(obj):
"""Recursively substitute env vars in a data structure."""
if isinstance(obj, str):
return _substitute_env_vars(obj)
elif isinstance(obj, dict):
return {k: _substitute_env_vars_recursive(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_substitute_env_vars_recursive(item) for item in obj]
return obj
def load_config(config_path: str) -> Config:
"""Load and parse configuration from YAML file."""
path = Path(config_path)
raw = yaml.safe_load(path.read_text())
# Substitute environment variables
raw = _substitute_env_vars_recursive(raw)
# Parse documents
documents = {}
for name, doc_data in raw.get("documents", {}).items():
documents[name] = Document(
url=doc_data["url"],
doc_id=doc_data["doc_id"],
api_key=doc_data["api_key"],
)
# Parse tokens
tokens = []
for token_data in raw.get("tokens", []):
scope = [
TokenScope(
document=s["document"],
permissions=s["permissions"],
)
for s in token_data.get("scope", [])
]
tokens.append(Token(
token=token_data["token"],
name=token_data["name"],
scope=scope,
))
return Config(documents=documents, tokens=tokens)

86
tests/test_config.py Normal file
View File

@@ -0,0 +1,86 @@
import pytest
from grist_mcp.config import load_config, Config, Document, Token, TokenScope
def test_load_config_parses_documents(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
my-doc:
url: https://grist.example.com
doc_id: abc123
api_key: secret-key
tokens: []
""")
config = load_config(str(config_file))
assert "my-doc" in config.documents
doc = config.documents["my-doc"]
assert doc.url == "https://grist.example.com"
assert doc.doc_id == "abc123"
assert doc.api_key == "secret-key"
def test_load_config_parses_tokens(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
budget:
url: https://grist.example.com
doc_id: abc123
api_key: key123
tokens:
- token: my-secret-token
name: test-agent
scope:
- document: budget
permissions: [read, write]
""")
config = load_config(str(config_file))
assert len(config.tokens) == 1
token = config.tokens[0]
assert token.token == "my-secret-token"
assert token.name == "test-agent"
assert len(token.scope) == 1
assert token.scope[0].document == "budget"
assert token.scope[0].permissions == ["read", "write"]
def test_load_config_substitutes_env_vars(tmp_path, monkeypatch):
monkeypatch.setenv("TEST_API_KEY", "env-secret-key")
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
my-doc:
url: https://grist.example.com
doc_id: abc123
api_key: ${TEST_API_KEY}
tokens: []
""")
config = load_config(str(config_file))
assert config.documents["my-doc"].api_key == "env-secret-key"
def test_load_config_raises_on_missing_env_var(tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
documents:
my-doc:
url: https://grist.example.com
doc_id: abc123
api_key: ${MISSING_VAR}
tokens: []
""")
with pytest.raises(ValueError, match="MISSING_VAR"):
load_config(str(config_file))