feat: add config loading with env var substitution
This commit is contained in:
40
config.yaml.example
Normal file
40
config.yaml.example
Normal 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
99
src/grist_mcp/config.py
Normal 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
86
tests/test_config.py
Normal 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))
|
||||
Reference in New Issue
Block a user