diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..96f50df --- /dev/null +++ b/config.yaml.example @@ -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] diff --git a/src/grist_mcp/config.py b/src/grist_mcp/config.py new file mode 100644 index 0000000..a2c7c54 --- /dev/null +++ b/src/grist_mcp/config.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ccce36b --- /dev/null +++ b/tests/test_config.py @@ -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))