Skip to content

Commit 3bbc609

Browse files
Merge pull request #11 from keboola/KAB-817-statefull-sessions
KAB-817: add the concept of "session state factory"
2 parents b44a727 + 8b2ef5f commit 3bbc609

File tree

7 files changed

+466
-409
lines changed

7 files changed

+466
-409
lines changed

src/keboola_mcp_server/cli.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Command-line interface for the Keboola MCP server."""
22

33
import argparse
4-
import asyncio
54
import logging
65
import sys
76
from typing import List, Optional
@@ -24,7 +23,7 @@ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace:
2423
parser = argparse.ArgumentParser(description="Keboola MCP Server")
2524
parser.add_argument(
2625
"--transport",
27-
choices=["stdio"],
26+
choices=["stdio", "sse"],
2827
default="stdio",
2928
help="Transport to use for MCP communication",
3029
)
@@ -35,7 +34,7 @@ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace:
3534
help="Logging level",
3635
)
3736
parser.add_argument(
38-
"--api-url", help="Keboola Storage API URL (defaults to https://connection.keboola.com)"
37+
"--api-url", default="https://connection.keboola.com", help="Keboola Storage API URL"
3938
)
4039

4140
return parser.parse_args(args)
@@ -49,11 +48,10 @@ def main(args: Optional[List[str]] = None) -> None:
4948
"""
5049
parsed_args = parse_args(args)
5150

52-
# Create config from environment, but override with CLI args
53-
config = Config.from_env()
54-
if parsed_args.api_url:
55-
config.storage_api_url = parsed_args.api_url
56-
config.log_level = parsed_args.log_level
51+
# Create config from the CLI arguments
52+
config = Config.from_dict(
53+
{"storage_api_url": parsed_args.api_url, "log_level": parsed_args.log_level}
54+
)
5755

5856
try:
5957
# Create and run server

src/keboola_mcp_server/config.py

Lines changed: 47 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
"""Configuration handling for the Keboola MCP server."""
22

3+
import dataclasses
34
import logging
4-
import os
55
from dataclasses import dataclass
6-
from typing import Optional
6+
from typing import Mapping, Optional
77

88
logger = logging.getLogger(__name__)
99

1010

11-
@dataclass
11+
@dataclass(frozen=True)
1212
class Config:
1313
"""Server configuration."""
1414

15-
storage_token: str
15+
storage_token: Optional[str] = None
1616
storage_api_url: str = "https://connection.keboola.com"
1717
log_level: str = "INFO"
1818
# Add Snowflake credentials
@@ -24,71 +24,42 @@ class Config:
2424
snowflake_schema: Optional[str] = None
2525
snowflake_role: Optional[str] = None
2626

27-
def __init__(
28-
self,
29-
storage_token: str,
30-
storage_api_url: str = "https://connection.keboola.com",
31-
snowflake_account: Optional[str] = None,
32-
snowflake_user: Optional[str] = None,
33-
snowflake_password: Optional[str] = None,
34-
snowflake_warehouse: Optional[str] = None,
35-
snowflake_database: Optional[str] = None,
36-
snowflake_role: Optional[str] = None,
37-
snowflake_schema: Optional[str] = None,
38-
log_level: str = "INFO",
39-
):
40-
self.storage_token = storage_token
41-
self.storage_api_url = storage_api_url
42-
self.snowflake_account = snowflake_account
43-
self.snowflake_user = snowflake_user
44-
self.snowflake_password = snowflake_password
45-
self.snowflake_warehouse = snowflake_warehouse
46-
self.snowflake_database = snowflake_database
47-
self.snowflake_role = snowflake_role
48-
self.snowflake_schema = snowflake_schema
49-
self.log_level = log_level
27+
@classmethod
28+
def _read_options(cls, d: Mapping[str, str]) -> Mapping[str, str]:
29+
options: dict[str, str] = {}
30+
for f in dataclasses.fields(cls):
31+
if f.name in d:
32+
options[f.name] = d.get(f.name)
33+
elif (dict_name := f"KBC_{f.name.upper()}") in d:
34+
options[f.name] = d.get(dict_name)
35+
return options
5036

5137
@classmethod
52-
def from_env(cls) -> "Config":
53-
"""Create config from environment variables."""
54-
# Add debug logging using logger instead of print
55-
for env_var in [
56-
"KBC_SNOWFLAKE_ACCOUNT",
57-
"KBC_SNOWFLAKE_USER",
58-
"KBC_SNOWFLAKE_PASSWORD",
59-
"KBC_SNOWFLAKE_WAREHOUSE",
60-
"KBC_SNOWFLAKE_DATABASE",
61-
"KBC_SNOWFLAKE_ROLE",
62-
"KBC_SNOWFLAKE_SCHEMA",
63-
]:
64-
logger.debug(f"Reading {env_var}: {'set' if os.getenv(env_var) else 'not set'}")
38+
def from_dict(cls, d: Mapping[str, str]) -> "Config":
39+
"""
40+
Creates new `Config` instance with values read from the input mapping.
41+
The keys in the input mapping can either be the names of the fields in `Config` class
42+
or their uppercase variant prefixed with 'KBC_'.
43+
"""
44+
return cls(**cls._read_options(d))
6545

