Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,32 @@ Claude will:
4. Test it with `dryrun_pipeline` tool

## Security
All queries pass through a security gate that:

### Database User Configuration (Recommended)

For production deployments, create a **read-only database user** for the MCP server. This provides defense-in-depth security at the database level.

Configure a read-only user in GreptimeDB using [static user provider](https://docs.greptime.com/user-guide/deployments-administration/authentication/static/#permission-modes):

```
# User format: username:permission_mode=password
mcp_readonly:readonly=your_secure_password
```

Permission modes:
- `readonly` (or `ro`) - Can only query data (recommended for MCP server)
- `writeonly` (or `wo`) - Can only write data
- `readwrite` (or `rw`) - Full access (default)

Then configure the MCP server to use this user:
```bash
GREPTIMEDB_USER=mcp_readonly
GREPTIMEDB_PASSWORD=your_secure_password
```

### Application-Level Security Gate

All queries also pass through a security gate that:
- Blocks DDL/DML operations: DROP, DELETE, TRUNCATE, UPDATE, INSERT, ALTER, CREATE, GRANT, REVOKE
- Blocks dynamic SQL execution: EXEC, EXECUTE, CALL
- Blocks data modification: REPLACE INTO
Expand Down Expand Up @@ -178,6 +203,11 @@ GREPTIMEDB_TIMEZONE=UTC
GREPTIMEDB_POOL_SIZE=5 # Optional: Connection pool size (defaults to 5)
GREPTIMEDB_MASK_ENABLED=true # Optional: Enable data masking (defaults to true)
GREPTIMEDB_MASK_PATTERNS= # Optional: Additional sensitive column patterns (comma-separated)

# MCP Server Transport Options
GREPTIMEDB_TRANSPORT=stdio # Optional: Transport mode (stdio, sse, streamable-http, defaults to stdio)
GREPTIMEDB_LISTEN_HOST=0.0.0.0 # Optional: HTTP server bind host (defaults to 0.0.0.0)
GREPTIMEDB_LISTEN_PORT=8080 # Optional: HTTP server bind port (defaults to 8080)
```

Or via command-line args:
Expand All @@ -192,7 +222,33 @@ Or via command-line args:
* `--timezone` the session time zone, empty by default (using server default time zone),
* `--pool-size` the connection pool size, `5` by default,
* `--mask-enabled` enable data masking for sensitive columns, `true` by default,
* `--mask-patterns` additional sensitive column patterns (comma-separated), empty by default.
* `--mask-patterns` additional sensitive column patterns (comma-separated), empty by default,
* `--transport` MCP transport mode (`stdio`, `sse`, `streamable-http`), `stdio` by default,
* `--listen-host` HTTP server bind host (for sse/streamable-http), `0.0.0.0` by default,
* `--listen-port` HTTP server bind port (for sse/streamable-http), `8080` by default.

## HTTP Server Mode

For containerized or Kubernetes deployments, you can run the MCP server in HTTP mode instead of stdio:

```bash
# Streamable HTTP mode (recommended for production)
greptimedb-mcp-server --transport streamable-http --listen-port 8080

# SSE mode (legacy, for older clients)
greptimedb-mcp-server --transport sse --listen-host 0.0.0.0 --listen-port 3000

# Via environment variables (for Docker/K8s)
GREPTIMEDB_TRANSPORT=streamable-http \
GREPTIMEDB_LISTEN_HOST=0.0.0.0 \
GREPTIMEDB_LISTEN_PORT=8080 \
greptimedb-mcp-server
```

**Transport modes:**
- `stdio` (default): Standard input/output, for local CLI integration (e.g., Claude Desktop)
- `streamable-http`: HTTP-based transport with SSE streaming, recommended for remote/production deployments
- `sse`: Server-Sent Events transport (legacy, being deprecated in MCP spec)

# Usage

Expand Down
40 changes: 40 additions & 0 deletions src/greptimedb_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ class Config:
Additional sensitive column patterns (comma-separated)
"""

transport: str
"""
MCP transport mode: stdio, sse, or streamable-http
"""

listen_host: str
"""
MCP HTTP server bind host (for sse/streamable-http transports)
"""

listen_port: int
"""
MCP HTTP server bind port (for sse/streamable-http transports)
"""

@staticmethod
def from_env_arguments() -> "Config":
"""
Expand Down Expand Up @@ -149,6 +164,28 @@ def from_env_arguments() -> "Config":
default=os.getenv("GREPTIMEDB_MASK_PATTERNS", ""),
)

parser.add_argument(
"--transport",
type=str,
choices=["stdio", "sse", "streamable-http"],
help="MCP transport mode (default: stdio)",
default=os.getenv("GREPTIMEDB_TRANSPORT", "stdio"),
)

parser.add_argument(
"--listen-host",
type=str,
help="MCP HTTP server bind host (default: 0.0.0.0)",
default=os.getenv("GREPTIMEDB_LISTEN_HOST", "0.0.0.0"),
)

parser.add_argument(
"--listen-port",
type=int,
help="MCP HTTP server bind port (default: 8080)",
default=int(os.getenv("GREPTIMEDB_LISTEN_PORT", "8080")),
)

args = parser.parse_args()
return Config(
host=args.host,
Expand All @@ -162,4 +199,7 @@ def from_env_arguments() -> "Config":
http_protocol=args.http_protocol,
mask_enabled=args.mask_enabled,
mask_patterns=args.mask_patterns,
transport=args.transport,
listen_host=args.listen_host,
listen_port=args.listen_port,
)
56 changes: 48 additions & 8 deletions src/greptimedb_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
validate_query_component,
validate_duration,
validate_fill,
validate_time_expression,
format_tql_time_param,
)

import asyncio
Expand Down Expand Up @@ -77,10 +79,26 @@ def get_http_auth(self) -> aiohttp.BasicAuth | None:
return None


# Global config (set by main() before run())
_config: Config | None = None

# Global state (initialized in lifespan)
_state: AppState | None = None


def get_config() -> Config:
"""Get the parsed configuration.

Falls back to parsing from env/args if not pre-initialized by main().
This preserves compatibility with alternative entry points like
`mcp dev greptimedb_mcp_server.server:mcp` or programmatic imports.
"""
global _config
if _config is None:
_config = Config.from_env_arguments()
return _config


def get_state() -> AppState:
"""Get the application state."""
if _state is None:
Expand All @@ -93,7 +111,7 @@ async def lifespan(mcp: FastMCP):
"""Initialize application state on startup."""
global _state

config = Config.from_env_arguments()
config = get_config()
db_config = {
"host": config.host,
"port": config.port,
Expand Down Expand Up @@ -337,9 +355,14 @@ async def execute_tql(
"Example: rate(http_requests_total[5m])",
],
start: Annotated[
str, "Start time (RFC3339, Unix timestamp, or relative like 'now-1h')"
str,
"Start time: SQL expression (e.g., \"now() - interval '5' minute\"), "
"RFC3339 (e.g., '2024-01-01T00:00:00Z'), or Unix timestamp",
],
end: Annotated[
str,
"End time: SQL expression (e.g., 'now()'), " "RFC3339, or Unix timestamp",
],
end: Annotated[str, "End time (RFC3339, Unix timestamp, or relative like 'now')"],
step: Annotated[str, "Query resolution step, e.g., '1m', '5m', '1h'"],
lookback: Annotated[str | None, "Lookback delta for range queries"] = None,
format: Annotated[
Expand All @@ -354,8 +377,8 @@ async def execute_tql(
if format not in VALID_FORMATS:
raise ValueError(f"Invalid format: {format}. Must be one of: {VALID_FORMATS}")

validate_tql_param(start, "start")
validate_tql_param(end, "end")
validate_time_expression(start, "start")
validate_time_expression(end, "end")
validate_tql_param(step, "step")
if lookback:
validate_tql_param(lookback, "lookback")
Expand All @@ -364,10 +387,12 @@ async def execute_tql(
if is_dangerous:
return f"Error: Dangerous operation blocked: {reason}"

start_param = format_tql_time_param(start)
end_param = format_tql_time_param(end)
if lookback:
tql = f"TQL EVAL ('{start}', '{end}', '{step}', '{lookback}') {query}"
tql = f"TQL EVAL ({start_param}, {end_param}, '{step}', '{lookback}') {query}"
else:
tql = f"TQL EVAL ('{start}', '{end}', '{step}') {query}"
tql = f"TQL EVAL ({start_param}, {end_param}, '{step}') {query}"

start_time = time.time()

Expand Down Expand Up @@ -834,7 +859,22 @@ def prompt_fn({arg_params}) -> str:

def main():
"""Main entry point."""
mcp.run()
global _config
_config = Config.from_env_arguments()

# Only configure HTTP server settings for non-stdio transports
# to avoid overriding user's programmatic configuration
if _config.transport != "stdio":
mcp.settings.host = _config.listen_host
mcp.settings.port = _config.listen_port
logger.info(
f"Starting MCP server with transport: {_config.transport} "
f"on {_config.listen_host}:{_config.listen_port}"
)
else:
logger.info("Starting MCP server with transport: stdio")

mcp.run(transport=_config.transport)


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ arguments:
description: "The metric name to query (required)."
required: true
- name: "start_time"
description: "Query start time (e.g., 'now-1h' or ISO timestamp)."
description: "Start time: SQL expression (now() - interval '5' minute), RFC3339, or Unix timestamp."
required: true
- name: "end_time"
description: "Query end time (e.g., 'now' or ISO timestamp)."
description: "End time: SQL expression (now()), RFC3339, or Unix timestamp."
required: true
metadata:
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ histogram_quantile(0.99, rate({{ metric }}_bucket[5m])) > 0.5
## Notes

- Use `execute_tql` tool with: query, start, end, step (required), lookback (optional)
- Time formats: SQL expression (now(), now() - interval '5' minute), RFC3339, or Unix timestamp
- Label matchers: `=`, `!=`, `=~` (regex), `!~`
- Time durations: s, m, h, d, w

Expand Down
29 changes: 29 additions & 0 deletions src/greptimedb_mcp_server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,32 @@ def validate_fill(value: str) -> str:
if not FILL_PATTERN.match(value):
raise ValueError("Invalid fill: must be NULL, PREV, LINEAR, or a number")
return value


def is_sql_time_expression(value: str) -> bool:
"""Check if value is a SQL time expression (contains function call)."""
return "(" in value


def format_tql_time_param(value: str) -> str:
"""Format time parameter for TQL: quote literals, leave SQL expressions as-is."""
if is_sql_time_expression(value):
return value
# Escape single quotes in literal values to avoid breaking the TQL statement
safe_value = value.replace("'", "''")
return f"'{safe_value}'"


def validate_time_expression(value: str, name: str) -> str:
"""Validate time expression for TQL start/end parameters."""
if not value:
raise ValueError(f"{name} is required")
if ";" in value or "--" in value:
raise ValueError(f"Invalid characters in {name}")
# Guard against malformed or injected strings with unbalanced quotes
if value.count("'") % 2 != 0:
raise ValueError(f"Unbalanced quotes in {name}")
is_dangerous, reason = security_gate(value)
if is_dangerous:
raise ValueError(f"Dangerous pattern in {name}: {reason}")
return value
30 changes: 30 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def test_config_default_values():
assert config.http_protocol == "http"
assert config.mask_enabled is True
assert config.mask_patterns == ""
assert config.transport == "stdio"
assert config.listen_host == "0.0.0.0"
assert config.listen_port == 8080


def test_config_env_variables():
Expand All @@ -36,6 +39,9 @@ def test_config_env_variables():
"GREPTIMEDB_HTTP_PROTOCOL": "https",
"GREPTIMEDB_MASK_ENABLED": "false",
"GREPTIMEDB_MASK_PATTERNS": "phone,address",
"GREPTIMEDB_TRANSPORT": "streamable-http",
"GREPTIMEDB_LISTEN_HOST": "127.0.0.1",
"GREPTIMEDB_LISTEN_PORT": "3000",
}

with patch.dict(os.environ, env_vars):
Expand All @@ -51,6 +57,9 @@ def test_config_env_variables():
assert config.http_protocol == "https"
assert config.mask_enabled is False
assert config.mask_patterns == "phone,address"
assert config.transport == "streamable-http"
assert config.listen_host == "127.0.0.1"
assert config.listen_port == 3000


def test_config_cli_arguments():
Expand All @@ -77,6 +86,12 @@ def test_config_cli_arguments():
"false",
"--mask-patterns",
"custom1,custom2",
"--transport",
"sse",
"--listen-host",
"192.168.1.1",
"--listen-port",
"9090",
]

with patch.dict(os.environ, {}, clear=True):
Expand All @@ -92,6 +107,9 @@ def test_config_cli_arguments():
assert config.http_protocol == "https"
assert config.mask_enabled is False
assert config.mask_patterns == "custom1,custom2"
assert config.transport == "sse"
assert config.listen_host == "192.168.1.1"
assert config.listen_port == 9090


def test_config_precedence():
Expand All @@ -108,6 +126,9 @@ def test_config_precedence():
"GREPTIMEDB_HTTP_PROTOCOL": "http",
"GREPTIMEDB_MASK_ENABLED": "true",
"GREPTIMEDB_MASK_PATTERNS": "env_pattern",
"GREPTIMEDB_TRANSPORT": "stdio",
"GREPTIMEDB_LISTEN_HOST": "env-listen-host",
"GREPTIMEDB_LISTEN_PORT": "1111",
}

cli_args = [
Expand All @@ -130,6 +151,12 @@ def test_config_precedence():
"false",
"--mask-patterns",
"cli_pattern",
"--transport",
"streamable-http",
"--listen-host",
"cli-listen-host",
"--listen-port",
"2222",
]

with patch.dict(os.environ, env_vars):
Expand All @@ -145,3 +172,6 @@ def test_config_precedence():
assert config.http_protocol == "https"
assert config.mask_enabled is False
assert config.mask_patterns == "cli_pattern"
assert config.transport == "streamable-http"
assert config.listen_host == "cli-listen-host"
assert config.listen_port == 2222
Loading
Loading