66-
storage_token = os.getenv("KBC_STORAGE_TOKEN")
67-
if not storage_token:
68-
raise ValueError("KBC_STORAGE_TOKEN environment variable is required")
46+
def replace_by(self, d: Mapping[str, str]) -> "Config":
47+
"""
48+
Creates new `Config` instance from the existing one by replacing the values from the input mapping.
49+
The keys in the input mapping can either be the names of the fields in `Config` class
50+
or their uppercase variant prefixed with 'KBC_'.
51+
"""
52+
return dataclasses.replace(self, **self._read_options(d))
6953

70-
return cls(
71-
storage_token=storage_token,
72-
storage_api_url=os.getenv("KBC_STORAGE_API_URL", "https://connection.keboola.com"),
73-
snowflake_account=os.getenv("KBC_SNOWFLAKE_ACCOUNT"),
74-
snowflake_user=os.getenv("KBC_SNOWFLAKE_USER"),
75-
snowflake_password=os.getenv("KBC_SNOWFLAKE_PASSWORD"),
76-
snowflake_warehouse=os.getenv("KBC_SNOWFLAKE_WAREHOUSE"),
77-
snowflake_database=os.getenv("KBC_SNOWFLAKE_DATABASE"),
78-
snowflake_role=os.getenv("KBC_SNOWFLAKE_ROLE"),
79-
snowflake_schema=os.getenv("KBC_SNOWFLAKE_SCHEMA"),
80-
log_level=os.getenv("KBC_LOG_LEVEL", "INFO"),
54+
def has_storage_config(self) -> bool:
55+
"""Check if Storage API configuration is complete."""
56+
return all(
57+
[
58+
self.storage_token,
59+
self.storage_api_url,
60+
]
8161
)
8262

83-
def validate(self) -> None:
84-
"""Validate the configuration."""
85-
if not self.storage_token:
86-
raise ValueError("Storage token not configured")
87-
if not self.storage_api_url:
88-
raise ValueError("Storage API URL is required")
89-
if self.log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
90-
raise ValueError(f"Invalid log level: {self.log_level}")
91-
9263
def has_snowflake_config(self) -> bool:
9364
"""Check if Snowflake configuration is complete."""
9465
return all(
@@ -100,3 +71,16 @@ def has_snowflake_config(self) -> bool:
10071
self.snowflake_database,
10172
]
10273
)
74+
75+
def __repr__(self):
76+
params: list[str] = []
77+
for f in dataclasses.fields(self):
78+
value = getattr(self, f.name)
79+
if value:
80+
if "token" in f.name or "password" in f.name:
81+
params.append(f"{f.name}='****'")
82+
else:
83+
params.append(f"{f.name}='{value}'")
84+
else:
85+
params.append(f"{f.name}=None")
86+
return f'Config({", ".join(params)})'

src/keboola_mcp_server/database.py

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22

33
import logging
44
from contextlib import contextmanager
5-
6-
import snowflake.connector
75
from dataclasses import dataclass
8-
from typing import Optional, List, Tuple, Dict, Any
6+
from typing import Any, Dict, List
97

10-
from .config import Config
8+
import snowflake.connector
119

1210
logger = logging.getLogger(__name__)
1311

@@ -179,38 +177,25 @@ def find_working_connection(self) -> str:
179177
error_msg += f" - {pattern} ({db}): {error}\n"
180178
raise DatabaseConnectionError(error_msg)
181179

180+
def create_snowflake_connection(self) -> snowflake.connector.connection:
181+
"""Create and return a Snowflake connection using configured credentials.
182+
Raises:
183+
ValueError: If credentials are not fully configured or connection fails
184+
"""
185+
try:
186+
database = self.find_working_connection()
187+
conn = snowflake.connector.connect(
188+
account=self.config.snowflake_account,
189+
user=self.config.snowflake_user,
190+
password=self.config.snowflake_password,
191+
warehouse=self.config.snowflake_warehouse,
192+
database=database,
193+
schema=self.config.snowflake_schema,
194+
role=self.config.snowflake_role,
195+
)
182196

183-
def create_snowflake_connection(config: Config) -> snowflake.connector.connection:
184-
"""Create a return a Snowflake connection using configured credentials.
185-
186-
Args:
187-
config: Configuration object containing Snowflake credentials
188-
189-
Returns:
190-
snowflake.connector.connection: established Snowflake connection
191-
192-
Raises:
193-
ValueError: If credentials are not fully configured or connection fails
194-
"""
195-
if not config.has_snowflake_config():
196-
raise ValueError("Snowflake credentials are not fully configured")
197-
198-
try:
199-
connection_manager = ConnectionManager(config)
200-
database = connection_manager.find_working_connection()
201-
202-
conn = snowflake.connector.connect(
203-
account=config.snowflake_account,
204-
user=config.snowflake_user,
205-
password=config.snowflake_password,
206-
warehouse=config.snowflake_warehouse,
207-
database=database,
208-
schema=config.snowflake_schema,
209-
role=config.snowflake_role,
210-
)
211-
212-
return conn
213-
except DatabaseConnectionError as e:
214-
raise ValueError(f"Failed to find working database connection: {str(e)}")
215-
except Exception as e:
216-
raise ValueError(f"Failed to create Snowflake connection: {str(e)}")
197+
return conn
198+
except DatabaseConnectionError as e:
199+
raise ValueError(f"Failed to find working database connection: {str(e)}")
200+
except Exception as e:
201+
raise ValueError(f"Failed to create Snowflake connection: {str(e)}")

0 commit comments

Comments
 (0)